---
name: verifi-harness
description: Author, run and debug a VERIFI engine v2 harness with @pragyacyber/harness-sdk. Use when writing defineHarness, calling ctx.mcp/ctx.emitFinding/ctx.budget, wiring EngineChannel or GrpcEngineChannel, packaging a harness container, or debugging a run that produced no findings.
---

# Authoring a VERIFI harness

Full reference: https://docs.verifi.pragyacyber.com/

## Install, before anything else

The SDKs are PRIVATE packages in GitHub Packages, scope `@pragyacyber`. Not npmjs.

```
# .npmrc — checked in. Token REFERENCED, never written into this file.
@pragyacyber:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
```

```bash
gh auth refresh -s read:packages          # adds the scope to the gh CLI's token
export NODE_AUTH_TOKEN=$(gh auth token)
npm view @pragyacyber/harness-sdk version # PROVE auth before writing any code
npm i @pragyacyber/harness-sdk@^1.0.0 @pragyacyber/engine-contract@^1.5.0
npm i -D typescript tsx @types/node
```

`404` means the account has no read access to the package — a manual grant by
someone in the org, per user AND per consuming repository. Not fixable from
here: STOP and say so. `401` means the token is missing, unscoped, expired, or
fine-grained (use a classic PAT).

Both packages are `1.x`, so use a CARET. An exact pin was a workaround for `0.x`
caret semantics and is no longer needed. Two copies of `engine-contract` in one
tree break type assignability — check `npm ls @pragyacyber/engine-contract`.

In a Dockerfile the token is a BuildKit secret, NEVER a build ARG — an ARG is
readable in the image history and ships the credential in every layer:

```dockerfile
RUN --mount=type=secret,id=npm_token \
    { echo "@pragyacyber:registry=https://npm.pkg.github.com"; \
      echo "//npm.pkg.github.com/:_authToken=$(cat /run/secrets/npm_token)"; } > .npmrc \
    && npm ci --omit=dev && rm -f .npmrc
```

```bash
DOCKER_BUILDKIT=1 docker build --secret id=npm_token,env=NPM_TOKEN -t h:dev .
```

The harness container serves nothing: no `EXPOSE`, no `HEALTHCHECK`. It dials
out, works, exits.

## Ground yourself first

Before writing any call, open
`node_modules/@pragyacyber/harness-sdk/dist/index.d.ts`. If a symbol is not in
there, it does not exist. Do not infer an API from its name.

Runtime exports, complete: `BudgetLedger`, `ConnectorVersionError`,
`FailoverSlot`, `GrpcEngineChannel`, `MODEL_PRICES_PER_MTOK`, `McpClient`,
`ModelClient`, `ModelRoleNotBoundError`, `SDK_VERSION`, `SlotNotFilledError`,
`collectRunSecrets`, `createMcpResolver`, `createModelResolver`,
`credentialsFromEnv`, `defineHarness`, `i`, `isHarness`, `loadEngineProto`,
`priceFor`, `protoPath`, `redact`, `resolveAwsCredentials`, `runHarness`,
`zPlanDeclaration`.

## The skeleton

```ts
import { defineHarness, i, type HarnessContext } from '@pragyacyber/harness-sdk';

export const harness = defineHarness({
  name: 'my-harness',
  version: '0.1.0',
  connectorVersion: '1.0.0',          // MAJOR must match CONTRACT_VERSION
  inputs: { domain: i.domain({ label: 'Target domain' }) },
  slots: { required: [{ name: 'recon', kind: 'exec' }] },
  models: [{ name: 'analyst', recommendedTier: 'balanced', required: false }],
  phases: [{ key: 'discover', label: 'Discovery', order: 1 }],  // order from 1, unique
  declarePlan: () => ({ phases: [...], coverage: ['...'] }),
  async run(ctx: HarnessContext) {
    return { status: 'done' as const, findings: 0, coverage: [], exitReason: '...' };
  },
});
```

`defineHarness` throws on: blank name; non-semver `version` or
`connectorVersion`; empty `models`; duplicate role names; duplicate required slot
names; duplicate phase `order`s; a phase `order` that is not a positive integer.

## Things that are true and surprising

- `models` cannot be empty even if you never call `ctx.model()`.
- `ctx.mcp(slot).call()` NEVER throws. Check `result.ok`.
- `ctx.mcp(slot).listTools()` DOES throw on a JSON-RPC error.
- `result.ok === true` means the MCP call worked, not that the tool succeeded.
  Parse the tool's own exit status out of the text.
- `ctx.emitFinding` is typed `RawFinding` (from `@pragyacyber/engine-contract`).
  The compiler checks field names now. `cves` is PLURAL; `affected` is
  `{url, parameter}[]`; the stable id is `findingKey`, not `dedupeKey`.
- `ctx.input` is `manifest.parameters`. Your SCOPE is `ctx.target.scope`, which
  IS on the context, along with `ctx.hardDeadline`. Check what you touch against
  it rather than trusting an input parameter.
- `ctx.model()` throws only when NO AWS credentials resolve at all (normal
  locally; on Fargate it reads the task-role endpoint, not env vars). It builds
  every request through the contract, so every modelled provider works — `bedrock`
  signs with the run's role, keyed providers use a per-role key the engine injects as
  `VERIFI_MODEL_<ROLE>_PROVIDER`/`_BASEURL`/`_APIKEY` (else the role defaults to
  `bedrock`). A failed call is `ok: false`, not a throw. (Contract routing: harness-sdk
  1.1.0; per-role keyed-provider injection: 1.2.0.)
