VERIFISDK docsengine v2 · contract 1.5.1 · harness-sdk 1.2.0
Harness authoring

defineHarness

The harness author's entry point — every field, what it validates at authoring time, and why.


defineHarness is the only function you are required to call. It validates the definition and stamps it with a brand so anything loading a harness module can check it.

import { defineHarness, i, isHarness } from '@pragyacyber/harness-sdk';

Source: verifi-harness-sdk/src/define-harness.ts.

The definition

export interface HarnessDefinition {
  name: string;
  version: string;
  /** The engine-contract major this harness targets; checked at handshake. */
  connectorVersion: string;
  topology?: 'lead_subagent';
  inputs: InputSchema;
  slots: { required: SlotDecl[]; optional?: SlotDecl[] };
  models: ModelRoleDecl[];
  phases: PhaseDecl[];
  knowledgeBases?: { domain: string; required: boolean }[];
  concurrency?: { maxSubAgents: number; maxParallelTools: number };
  resources?: { cpu: string; memoryMb: number; maxRuntimeMinutes: number };
  declarePlan(input: Record<string, unknown>): PlanDeclaration;
  run(ctx: HarnessContext): Promise<{
    status: 'done' | 'failed';
    findings: number;
    coverage: string[];
    exitReason: string;
  }>;
}

defineHarness(def) returns Harness, which is HarnessDefinition & { readonly __verifiHarness: true }.

What it validates, and what happens if you get it wrong

Validation runs at authoring time, on your machine, rather than at provision time — a harness that declares no model role or an un-orderable phase list should fail when you import the module, not thirty seconds into a client's scan.

CheckThrown message
name non-blanka harness must have a name
version matches ^\d+\.\d+\.\d+$version must be MAJOR.MINOR.PATCH, got '…'
connectorVersion matches the same regexconnectorVersion must be MAJOR.MINOR.PATCH, got '…'
models.length > 0a harness must declare at least one model role — the Service binds one Model per role
model role names uniquemodel role names must be unique within a harness
required slot names uniquerequired slot names must be unique
phase order values uniquephase orders must be unique so the UI skeleton is unambiguous
every phase order a positive integerphase order must be a positive integer starting at 1 — 'discover' is 0. The contract refuses 0, so a harness numbering from zero would fail at registration.

defineHarness does not validate inputs, declarePlan's return value, resources, or concurrency. Those are declarations the platform reads; nothing in the SDK enforces them at authoring time.

name and version

name identifies the harness across registrations. version is the harness's own semver. Neither is checked against the registry by the SDK.

connectorVersion

The @pragyacyber/engine-contract major this harness targets. runHarness checks it before anything else:

if (!isCompatible(harness.connectorVersion)) {
  throw new ConnectorVersionError(harness.connectorVersion, CONTRACT_VERSION);
}

isCompatible compares MAJOR only, and treats anything unparseable as incompatible. Because a major is additive-only, a newer engine understands every older minor of that major by construction — so within 1.x there is no support window. Verified:

isCompatible('1.0.0') -> true
isCompatible('2.0.0') -> false
isCompatible('1.0')   -> false   // malformed is never "compatible"

The production ASM harness targets 1.0.0 while the shipped contract is 1.5.1, and handshakes fine. Declaring the lowest 1.x you actually rely on is the honest thing to do: the check only reads the major, so a higher number buys you nothing and misdescribes what you need.

inputs and the i.* builder

inputs is a Record<string, InputField>. The i.* builder is the intended way to write them; it fills sensible defaults, and it is the only place the sensitivity default is decided.

export interface InputField {
  type: 'url' | 'domain' | 'credential' | 'cloud_credential' | 'secret'
      | 'string' | 'number' | 'boolean' | 'enum' | 'list';
  label: string;
  description?: string;
  required: boolean;
  /** Sensitive values are stored as credRef and masked; they never appear in logs. */
  sensitive: boolean;
  options?: string[];
  item?: InputField;
}
BuilderDefault requiredDefault sensitive
i.url(o?)truefalse
i.domain(o?)truefalse
i.string(o?)truefalse
i.number(o?)truefalse
i.boolean(o?)falsefalse
i.enum(options, o?)truefalse
i.list(item, o?)falsefalse
i.credential(o?)falsetrue
i.cloudCredential(o?)falsetrue
i.secret(o?)falsetrue
inputs: {
  domain: i.domain({ label: 'Target domain', description: 'Apex domain of the authorized target.' }),
  includeSubdomains: i.boolean({ label: 'Scan discovered subdomains' }),
  mode: i.enum(['fast', 'thorough'], { label: 'Sweep depth' }),
  extraScope: i.list(i.domain(), { label: 'Additional in-scope hosts' }),
  apiKey: i.secret({ label: 'API key for the target' }),
}

