VERIFISDK docsengine v2 · contract 1.5.1 · harness-sdk 1.2.0
Start here

Quickstart

A harness that compiles, runs locally against a fake channel, and streams a finding — in about fifteen minutes.


This builds a harness end to end without any engine, AWS account, or MCP server. Every snippet below was type-checked with tsc --noEmit against @pragyacyber/harness-sdk@1.0.0 and @pragyacyber/engine-contract@1.5.1, and executed; the console output further down is real output from running it. This harness makes no model call, and the model path is the only thing harness-sdk has changed since 1.0.0 (the ctx.model() contract routing in 1.1.0, the per-role keyed-model env resolution in 1.2.0), so the transcript stands unchanged on 1.2.0.

1. Project setup

The packages are private, in GitHub Packages under the @pragyacyber scope. Getting an install to work has a prerequisite that no amount of npm configuration substitutes for: someone in the organisation has to grant your account — and separately, each repository whose CI will install — read access to the package.

Getting the packages is the full path: access requests, the read:packages token, .npmrc, CI, and passing the token to a Docker build without baking it into the image. Do that first; the rest of this page assumes npm install already works.

The short version, once you have access:

gh auth refresh -s read:packages
export NODE_AUTH_TOKEN=$(gh auth token)
# .npmrc — the token is REFERENCED, never written into this file
@pragyacyber:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
npm view @pragyacyber/harness-sdk version   # prove auth before writing any code
npm init -y
npm pkg set type=module
npm i @pragyacyber/harness-sdk@^1.0.0 @pragyacyber/engine-contract@^1.5.0
npm i -D typescript tsx @types/node

tsconfig.json needs Node 22+ semantics and moduleResolution: "bundler" or "node16"; the SDK ships ESM only.

2. Declare the harness

A harness declares what it needs — inputs, slots, model roles, phases — and a Service composition supplies them. It never constructs a provider client or opens its own socket.

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

export const helloHarness = defineHarness({
  name: 'hello-asm',
  version: '0.1.0',
  connectorVersion: '1.0.0',

  inputs: {
    domain: i.domain({ label: 'Target domain', required: true }),
    deep: i.boolean({ label: 'Deep scan', required: false }),
  },

  slots: { required: [{ name: 'recon', kind: 'exec' }] },

  models: [
    { name: 'analyst', recommendedTier: 'balanced', required: false },
  ],

  phases: [
    { key: 'discover', label: 'Discovery', order: 1 },
    { key: 'assess', label: 'Assessment', order: 2 },
  ],

  declarePlan: () => ({
    phases: [
      { key: 'discover', label: 'Discovery', order: 1 },
      { key: 'assess', label: 'Assessment', order: 2 },
    ],
    coverage: ['asm.dns.dnssec', 'asm.network.ports'],
  }),

  async run(ctx: HarnessContext) {
    const domain = String(ctx.input['domain'] ?? '').trim();
    if (!domain) throw new Error('no target domain supplied');

    // The run's own authorized boundary and deadline, straight off the context.
    ctx.log('scope', { scope: ctx.target.scope, until: ctx.hardDeadline });

    const recon = ctx.mcp('recon');
    ctx.emitEvent({ type: 'phase_start', payload: { phase: 'discover' } });

    const result = await recon.call('run_command', {
      command: `dig +short NS ${domain}`,
      timeout: 30,
      workspace: ctx.runId,
    });

    if (!result.ok) {
      ctx.log('nameserver lookup failed', { error: result.error ?? 'unknown' });
    } else if (!result.text.trim()) {
      ctx.emitFinding({
        findingKey: `no-authoritative-ns:${domain}`,
        title: 'Domain resolves no nameservers',
        severity: 'high',
        confidence: 'firm',
        category: 'dns',
        description: 'An authoritative NS lookup for the apex returned nothing.',
        impact: 'Name resolution for the domain can fail entirely.',
        remediation: 'Confirm the delegation at the registrar.',
        cwe: 'CWE-1188',
        affected: [{ url: `https://${domain}` }],
      });
    }

    ctx.emitEvent({ type: 'phase_end', payload: { phase: 'assess' } });

    return {
      status: 'done' as const,
      findings: 0,
      coverage: ['asm.dns.dnssec'],
      exitReason: 'sweep complete',
    };
  },
});

