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/nodetsconfig.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.
defineHarnessthrows otherwise. Mark itrequired: false— the production ASM harness does exactly this, because its findings come from a deterministic rubric. - Phase
orderstarts at 1.defineHarnessrefuses0and refuses a non-integer, because the registry'szPhase.orderis.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 deadniktosays nothing about whether the DNS check works, so one failure must not abort a sweep that can still produce real findings.emitFindingis typedRawFindingand streams immediately. The compiler now checks the field names, which matters because getting one wrong used to fail silently —cvesis plural,affectedis an array of objects, the stable identity isfindingKey. Do not collect findings and return them at the end. See Findings and events.ctx.targetandctx.hardDeadlineare yours to read. A harness that cannot see its own authorized boundary cannot respect it.ctx.target.scopeis 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.tsWith 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_TOKENandENGINE_GRPC_ADDRfrom its environment and swaps the fake channel forGrpcEngineChannel. See The engine channel. - An admin registers the harness image and composes a Service that binds a real MCP to
your
reconslot and a Model to youranalystrole.