At runtime the values arrive as ctx.input, typed Record<string, unknown>. The SDK does not coerce or validate them against your inputs declaration — read them defensively:

const domain = String(ctx.input['domain'] ?? '').trim();
const apexOnly = ctx.input['includeSubdomains'] === false;

slots

slots: {
  required: [{ name: 'kali', kind: 'exec' }],
  optional: [{ name: 'web_proxy', kind: 'web_proxy' }],
}

name is what you pass to ctx.mcp(name). kind describes the capability the slot needs so an admin can bind a suitable MCP — exec, recon, web_proxy, cloud, kb are the kinds used in the contract's own comments. Nothing constrains kind to a closed set.

Calling ctx.mcp() for a slot the manifest did not fill throws SlotNotFilledError immediately. That is deliberate: a silently-skipped slot is how a scan finishes green having tested nothing.

models

export interface ModelRoleDecl {
  name: string;
  recommendedTier: ModelTier;      // 'frontier' | 'balanced' | 'fast' | 'local'
  required: boolean;
  description?: string;
}

name is developer-chosen, not an enum — planner, attacker, scout, analyst, anything. A Service binds one Model per role, and the same Model may fill several roles.

phases

phases: [
  { key: 'surface', label: 'Surface map', order: 1 },
  { key: 'scan', label: 'Category toolchain', order: 2 },
  { key: 'assess', label: 'Risk model + vulnerabilities', order: 3 },
]

order must be unique and a positive integer, so number from 1. These power the progress skeleton in the console and the dry-run event stream. Emitting phase_start / phase_end events at runtime is your job — the SDK only emits them automatically on the dry-run path.

declarePlan(input)

export interface PlanDeclaration {
  phases: PhaseDecl[];
  /** e.g. WSTG ids. What the harness INTENDS to cover — measured against later. */
  coverage: string[];
}

It exists for three things: powering the dry-run wiring smoke, giving the UI a progress skeleton, and stating coverage intent so gaps can be reported against it.

coverage strings are opaque to the SDK. The ASM harness uses a dotted taxonomy (asm.surface.subdomains, asm.tls.protocols); the SDK README's example uses WSTG ids (WSTG-SESS-03). Pick one and stay consistent — a coverage gap is computed by comparing declared coverage against what run() returns.

A zPlanDeclaration zod schema is exported if you want to validate your own output.

resources and concurrency

Both optional, both declarations rather than enforcement:

concurrency: { maxSubAgents: 1, maxParallelTools: 4 },
resources: { cpu: '2', memoryMb: 4096, maxRuntimeMinutes: 1440 },

The ASM harness sets maxRuntimeMinutes: 1440 with the comment that a full-surface sweep with heavy tools legitimately runs for hours, and that time is the only ceiling — findings stream out as they are found, so a long run is safe.

run(ctx)

run(ctx: HarnessContext): Promise<{
  status: 'done' | 'failed';
  findings: number;
  coverage: string[];
  exitReason: string;
}>

exitReason is required on every terminal run — zRun refuses to parse a terminal run without one, because the previous engine marked a no-op job done and stranded another in queued forever with no record of why.

findings is reconciled by the runtime: runHarness uses result.findings || findings, where the right-hand side is its own count of emitFinding calls. Returning 0 while having emitted findings is therefore safe, and returning your own count is also safe. A thrown error is caught, converted to status: 'failed' with your error message as exitReason, and reported — findings already streamed are unaffected.

isHarness(value)

export function isHarness(value: unknown): value is Harness;

A runtime guard for anything dynamically loading a harness module. It checks for the __verifiHarness brand.