Five things in there will surprise you, and all five are deliberate:

  • You must declare at least one model role even if you never call a model. defineHarness throws otherwise. Mark it required: false — the production ASM harness does exactly this, because its findings come from a deterministic rubric.
  • Phase order starts at 1. defineHarness refuses 0 and refuses a non-integer, because the registry's zPhase.order is .positive() — numbering from zero used to pass on your machine and fail at registration.
  • recon.call() does not throw when the tool fails. It returns { ok: false, error }. Tools are independent: a dead nikto says nothing about whether the DNS check works, so one failure must not abort a sweep that can still produce real findings.
  • emitFinding is typed RawFinding and streams immediately. The compiler now checks the field names, which matters because getting one wrong used to fail silently — cves is plural, affected is an array of objects, the stable identity is findingKey. Do not collect findings and return them at the end. See Findings and events.
  • ctx.target and ctx.hardDeadline are yours to read. A harness that cannot see its own authorized boundary cannot respect it. ctx.target.scope is the in-scope URL list the engine authorized this run against; check what you are about to touch against it rather than trusting an input parameter to agree.

3. Run it locally against a fake channel

EngineChannel is a plain interface. Implementing it is how you develop without an engine.

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

/** A channel that prints instead of dialling the engine. */
const channel: EngineChannel = {
  async handshake(connectorVersion, name, version) {
    console.log(`handshake ${name}@${version} (connector ${connectorVersion})`);
  },
  sendEvent: (e) => console.log(`event  ${e.type}`, e.payload),
  sendFinding: (f) => console.log(`finding`, f['title']),
  async sendDone(outcome) {
    console.log(`done   ${outcome.status}: ${outcome.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' },
  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' },
  credentialRefs: {},
  hardDeadline: new Date(Date.now() + 15 * 60_000).toISOString(),
  contractVersion: '1.5.1',
};

const outcome = await runHarness(helloHarness, manifest, channel);
console.log(outcome);
npx tsc --noEmit     # exits 0
npx tsx sample-local.ts

With nothing listening on 127.0.0.1:8000, this is the actual output:

handshake hello-asm@0.1.0 (connector 1.0.0)
event  log {
  msg: 'scope',
  scope: [ 'https://example.com' ],
  until: '2026-08-17T20:52:36.387Z'
}
event  phase_start { phase: 'discover' }
event  tool_error { slot: 'recon', tool: 'run_command', error: 'fetch failed' }
event  mcp.no_healthy_tool { slot: 'recon', reason: 'fetch failed' }
event  log { msg: 'nameserver lookup failed', error: 'fetch failed' }
event  phase_end { phase: 'assess' }
done   done: sweep complete
{
  status: 'done',
  findings: 0,
  coverage: [ 'asm.dns.dnssec' ],
  exitReason: 'sweep complete',
  dryRun: false
}

Read that output carefully — it is the design working. The tool call failed, the harness carried on, and the SDK emitted two events: tool_error for the individual call and mcp.no_healthy_tool at error severity for the slot having nothing behind it. That second event is what stops a run with a dead scanner from looking like a clean result. You did not write either of them.

4. Prove the wiring without spending anything

Set parameters.dryRun = true and runHarness never calls harness.run() at all. It streams the static declarePlan() phases and returns.

parameters: { domain: 'example.com', dryRun: true },
handshake hello-asm@0.1.0 (connector 1.0.0)
event  phase_start { phase: 'discover', label: 'Discovery', dryRun: true }
event  phase_end { phase: 'discover', dryRun: true }
event  phase_start { phase: 'assess', label: 'Assessment', dryRun: true }
event  phase_end { phase: 'assess', dryRun: true }
done   done: dry run: wiring verified, no model or tool call made
{
  status: 'done',
  findings: 0,
  coverage: [ 'asm.dns.dnssec', 'asm.network.ports' ],
  exitReason: 'dry run: wiring verified, no model or tool call made',
  dryRun: true
}

Note that coverage on the dry-run path is what declarePlan() declared, while on the real path it is what run() returned. That difference is the whole point of declaring coverage: the gap between the two is a coverage_gap, and a gap is a first-class run output rather than an absence.

5. Point it at a real MCP

Nothing in the harness changes. Start any conformant MCP on 127.0.0.1:8000 — the Kali MCP exposes exactly the run_command(command, timeout, workspace) tool used above — and rerun. The SDK speaks MCP streamable-http: it POSTs initialize, sends the initialized notification, carries the mcp-session-id header the server issues, and then calls tools/call.

6. Ship it

  • Containerise it. The image serves nothing: it dials out, works, exits. See Packaging and boot.
  • The container reads RUN_MANIFEST, RUN_TOKEN and ENGINE_GRPC_ADDR from its environment and swaps the fake channel for GrpcEngineChannel. See The engine channel.
  • An admin registers the harness image and composes a Service that binds a real MCP to your recon slot and a Model to your analyst role.

Where to go next