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.
| Check | Thrown message |
|---|---|
name non-blank | a harness must have a name |
version matches ^\d+\.\d+\.\d+$ | version must be MAJOR.MINOR.PATCH, got '…' |
connectorVersion matches the same regex | connectorVersion must be MAJOR.MINOR.PATCH, got '…' |
models.length > 0 | a harness must declare at least one model role — the Service binds one Model per role |
| model role names unique | model role names must be unique within a harness |
| required slot names unique | required slot names must be unique |
phase order values unique | phase orders must be unique so the UI skeleton is unambiguous |
every phase order a positive integer | phase 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;
}| Builder | Default required | Default sensitive |
|---|---|---|
i.url(o?) | true | false |
i.domain(o?) | true | false |
i.string(o?) | true | false |
i.number(o?) | true | false |
i.boolean(o?) | false | false |
i.enum(options, o?) | true | false |
i.list(item, o?) | false | false |
i.credential(o?) | false | true |
i.cloudCredential(o?) | false | true |
i.secret(o?) | false | true |
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.