- `ctx.mcp().call()` has a 300s ceiling. `createMcpResolver` accepts a
  `timeoutMs` but `runHarness` does not expose one, so through the real
  entrypoint it stands. Clamp your tool timeouts to 285s server-side.
- Phase `order` must be a POSITIVE INTEGER in both the SDK and the registry.
  Number from 1. `0` and `1.5` throw.
- Redaction is on: `runHarness` calls `collectRunSecrets()` and scrubs THIS
  container's injected secrets from tool output. It never scrubs a credential the
  scan FOUND — that is the finding.

## Emitting results

```ts
ctx.emitFinding({
  findingKey: 'check-id:asset',       // deterministic; no time, no run id, no severity
  title: '...',
  severity: 'medium',                 // critical | high | medium | low | info
  confidence: 'firm',                 // confirmed | firm | tentative
  category: 'http',
  description: '...',                 // real prose, not the title again
  impact: '...',
  remediation: '...',
  cwe: 'CWE-319',                     // string or string[]; the engine keeps the first
  cves: ['CVE-2024-0001'],            // note the PLURAL key; singular `cve` is ignored
  affected: [{ url: 'https://host' }],// an ARRAY OF OBJECTS, never a bare string
});
```

Emit AS YOU PRODUCE. Never batch to the end — the engine persists on arrival, so a
run that dies late keeps everything it already found.

NEVER emit `reviewStatus`, `publishedAt`, `reviewedBy` or `clientVisible` — both
the schema and the engine refuse them.

`info` IS a valid severity. `informational` is REFUSED, and the error names the
value to use. Whether to emit info at all is a judgement call: the reference ASM
harness treats informational output as inventory and does not emit it.

`zRawFinding` is `.passthrough()`, so extra keys survive — attach your own
evidence. They are carried, not normalised.

## Budget

`ctx.budget` meters. It cannot refuse a spend and must never grow the ability to.
Useful reads: `timeRemaining()`, `pastDeadline()`, `overThreshold()`, `spent()`.
`allocate()` returns a sub-ledger — an accounting split, not a cap.

## Running it locally — copy this, it is complete

No engine, no AWS account, no MCP needed. `EngineChannel` is a plain interface;
implementing it is how you develop.

```ts
import type { RunManifest } from '@pragyacyber/engine-contract';
import { runHarness, type EngineChannel } from '@pragyacyber/harness-sdk';
import { harness } from './harness.js';

const channel: EngineChannel = {
  async handshake(cv, name, version) { console.log(`handshake ${name}@${version} (${cv})`); },
  sendEvent: (e) => console.log('event ', e.type, e.payload),
  sendFinding: (f) => console.log('finding', f['title']),
  async sendDone(o) { console.log(`done   ${o.status}: ${o.exitReason}`); },
  onStop: () => {},
  async close() {},
};

const manifest: RunManifest = {
  runId: 'run_local_1',
  correlationId: 'corr_local_1',
  binding: { tenantId: 'local', assessmentId: 'assess_local', assetId: 'asset_local' },
  target: { scope: ['https://example.com'], excludes: [], authMode: 'none' },
  parameters: { domain: 'example.com' },          // add dryRun: true to skip run()
  composition: {
    serviceId: 'svc_local',
    serviceVersion: 1,
    harnessDigest: 'sha256:' + '0'.repeat(64),
    mcpDigests: {}, modelIds: {}, kbContentVersions: {},
    capturedAt: new Date().toISOString(),
  },
  budget: {
    maxDurationMinutes: 15,
    costUsdThresholds: [1, 5, 25],
    tokenThresholds: [100_000, 500_000],
    toolCallThresholds: [50, 200],
    evidenceByteThresholds: [1_000_000, 10_000_000],
    maxSubAgents: 1,
    checkpointEverySeconds: 30,
  },
  policy: { redaction: 'standard', hitl: 'approve_destructive',
            allowedActions: [], deniedActions: [] },
  endpoints: { recon: 'http://127.0.0.1:8000' },  // slot name -> base URL
  credentialRefs: {},
  hardDeadline: new Date(Date.now() + 15 * 60_000).toISOString(),
  contractVersion: '1.5.1',
};

console.log(await runHarness(harness, manifest, channel));
```

```bash
npx tsx local-run.ts
```

`EngineChannel` is exactly those 6 methods. With nothing listening on the
endpoint, the tool call fails, the harness carries on, and the SDK emits
`tool_error` AND `mcp.no_healthy_tool` — you write neither.

Set `parameters.dryRun = true` and `runHarness` never calls `harness.run()`; it
streams the static `declarePlan()` phases and returns.

A green dry run proves WIRING, not findings. Never report it as evidence the
scanner works.

## Verification — required before claiming success

```bash
npx tsc --noEmit
npx tsx local-run.ts     # paste the actual output
```

State explicitly whether a finding was emitted and what events fired. If
`mcp.no_healthy_tool` appeared, the slot had nothing behind it and the run's
thinness is a plumbing failure, not a clean target.

## Registering the image

By DIGEST, never a tag:

```
POST /registries/harnesses
{ "id": "...", "name": "...", "image": "...", "imageDigest": "sha256:<64 hex>" }
```

`imageDigest` must match `/^sha256:[0-9a-f]{64}$/`; a tag is a 400. Without it a
harness registers and sits in `probing` forever. Get the digest from the registry
after a push (`aws ecr describe-images … --query 'imageDetails[0].imageDigest'`),
not from your local tag.

The probe checks the image is scanned, the scan is recent, and nothing is
critical/high. UNSCANNED fails exactly like VULNERABLE.
