From 9bd6c67e2df4252c0efec7360b2e4aecb17b821b Mon Sep 17 00:00:00 2001 From: ruv Date: Mon, 6 Jul 2026 20:39:24 -0400 Subject: [PATCH] fix(publish): fail closed when a present witness signature was never verified (#4 HIGH-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beta feedback #4 (HIGH-1): witness verification is a guaranteed no-op — no kernel backend exposes witnessVerify, so witness-client.ts's crypto-verify delegate always falls through to the shape-only degraded path, which returned {valid:true}. publish.ts only threw on !valid, so a shape-valid witness.json carrying a GARBAGE signature (64-hex key, 128-hex sig, schema 1) sailed through and the harness published as "signed" — a signature that was never checked. This closes the publish exploit without needing the (native-publish-gated) NAPI/WASM verifier: - witness-client.ts: VerificationResult gains `unverified?: boolean`; the degraded path now sets it (valid stays true so read-only `verify`/`doctor` diagnostics are unchanged — they only assert shape). - publish.ts: a present-but-unverified witness now FAILS CLOSED ("Refusing to publish this harness as signed") unless the caller passes allowUnverified — which then warns the signature was NOT checked. - publish-cmd.ts: `--allow-unverified-witness` flag + a loud warning line. Live-verified via real `harness publish`: a shape-valid, garbage-128-hex-sig witness now fails closed (exit 1, clear message); `--allow-unverified-witness` proceeds with the warning. 2 new tests (exploit closed + explicit opt-in); the degraded `verify` test still reports VALID; full create-agent-harness suite 363/363; tsc clean. Not in this PR (needs Rust + NAPI/WASM export, native-publish-gated): wiring a real witnessVerify so a GENUINE signature can be cryptographically checked. Until then publishing a "signed" harness requires the explicit --allow-unverified-witness acknowledgement. Co-Authored-By: claude-flow --- node_modules | 1 + .../__tests__/publish.test.ts | 33 +++++++++++++++++++ .../create-agent-harness/src/publish-cmd.ts | 5 +++ packages/create-agent-harness/src/publish.ts | 17 ++++++++++ .../src/witness-client.ts | 13 +++++++- 5 files changed, 68 insertions(+), 1 deletion(-) create mode 120000 node_modules diff --git a/node_modules b/node_modules new file mode 120000 index 00000000..4c37106c --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/home/ruvultra/projects/agent-harness-generator/node_modules \ No newline at end of file diff --git a/packages/create-agent-harness/__tests__/publish.test.ts b/packages/create-agent-harness/__tests__/publish.test.ts index 3e1e13e0..591b6b74 100644 --- a/packages/create-agent-harness/__tests__/publish.test.ts +++ b/packages/create-agent-harness/__tests__/publish.test.ts @@ -34,6 +34,39 @@ describe('publishHarness (dry-run path)', () => { }); }); +// GH #4 (HIGH-1): a present witness whose signature was NOT cryptographically verified (degraded mode — +// no kernel verifier) must NOT publish silently as "signed". Before the fix the shape-only path returned +// valid:true, so a shape-valid witness carrying a garbage signature published as verified. +describe('publishHarness — fail-closed on an unverified witness (GH #4 HIGH-1)', () => { + async function harnessWithGarbageWitness(): Promise { + const root = await mkdtemp(join(tmpdir(), 'cah-pub-wit-')); + await mkdir(join(root, '.harness'), { recursive: true }); + await writeFile(join(root, '.harness', 'manifest.json'), + JSON.stringify({ schema: 1, template: 'minimal', vars: { name: 'demo' } })); + // Shape-valid witness with a GARBAGE signature — the exploit input. + await writeFile(join(root, '.harness', 'witness.json'), JSON.stringify({ + schema: 1, harness: 'demo', version: '0.1.0', entries: [], + public_key: 'a'.repeat(64), signature: 'b'.repeat(128), + })); + return root; + } + + it('refuses to publish a harness whose witness signature was never checked', async () => { + const root = await harnessWithGarbageWitness(); + await expect(publishHarness({ + harnessDir: root, pinata: { jwt: 'x' }, confirm: false, + })).rejects.toThrow(/NOT cryptographically verified|--allow-unverified-witness/); + }); + + it('publishes when --allow-unverified-witness is explicitly opted in', async () => { + const root = await harnessWithGarbageWitness(); + const r = await publishHarness({ + harnessDir: root, pinata: { jwt: 'x' }, confirm: false, allowUnverified: true, + }); + expect(r.manifestCid).toBe('dry-run-no-pin'); + }); +}); + describe('pinJson', () => { it('throws on missing JWT', async () => { await expect(pinJson({ jwt: '' }, { foo: 'bar' }, { name: 't' })) diff --git a/packages/create-agent-harness/src/publish-cmd.ts b/packages/create-agent-harness/src/publish-cmd.ts index 7811555e..a20b4960 100644 --- a/packages/create-agent-harness/src/publish-cmd.ts +++ b/packages/create-agent-harness/src/publish-cmd.ts @@ -23,9 +23,13 @@ export async function publishCmd(args: string[]): Promise { const positional = args.filter(a => !a.startsWith('--')); const dir = resolve(positional[0] ?? process.cwd()); const confirm = args.includes('--confirm'); + const allowUnverified = args.includes('--allow-unverified-witness'); const nameOverride = args.find(a => a.startsWith('--name='))?.slice('--name='.length); const lines: string[] = [`harness publish — ${dir} ${confirm ? '(CONFIRMED)' : '(DRY-RUN)'}`]; + if (allowUnverified) { + lines.push(' ⚠️ --allow-unverified-witness: the witness signature will NOT be cryptographically checked.'); + } if (confirm) { const jwt = process.env.PINATA_JWT; @@ -47,6 +51,7 @@ export async function publishCmd(args: string[]): Promise { }, confirm, name: nameOverride, + allowUnverified, }); lines.push(` manifest CID: ${r.manifestCid}`); lines.push(` size: ${r.manifestSize} bytes`); diff --git a/packages/create-agent-harness/src/publish.ts b/packages/create-agent-harness/src/publish.ts index 5e9d9140..276df55c 100644 --- a/packages/create-agent-harness/src/publish.ts +++ b/packages/create-agent-harness/src/publish.ts @@ -89,6 +89,12 @@ export interface HarnessPublishOptions { confirm: boolean; /** Optional override of the harness's name (defaults to manifest.vars.name). */ name?: string; + /** + * GH #4 (HIGH-1): explicitly publish even when a present witness could NOT be cryptographically + * verified (no verifier available). Off by default — publishing is fail-closed on an unverified + * witness so a garbage signature can't masquerade as signed. The signature is NOT checked when set. + */ + allowUnverified?: boolean; } export interface PublishResult { @@ -128,6 +134,17 @@ export async function publishHarness(opts: HarnessPublishOptions): Promise