'use strict'; process.env.GSD_TEST_MODE = '1'; /** * instruction-surface-disclosure.security.test.cjs — behavioral tests for a FIFTH disclosed * class inside the capability trust gate (ADR-2363 D5, #3248): `instructionSurfaces` — the * skill stems a capability manifest declares — added to `discloseExecutableSurfaces`. * * Instruction surfaces are SKILLS ONLY (`InstructionSurface.kind` is the literal `'skill'`). * ADR-2363 D3's class table names "skills, agents", but a declared `agents[]` is deliberately * NOT collected: third-party `agents[]` are never staged into the agent's instruction context * (there is no registry-aware agent staging path, unlike `readInstalledCapabilitySkill` for * skills), so disclosing them would be a false claim in a consent prompt. Tests below that used * to assert agent disclosure now assert the NEGATIVE — that a declared `agents` array yields no * instruction surface at all. * * Implements every row carrying a Test name in * `.gsd/phase/feat-3248-disclose-instruction-surfaces/50-test-matrix.md`, derived from * `40-design.md`'s behavior table. Rows 18-20 and 23-25 are the load-bearing ones: they encode * ADR-2363 D4 — instruction surfaces must never perturb `disclosureSignature`/`hasExecutable`, and * no pre-existing consent record may be disturbed by a manifest gaining a `skills`/`agents` array. * * FAILING-FIRST: at the time this file was written, `Disclosure.instructionSurfaces` does not * exist. `discloseExecutableSurfaces` is called directly (the cheapest unit that proves the * behavior), matching `tests/reviewer-trust-disclosure.test.cjs`'s own established idiom. * * Suite: `security` (filename `.security.` infix) — this is a trust-gate surface. */ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const fc = require('fast-check'); const { cleanup } = require('./helpers.cjs'); const trust = require('../gsd-core/bin/lib/capability-trust.cjs'); // ─── Fixture builders ────────────────────────────────────────────────────── // House convention (tests/reviewer-manifest-body.test.cjs, tests/reviewer-trust-disclosure.test.cjs): // builder functions return a VALID fixture; an optional `mutator` callback is applied to the FRESH // object before it is returned. Every call builds a brand-new object — no shared mutable state. /** * A minimal, valid capability manifest declaring both skills and agents. Kept declaring BOTH * deliberately — it is now valuable precisely because it proves `agents` is ignored: every * assertion against this fixture's `instructionSurfaces` must show the skills only, never the * declared agent name. */ function skillsAndAgentsManifest(mutator) { const manifest = { id: 'test-cap', role: 'feature', title: 'Test Capability', description: 'A test capability for the instruction-surface disclosure test suite.', tier: 'standard', requires: [], version: '1.0.0', skills: ['ui-phase', 'ui-review'], agents: ['gsd-ui-checker'], }; if (mutator) mutator(manifest); return manifest; } /** A manifest carrying one hook, one command module, and one mcpServer — no skills/agents/reviewer. */ function executableSurfaceManifest(mutator) { const manifest = { id: 'x', hooks: [{ event: 'PostToolUse', script: 'hooks/x.js' }], commands: [{ family: 'demo', module: 'demo.cjs', router: 'run' }], mcpServers: { srv: { command: 'node', args: ['s.js'], env: { A: '1' } } }, }; if (mutator) mutator(manifest); return manifest; } // ─── A. Happy path (rows 1-3) ─────────────────────────────────────────────── describe('A. Happy path', () => { test('discloses declared skill stems in order', () => { const d = trust.discloseExecutableSurfaces({ id: 'x', skills: ['a', 'b'] }); assert.deepEqual(d.instructionSurfaces, [ { kind: 'skill', name: 'a' }, { kind: 'skill', name: 'b' }, ]); }); // A declared `agents` array is classified as an instruction surface by ADR-2363 D3's class // table, but is deliberately NOT staged into the instruction context for third-party // capabilities (no registry-aware agent staging path — see the module header on // src/capability-trust.cts). Disclosing it would name a surface that does not exist, so it // must yield NO instruction surfaces at all. test('declared agent names yield no instruction surfaces', () => { const d = trust.discloseExecutableSurfaces({ id: 'x', agents: ['gsd-ui-checker'] }); assert.deepEqual(d.instructionSurfaces, []); }); test('a manifest declaring both skills and agents discloses only its skills', () => { const d = trust.discloseExecutableSurfaces(skillsAndAgentsManifest()); assert.deepEqual(d.instructionSurfaces, [ { kind: 'skill', name: 'ui-phase' }, { kind: 'skill', name: 'ui-review' }, ]); }); }); // ─── B. Boundary (rows 4-7) ───────────────────────────────────────────────── describe('B. Boundary', () => { test('absent skills yields an empty instruction surface list', () => { const d = trust.discloseExecutableSurfaces({ id: 'x' }); assert.deepEqual(d.instructionSurfaces, []); }); test('empty skills array yields empty list', () => { const d = trust.discloseExecutableSurfaces({ id: 'x', skills: [] }); assert.deepEqual(d.instructionSurfaces, []); }); test('single skill', () => { const d = trust.discloseExecutableSurfaces({ id: 'x', skills: ['only'] }); assert.deepEqual(d.instructionSurfaces, [{ kind: 'skill', name: 'only' }]); }); test('many skills are not truncated', () => { const stems = Array.from({ length: 64 }, (_, i) => `skill-${i}`); const d = trust.discloseExecutableSurfaces({ id: 'x', skills: stems }); assert.deepEqual( d.instructionSurfaces, stems.map((name) => ({ kind: 'skill', name })), ); }); }); // ─── C. Negative / malformed (rows 8-11, 15) ──────────────────────────────── describe('C. Negative / malformed', () => { test('non-array skills yields empty list', () => { const d = trust.discloseExecutableSurfaces({ id: 'x', skills: 'a' }); assert.deepEqual(d.instructionSurfaces, []); }); test('object skills yields empty list', () => { const d = trust.discloseExecutableSurfaces({ id: 'x', skills: { 0: 'a' } }); assert.deepEqual(d.instructionSurfaces, []); }); test('non-string and empty stems are dropped individually', () => { const d = trust.discloseExecutableSurfaces({ id: 'x', skills: ['ok', 42, null, {}, '', true] }); assert.deepEqual(d.instructionSurfaces, [{ kind: 'skill', name: 'ok' }]); }); test('whitespace-only stem is dropped', () => { const d = trust.discloseExecutableSurfaces({ id: 'x', skills: [' '] }); assert.deepEqual(d.instructionSurfaces, []); }); test('non-object manifest is total', () => { for (const manifest of [null, 42, []]) { assert.doesNotThrow(() => trust.discloseExecutableSurfaces(manifest)); const d = trust.discloseExecutableSurfaces(manifest); assert.deepEqual(d.instructionSurfaces, [], `manifest=${JSON.stringify(manifest)} must disclose no instruction surfaces`); assert.deepEqual(d.hooks, []); assert.deepEqual(d.commandModules, []); assert.deepEqual(d.mcpServers, []); assert.deepEqual(d.reviewerLanes, []); assert.equal(d.hasExecutable, false); } }); }); // ─── D. Hostile (rows 12-14, 16) ───────────────────────────────────────────── describe('D. Hostile', () => { test('a throwing skills getter degrades only its own class', () => { const manifest = executableSurfaceManifest(); Object.defineProperty(manifest, 'skills', { enumerable: true, get() { throw new Error('boom: throwing skills getter'); }, }); assert.doesNotThrow(() => trust.discloseExecutableSurfaces(manifest)); const d = trust.discloseExecutableSurfaces(manifest); assert.deepEqual(d.instructionSurfaces, [], 'a throwing skills getter must degrade to no instruction surfaces'); assert.deepEqual(d.hooks, [{ event: 'PostToolUse', script: 'hooks/x.js' }], 'hooks must still populate'); assert.deepEqual( d.commandModules, [{ family: 'demo', module: 'demo.cjs', router: 'run' }], 'command modules must still populate', ); assert.equal(d.mcpServers.length, 1, 'mcp servers must still populate'); assert.deepEqual(d.reviewerLanes, [], 'lane-free manifest still discloses no lane (unaffected either way)'); assert.equal(d.hasExecutable, true, 'the other three classes still set hasExecutable'); }); test('a hostile Proxy manifest never throws', () => { const proxyManifest = new Proxy( {}, { get() { throw new Error('boom: get trap'); }, has() { throw new Error('boom: has trap'); }, ownKeys() { throw new Error('boom: ownKeys trap'); }, }, ); assert.doesNotThrow(() => trust.discloseExecutableSurfaces(proxyManifest)); const d = trust.discloseExecutableSurfaces(proxyManifest); assert.equal(d.hasExecutable, false); assert.deepEqual(d.instructionSurfaces, []); assert.deepEqual(d.hooks, []); assert.deepEqual(d.commandModules, []); assert.deepEqual(d.mcpServers, []); assert.deepEqual(d.reviewerLanes, []); }); test('prototype-polluting stem names do not mutate Object.prototype', () => { const beforeProps = Object.getOwnPropertyNames(Object.prototype).sort(); const d = trust.discloseExecutableSurfaces({ id: 'x', skills: ['__proto__', 'constructor', 'prototype'], }); assert.deepEqual(d.instructionSurfaces, [ { kind: 'skill', name: '__proto__' }, { kind: 'skill', name: 'constructor' }, { kind: 'skill', name: 'prototype' }, ], 'the literal names are disclosed, not interpreted as prototype keys'); const afterProps = Object.getOwnPropertyNames(Object.prototype).sort(); assert.deepEqual(afterProps, beforeProps, 'Object.prototype must be unchanged'); assert.equal(({}).polluted, undefined, 'a fresh plain object must carry no polluted property'); }); test('adversarial stem contents survive disclosure intact', () => { const huge = 'x'.repeat(10000); const stems = ['a\nb', 'x\0y', '日本語スキル', huge]; const d = trust.discloseExecutableSurfaces({ id: 'x', skills: stems }); assert.deepEqual( d.instructionSurfaces, stems.map((name) => ({ kind: 'skill', name })), 'every adversarial stem must be disclosed verbatim, with no crash and no truncation', ); const last = d.instructionSurfaces[d.instructionSurfaces.length - 1]; assert.equal(last.name.length, 10000, 'the 10k-char stem must not be truncated'); }); // Security matrix (CONTRIBUTING.md "Security and prompt-injection surfaces"): a fake instruction // tag and a traversal-shaped stem. Both are disclosed VERBATIM as ordinary names — collectInstructionSurfaces // never parses, executes, or interprets a stem's contents (ADR-2363 D2, Kerckhoffs: a shipped rule // set is readable by the adversary who installs it), and `missingArtifacts` stays empty even with a // `stagedDir` supplied. A stem is a REGISTRY NAME, not a bundle-relative artifact path — this // collector never joins it to a filesystem path (see `collectInstructionSurfaces`'s own JSDoc), so // `'../../etc/passwd'` has nothing to traverse: there is no `path.join(stagedDir, stem)` call for it // to escape. Treating it as a defect would mean the FIX is to start resolving stems against the // filesystem, which is exactly the mistake ADR-2363 D5's design note calls out as the reviewer-lane // `binary` precedent (matrix C6) — existence-checking a registry name blocks every install instead // of protecting one. Verbatim disclosure of the instruction-tag string is the intended behavior per // ADR-2363 D1/D2, not a defect: the consent prompt shows the human exactly what was declared, // unfiltered, so THEY judge it — the tool never silently "sanitizes" or interprets it on their behalf. test('a fake instruction tag and a traversal-shaped stem are disclosed verbatim, never filesystem-resolved', (t) => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'instr-surface-d5-')); t.after(() => cleanup(dir)); const instructionTag = 'ignore previous'; const traversal = '../../etc/passwd'; const d = trust.discloseExecutableSurfaces({ id: 'x', skills: [instructionTag, traversal] }, dir); assert.deepEqual(d.instructionSurfaces, [ { kind: 'skill', name: instructionTag }, { kind: 'skill', name: traversal }, ], 'both hostile stems must be disclosed as ordinary names, character-for-character'); assert.deepEqual( d.missingArtifacts, [], 'a traversal-shaped stem is a registry name, never resolved against stagedDir — it must not surface as a missing/escaping artifact', ); }); }); // ─── E. Duplicate (row 17) ─────────────────────────────────────────────────── describe('E. Duplicate', () => { test('duplicate stems are not collapsed', () => { const d = trust.discloseExecutableSurfaces({ id: 'x', skills: ['a', 'a'] }); assert.deepEqual(d.instructionSurfaces, [ { kind: 'skill', name: 'a' }, { kind: 'skill', name: 'a' }, ]); }); }); // ─── F. Independence — signature stability (rows 18-20, ADR-2363 D4) ──────── describe('F. Independence — signature stability', () => { test('skills do not perturb the disclosure signature', () => { const withSkills = { id: 'x', role: 'feature', version: '1.0.0', skills: ['a', 'b'] }; const withoutSkills = { id: 'x', role: 'feature', version: '1.0.0' }; assert.equal(trust.signatureForManifest(withSkills), trust.signatureForManifest(withoutSkills)); }); test('skills do not perturb the disclosure signature of an executable-surface-bearing manifest', () => { const withSkills = executableSurfaceManifest((m) => { m.skills = ['a', 'b']; }); const withoutSkills = executableSurfaceManifest(); assert.equal(trust.signatureForManifest(withSkills), trust.signatureForManifest(withoutSkills)); }); // These two `agents` signature tests still assert a true and useful property — agents never // perturb the signature — but are now TRIVIALLY true, since `agents` is not collected into // `instructionSurfaces` at all (it never reaches `collectInstructionSurfaces`'s per-field // loop). Kept so they guard the NARROWING (agents dropped entirely) rather than D4 // specifically — a regression that made `agents` collected again would still need a separate // D4 test to catch a signature perturbation. test('agents do not perturb the disclosure signature', () => { const withAgents = { id: 'x', role: 'feature', version: '1.0.0', agents: ['gsd-ui-checker'] }; const withoutAgents = { id: 'x', role: 'feature', version: '1.0.0' }; assert.equal(trust.signatureForManifest(withAgents), trust.signatureForManifest(withoutAgents)); }); test('agents do not perturb the disclosure signature of an executable-surface-bearing manifest', () => { const withAgents = executableSurfaceManifest((m) => { m.agents = ['gsd-ui-checker']; }); const withoutAgents = executableSurfaceManifest(); assert.equal(trust.signatureForManifest(withAgents), trust.signatureForManifest(withoutAgents)); }); // A JS re-implementation of the PRE-#3248 (and pre-#2796-lane) discloseExecutableSurfaces // (hooks/commands/mcpServers ONLY) + disclosureSignature + stableJson — copied verbatim from // `tests/reviewer-trust-disclosure.test.cjs`'s own `refDiscloseExecutableSurfaces` / // `refStableJson` oracle (itself copied from src/capability-trust.cts as it stood before ADR-2782 // Phase 3), which satisfies the fixture-provenance rule (#2371): it was written by a source that // does not know the `skills`/`agents`/`instructionSurfaces` class exists at all. function refAsString(v) { return typeof v === 'string' ? v : ''; } function refStableJson(value) { if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'; if (Array.isArray(value)) return `[${value.map(refStableJson).join(',')}]`; const keys = Object.keys(value).sort(); return `{${keys.map((k) => `${JSON.stringify(k)}:${refStableJson(value[k])}`).join(',')}}`; } function refDiscloseExecutableSurfaces(manifest) { const hooks = []; const commandModules = []; const mcpServers = []; if (Array.isArray(manifest.hooks)) { for (const h of manifest.hooks) { if (typeof h !== 'object' || h === null) continue; const script = refAsString(h['script']); const event = refAsString(h['event']); if (script) hooks.push({ event, script }); } } if (Array.isArray(manifest.commands)) { for (const c of manifest.commands) { if (typeof c !== 'object' || c === null) continue; const moduleName = refAsString(c['module']); const family = refAsString(c['family']); const router = refAsString(c['router']); if (moduleName) commandModules.push({ family, module: moduleName, router }); } } if (manifest.mcpServers && typeof manifest.mcpServers === 'object') { const pushServer = (name, config) => { if (!name) return; const cfg = typeof config === 'object' && config !== null ? config : {}; const command = refAsString(cfg['command']); const rawArgs = Array.isArray(cfg['args']) ? cfg['args'] : []; const argv = rawArgs.filter((a) => typeof a === 'string'); const transport = refAsString(cfg['type']) || refAsString(cfg['transport']); const url = refAsString(cfg['url']); const headers = {}; const rawHeaders = cfg['headers']; if (rawHeaders && typeof rawHeaders === 'object' && !Array.isArray(rawHeaders)) { for (const [k, v] of Object.entries(rawHeaders)) { if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue; if (typeof v === 'string') headers[k] = v; } } const env = {}; const rawEnv = cfg['env']; if (rawEnv && typeof rawEnv === 'object' && !Array.isArray(rawEnv)) { for (const [k, v] of Object.entries(rawEnv)) { if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue; if (typeof v === 'string') env[k] = v; } } const cwd = refAsString(cfg['cwd']); const rawConfig = {}; for (const [k, v] of Object.entries(cfg)) { if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue; rawConfig[k] = v; } const surface = { name, transport, command, argv, rawArgs, url, headers, env, rawConfig }; if (cwd) surface.cwd = cwd; mcpServers.push(surface); }; if (Array.isArray(manifest.mcpServers)) { for (const s of manifest.mcpServers) { if (typeof s === 'object' && s !== null) pushServer(refAsString(s['name']), s['config'] ?? s); } } else { for (const [name, config] of Object.entries(manifest.mcpServers)) pushServer(name, config); } } return { hooks, commandModules, mcpServers }; } function refDisclosureSignature(d) { const hooks = d.hooks.map((h) => refStableJson(['hook', h.event, h.script])).sort(); const mods = d.commandModules.map((m) => refStableJson(['mod', m.family, m.module, m.router || ''])).sort(); const mcp = d.mcpServers .map((s) => refStableJson([ 'mcp', s.name, s.transport || '', s.command, s.rawArgs || [], s.url || '', s.headers || {}, s.env || {}, s.cwd || '', s.rawConfig || {}, ]), ) .sort(); return JSON.stringify([hooks, mods, mcp]); } function referenceLaneFreeSignature(manifest) { return refDisclosureSignature(refDiscloseExecutableSurfaces(manifest)); } test('signature matches the pre-change oracle for a skill-bearing manifest', () => { const manifestWithSkills = executableSurfaceManifest((m) => { m.skills = ['ui-phase', 'ui-review']; m.agents = ['gsd-ui-checker']; }); // The oracle does not know `skills`/`agents`/`reviewer` exist at all — it only ever reads // hooks/commands/mcpServers — so its output for the skill-bearing manifest IS the reference // "sans skills" signature the matrix asks for. assert.equal(trust.signatureForManifest(manifestWithSkills), referenceLaneFreeSignature(manifestWithSkills)); }); }); // ─── G. Independence — hasExecutable (rows 21-22) ─────────────────────────── describe('G. Independence — hasExecutable', () => { test('an instruction surface alone does not set hasExecutable', () => { const d = trust.discloseExecutableSurfaces(skillsAndAgentsManifest()); assert.equal(d.hasExecutable, false); }); test('hasExecutable still reflects executable surfaces only', () => { const manifest = skillsAndAgentsManifest((m) => { m.hooks = [{ event: 'PostToolUse', script: 'hooks/x.js' }]; }); const d = trust.discloseExecutableSurfaces(manifest); assert.equal(d.hasExecutable, true, 'the hook, not the instruction surfaces, sets hasExecutable'); assert.equal(d.hooks.length, 1); // Skills-only count: `skillsAndAgentsManifest` declares 2 skills + 1 agent, but agents are // not collected (see the module-header comment above), so only the 2 skills disclose. assert.equal(d.instructionSurfaces.length, 2, 'instruction surfaces still disclosed alongside the hook'); }); }); // ─── H. Independence — executableSetChanged (row 23) ──────────────────────── describe('H. Independence — executableSetChanged', () => { test('adding a skill is not an executable-set change', () => { const before = trust.discloseExecutableSurfaces({ id: 'x' }); const after = trust.discloseExecutableSurfaces({ id: 'x', skills: ['a'] }); assert.equal(trust.executableSetChanged(before, after), false); }); }); // ─── I. Regression — pre-existing consent record (row 24) ─────────────────── describe('I. Regression — pre-existing consent record', () => { const LOCAL_SPEC = { kind: 'local', raw: '.', target: '.' }; test('a pre-existing consent record survives instruction-surface disclosure', () => { // Simulates a consent record written BEFORE this phase (a manifest with no skills/agents), // then the capability being upgraded to a version that adds a skill — the stored signature // must still match, so no re-consent prompt fires. const preChangeManifest = { id: 'x', role: 'feature', version: '1.0.0' }; const v1 = trust.evaluateInstallTrust({ parsed: LOCAL_SPEC, manifest: preChangeManifest, hostVersion: '1.0.0' }); const storedSignature = trust.disclosureSignature(v1.disclosure); const upgradedManifest = { id: 'x', role: 'feature', version: '1.1.0', skills: ['ui-phase'] }; const v2 = trust.evaluateInstallTrust({ parsed: LOCAL_SPEC, manifest: upgradedManifest, hostVersion: '1.0.0' }); const upgradedSignature = trust.disclosureSignature(v2.disclosure); assert.equal(upgradedSignature, storedSignature, 'the stored consent signature must still match after the upgrade'); assert.equal( trust.executableSetChanged(v1.disclosure, v2.disclosure), false, 'gaining a skill must not force a re-consent prompt', ); assert.equal(v2.requiresConsent, false, 'a skill-only capability requires no consent at all'); }); }); // ─── J. Independence — missingArtifacts (row 25) ──────────────────────────── describe('J. Independence — missingArtifacts', () => { test('skill stems are never existence-checked against stagedDir', (t) => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'instr-surface-j1-')); t.after(() => cleanup(dir)); const manifest = { id: 'x', skills: ['nonexistent-skill-stem', 'another-missing-one'] }; const d = trust.discloseExecutableSurfaces(manifest, dir); assert.deepEqual( d.missingArtifacts, [], 'skill stems are registry names, not bundle-relative artifact paths — they must never contribute to missingArtifacts', ); assert.equal(d.instructionSurfaces.length, 2, 'the skills must still be disclosed'); }); }); // ─── K. Consent prompt (rows 26-27) ────────────────────────────────────────── // // `summarizeInstructionSurfaces(disclosure)` is the typed surface the implementation added for // exactly this: CONTRIBUTING's "Prohibited: Raw Text Matching on Test Outputs" forbids regex-matching // `summarizeDisclosure`'s rendered prose, so these rows assert on that function's structured output // (its length against the declared surface count) and on ARRAY CONTAINMENT between the two // renderers — never on the wording of a line. describe('K. Consent prompt', () => { test('consent prompt names instruction surfaces separately', () => { const manifest = { id: 'x', skills: ['ui-phase', 'ui-review'] }; const d = trust.discloseExecutableSurfaces(manifest); assert.deepEqual(d.instructionSurfaces, [ { kind: 'skill', name: 'ui-phase' }, { kind: 'skill', name: 'ui-review' }, ]); assert.deepEqual(d.hooks, [], 'instruction surfaces must not be folded into an executable-surface class'); assert.equal(d.hasExecutable, false, 'a skill-only manifest never requires consent from this data'); // One header line + one line per surface + one "not content-scanned" line. const section = trust.summarizeInstructionSurfaces(d); assert.equal(section.length, d.instructionSurfaces.length + 2, 'every declared surface gets its own line'); }); test('consent prompt omits the section when there is nothing to disclose', () => { const d = trust.discloseExecutableSurfaces({ id: 'x' }); assert.deepEqual(d.instructionSurfaces, []); assert.deepEqual( trust.summarizeInstructionSurfaces(d), [], 'nothing declared => no lines at all, so no empty header can render', ); }); // Row 26a — the defect this phase is most likely to ship silently. A skill-only capability has // hasExecutable === false and takes summarizeDisclosure's EARLY RETURN, so a section appended only // at the end of the function would never render for precisely the capabilities that need it. // Asserted as ARRAY CONTAINMENT of one renderer's output in the other's — a structural property, // not a prose match. test('a skill-only capability still renders its instruction surfaces in the consent summary', () => { const d = trust.discloseExecutableSurfaces({ id: 'x', skills: ['ui-phase'] }); assert.equal(d.hasExecutable, false, 'precondition: this manifest takes the early-return branch'); const section = trust.summarizeInstructionSurfaces(d); const summary = trust.summarizeDisclosure(d); assert.ok(section.length > 0, 'precondition: there is a section to render'); for (const line of section) { assert.ok(summary.includes(line), 'every instruction-surface line must reach the rendered summary'); } }); // Row 26b — the same containment property for a capability that ships BOTH, where the summary // takes the executable branch instead. test('a capability with both executable and instruction surfaces renders both', () => { const manifest = executableSurfaceManifest((m) => { m.skills = ['ui-phase']; m.agents = ['gsd-ui-checker']; }); const d = trust.discloseExecutableSurfaces(manifest); assert.equal(d.hasExecutable, true, 'precondition: this manifest takes the executable branch'); const section = trust.summarizeInstructionSurfaces(d); const summary = trust.summarizeDisclosure(d); // Skills-only: the declared `agents` entry is not collected, so this is 1 surface (the // skill), not 2 — header + 1 surface + the not-scanned line. assert.equal(section.length, 3, 'header + 1 surface + the not-scanned line'); for (const line of section) { assert.ok(summary.includes(line), 'every instruction-surface line must reach the rendered summary'); } }); // Row 26c — the CLI edge calls `summarizeDisclosure(res.disclosure || {})` // (gsd-core/bin/lib/capability-command-router.cjs), so a BARE `{}` carrying no arrays at all // reaches both renderers whenever a lifecycle result has no disclosure. Reading // `.instructionSurfaces.length` off that object unguarded would throw a TypeError at the consent // prompt — a crash on the exact path that is supposed to inform the user. test('a partial disclosure object from the CLI edge never throws', () => { assert.doesNotThrow(() => trust.summarizeInstructionSurfaces({})); assert.deepEqual(trust.summarizeInstructionSurfaces({}), []); assert.doesNotThrow(() => trust.summarizeDisclosure({})); assert.deepEqual(trust.summarizeDisclosure({}), ['This capability ships no executable surfaces (declarative only).']); }); // Row 26d — a manifest may declare an unbounded number of stems. `lines.push(...section)` would // exceed the engine's argument limit and throw RangeError here; the renderer must iterate. This // guards a "simplification" back to spread, which no smaller fixture can catch. test('an unbounded stem count does not break the renderer', () => { const stems = Array.from({ length: 200000 }, (_, i) => `s${i}`); const d = trust.discloseExecutableSurfaces({ id: 'x', skills: stems }); assert.equal(d.instructionSurfaces.length, 200000); let summary; assert.doesNotThrow(() => { summary = trust.summarizeDisclosure(d); }); assert.equal(summary.length, 200000 + 3, 'intro line + header + one line per stem + the not-scanned line'); }); }); // ─── L. Cross-platform (row 28) ────────────────────────────────────────────── describe('L. Cross-platform', () => { test('CRLF in a stem does not split the entry', () => { const stem = 'ui-phase\r\nwith-crlf'; const d = trust.discloseExecutableSurfaces({ id: 'x', skills: [stem] }); assert.deepEqual( d.instructionSurfaces, [{ kind: 'skill', name: stem }], 'a CRLF inside a stem must be disclosed verbatim as ONE entry, never split into two', ); }); }); // ─── M. Property-based (fast-check) ───────────────────────────────────────── // // Generalizes the hand-written A-L fixtures with an ADVERSARIAL manifest arbitrary: valid string // stems mixed with non-strings, blanks, nested arrays, nested objects, nulls, and (via // `instructionFieldArb`'s low-weight branch) `skills`/`agents` occasionally replaced wholesale by a // non-array. `manifestArb` STILL GENERATES `agents` (good — hostile/adversarial input coverage), // but `agents` is never collected into `instructionSurfaces`: only `skills` feeds // `collectInstructionSurfaces`'s per-field loop (`INSTRUCTION_SURFACE_FIELDS` is a one-row table). // `manifestArb` always yields a plain object (never array/null/Proxy — those totality // cases are covered directly in section C/D) so P2/P3 can safely spread-and-delete `skills`/`agents` // off the SAME generated manifest, matching this file's `refDiscloseExecutableSurfaces`/section D's // established idiom of importing fast-check as `const fc = require('fast-check')` and calling // `fc.assert(fc.property(...))` with no per-call seed/numRuns override. describe('M. Property-based (fast-check)', () => { const stringArb = fc.string(); const blankArb = fc.constantFrom('', ' ', '\n', '\t'); const nonStringStemArb = fc.oneof( fc.integer(), fc.boolean(), fc.constant(null), fc.constant(undefined), fc.array(stringArb, { maxLength: 3 }), fc.object({ maxDepth: 1 }), ); const stemMemberArb = fc.oneof( { weight: 3, arbitrary: stringArb }, { weight: 1, arbitrary: blankArb }, { weight: 1, arbitrary: nonStringStemArb }, ); const stemsArrayArb = fc.array(stemMemberArb, { maxLength: 6 }); // Occasionally replace the whole field with a non-array (a scalar, null, or a plain object) — // exercises `collectInstructionSurfaces`'s "a non-array field declares nothing" branch. const instructionFieldArb = fc.oneof( { weight: 5, arbitrary: stemsArrayArb }, { weight: 1, arbitrary: fc.oneof(stringArb, fc.integer(), fc.constant(null), fc.object({ maxDepth: 1 })) }, ); const hookArb = fc.record({ event: stringArb, script: stringArb }, { requiredKeys: [] }); const commandArb = fc.record({ family: stringArb, module: stringArb, router: stringArb }, { requiredKeys: [] }); const mcpConfigArb = fc.record( { command: stringArb, args: fc.array(fc.oneof(stringArb, fc.integer())) }, { requiredKeys: [] }, ); // Always a plain object — P2/P3 rely on being able to spread it and delete skills/agents. const manifestArb = fc.record( { id: stringArb, hooks: fc.array(hookArb, { maxLength: 3 }), commands: fc.array(commandArb, { maxLength: 3 }), mcpServers: fc.dictionary(stringArb, mcpConfigArb), skills: instructionFieldArb, agents: instructionFieldArb, }, { requiredKeys: [] }, ); /** `m` with `skills`/`agents` deleted — the D4 "sans instruction surfaces" comparison object. */ function withoutInstructionFields(m) { const m2 = { ...m }; delete m2.skills; delete m2.agents; return m2; } test('P1: discloseExecutableSurfaces is total and every instruction surface is well-shaped', () => { fc.assert( fc.property(manifestArb, (manifest) => { let d; assert.doesNotThrow(() => { d = trust.discloseExecutableSurfaces(manifest); }, `discloseExecutableSurfaces threw for manifest=${JSON.stringify(manifest)}`); assert.ok(Array.isArray(d.instructionSurfaces), 'instructionSurfaces must always be an array'); for (const surface of d.instructionSurfaces) { // Skills-only: `agents` is generated by the arbitrary but never collected, so every // disclosed instruction surface must be a skill. assert.equal(surface.kind, 'skill', `unexpected kind ${JSON.stringify(surface.kind)}`); assert.equal(typeof surface.name, 'string', `name must be a string, got ${typeof surface.name}`); assert.ok(surface.name.length > 0, 'name must be non-empty'); } }), ); }); test('P2: ADR-2363 D4 — instruction surfaces never perturb the disclosure signature', () => { fc.assert( fc.property(manifestArb, (manifest) => { const m2 = withoutInstructionFields(manifest); assert.equal( trust.signatureForManifest(manifest), trust.signatureForManifest(m2), `signature diverged for manifest=${JSON.stringify(manifest)}`, ); }), ); }); test('P3: ADR-2363 D3 — instruction surfaces never perturb hasExecutable', () => { fc.assert( fc.property(manifestArb, (manifest) => { const m2 = withoutInstructionFields(manifest); assert.equal( trust.discloseExecutableSurfaces(manifest).hasExecutable, trust.discloseExecutableSurfaces(m2).hasExecutable, `hasExecutable diverged for manifest=${JSON.stringify(manifest)}`, ); }), ); }); test('P4: summarizeInstructionSurfaces is total and its length tracks instructionSurfaces.length', () => { fc.assert( fc.property(manifestArb, (manifest) => { const d = trust.discloseExecutableSurfaces(manifest); let section; assert.doesNotThrow(() => { section = trust.summarizeInstructionSurfaces(d); }, `summarizeInstructionSurfaces threw for manifest=${JSON.stringify(manifest)}`); if (d.instructionSurfaces.length === 0) { assert.deepEqual(section, []); } else { assert.equal(section.length, d.instructionSurfaces.length + 2); } }), ); }); }); // ─── N. Consent-prompt injection safety ───────────────────────────────────── // // #3248 BLOCKER finding: `summarizeDisclosure`'s lines are joined with `\n` and written RAW to // stderr on the needs-consent path (`capability-command-router.cjs`). An unescaped newline in a // manifest-supplied value forged lines indistinguishable from genuine GSD disclosure text, and an // unescaped ANSI/control sequence could rewrite already-printed terminal lines. `renderValueForPrompt` // is the fix: every manifest-supplied value rendered into a consent-prompt line is escaped and // length-bounded first. The DISCLOSURE OBJECT itself still carries values VERBATIM (unchanged) — // only the RENDERED line is escaped. // // Assertions here are on TYPED values and STRUCTURAL properties only (array length, character-class // absence) — CONTRIBUTING.md forbids regex-matching rendered prose. Checking a rendered line for the // ABSENCE of specific control characters is a structural safety property, not a prose match. describe('N. Consent-prompt injection safety', () => { // Every character that must never survive into a rendered consent-prompt line: C0, DEL, C1, the // bidi/isolate controls, and the line/paragraph separators. A raw newline forges a line that is // indistinguishable from genuine GSD disclosure text; a raw ESC lets a manifest value rewrite lines // already printed to the terminal. Defined independently of `src/capability-trust.cts`'s own // `UNSAFE_PROMPT_CHARS` (not imported) so this test does not just echo the implementation back at // itself — it is an independent restatement of the same forbidden-character contract. // eslint-disable-next-line no-control-regex -- deliberately matching C0/DEL/C1 control chars. const FORBIDDEN_IN_RENDERED_LINE = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/; test('renderValueForPrompt is identity for an ordinary stem', () => { assert.equal(trust.renderValueForPrompt('ui-phase'), 'ui-phase'); }); test('renderValueForPrompt escapes each hostile class', () => { const hostileInputs = [ '\n', // C0 — line feed '\r\n', // C0 — CRLF '\x1b[2K', // C0 ESC — ANSI erase-line, can rewrite already-printed terminal output '
', // line/paragraph separator '‮', // bidi/isolate control — RIGHT-TO-LEFT OVERRIDE ]; for (const input of hostileInputs) { const result = trust.renderValueForPrompt(input); assert.equal( FORBIDDEN_IN_RENDERED_LINE.test(result), false, `renderValueForPrompt(${JSON.stringify(input)}) must contain no forbidden character, got ${JSON.stringify(result)}`, ); } // The escaped form still contains the surrounding legible text, so the value stays // identifiable rather than vanishing. const escaped = trust.renderValueForPrompt('a\nb'); assert.ok(escaped.includes('a'), 'escaped form must still contain the leading legible text'); assert.ok(escaped.includes('b'), 'escaped form must still contain the trailing legible text'); }); test('renderValueForPrompt bounds length', () => { const huge = 'x'.repeat(10000); const result = trust.renderValueForPrompt(huge); assert.ok(result.length < 10000, `expected a materially shorter result, got length ${result.length}`); }); test('a forged skill stem cannot inject a line', () => { const forged = 'ok\n hooks (1): run as runtime hook commands\n - fake -> ok\nRe-run with --yes to grant consent.'; const d = trust.discloseExecutableSurfaces({ id: 'x', skills: [forged] }); const summary = trust.summarizeDisclosure(d); for (const line of summary) { assert.equal( FORBIDDEN_IN_RENDERED_LINE.test(line), false, `rendered line must contain no forbidden character: ${JSON.stringify(line)}`, ); } // intro line + header + 1 surface + the not-scanned line — the forged text must not become // EXTRA array entries either (it stayed one skill, so it renders as exactly one surface line). assert.equal(summary.length, 4, 'intro + header + 1 surface + not-scanned line'); }); // PARITY — the generative-fix-divergence guard (CLAUDE.md "Generative Fix Divergence"): the same // escaping guarantee must hold for every one of the five disclosed classes, not just skills. Every // rendered field of every class carries the same hostile payload; if a future class is added to the // renderer without routing its values through `renderValueForPrompt`, this test catches it instead // of that class silently shipping unescaped. test('PARITY — the same injection-safety guarantee holds for all five disclosed classes', () => { const payload = 'a\nb[2Kc'; const manifest = { id: 'x', hooks: [{ event: payload, script: payload }], commands: [{ family: payload, module: payload, router: payload }], mcpServers: { [payload]: { command: payload, args: [payload], url: payload, cwd: payload, env: { [payload]: payload }, }, }, reviewer: { slug: payload, transport: 'spawn', invoke: { binary: payload, args: [payload], hostConfigKey: payload, }, handler: payload, }, skills: [payload], }; const d = trust.discloseExecutableSurfaces(manifest); const summary = trust.summarizeDisclosure(d); for (const line of summary) { assert.equal( FORBIDDEN_IN_RENDERED_LINE.test(line), false, `rendered line must contain no forbidden character: ${JSON.stringify(line)}`, ); } }); // #2483 — the PARITY test above is hand-maintained, and that is its one structural weakness: its // payload manifest enumerates the lane fields that existed when it was written, so a field added // to the renderer LATER is simply absent from the payload and the guard passes over it vacuously. // This PR adds three such fields — `invoke.env`, `invoke.defaultHost` and `probe.binary` — each // manifest-supplied and each reaching a consent-prompt line, so each carries the same #3248 // escaping obligation as every field the block above covers. // // Measured before writing this: with the lane `env` line rendered RAW (the pre-#3248 form), the // entire 948-test lane/capability/trust-disclosure suite stayed green. The escaping was real and // completely unguarded. // // Two manifests are required because the two lane shapes render disjoint lines: `defaultHost` is // emitted only on the openai-http branch, `env`/`probe` only reach a line on a lane that declares // them. The probe binary must DIFFER from the dispatch binary or its line does not render at all. test('PARITY — reviewer-lane env, defaultHost and probe binary are escaped too (#2483)', () => { const payload = 'a\nb\u001b[2Kc'; const spawnManifest = { id: 'x', reviewer: { slug: payload, transport: 'spawn', invoke: { binary: payload, args: [payload], env: { [payload]: payload } }, handler: payload, probe: { binary: `${payload}-probe`, kind: 'command-capability' }, }, }; const httpManifest = { id: 'x', reviewer: { slug: payload, transport: 'openai-http', invoke: { hostConfigKey: payload, defaultHost: payload }, }, }; for (const manifest of [spawnManifest, httpManifest]) { const summary = trust.summarizeDisclosure(trust.discloseExecutableSurfaces(manifest)); for (const line of summary) { assert.equal( FORBIDDEN_IN_RENDERED_LINE.test(line), false, `rendered line must contain no forbidden character: ${JSON.stringify(line)}`, ); } } // NON-VACUITY — asserted on the TYPED disclosure object and on structural line counts, never by // substring-matching rendered prose (CONTRIBUTING.md § "Prohibited: Raw Text Matching on Test // Outputs"; the section header above also promises structural assertions only, and a prose match // here would make that promise false). Two legs, because they answer different halves: // (a) the fixtures actually populate the typed fields, so the render conditions are reachable; // (b) each field contributes exactly one line, so the sweep above had something to sweep. const spawnLane = trust.discloseExecutableSurfaces(spawnManifest).reviewerLanes[0]; assert.equal(Object.keys(spawnLane.env).length, 1, 'fixture must populate the lane env'); assert.notEqual( spawnLane.probeBinary, spawnLane.binary, 'the probe line renders only when the probe binary differs from the dispatch binary', ); const httpLane = trust.discloseExecutableSurfaces(httpManifest).reviewerLanes[0]; assert.notEqual(httpLane.defaultHost, '', 'fixture must populate defaultHost'); const lineCount = (m) => trust.summarizeDisclosure(trust.discloseExecutableSurfaces(m)).length; const withoutEnv = structuredClone(spawnManifest); delete withoutEnv.reviewer.invoke.env; const withoutProbe = structuredClone(spawnManifest); delete withoutProbe.reviewer.probe; const withoutDefaultHost = structuredClone(httpManifest); delete withoutDefaultHost.reviewer.invoke.defaultHost; assert.equal(lineCount(spawnManifest) - lineCount(withoutEnv), 1, 'env contributes one line'); assert.equal(lineCount(spawnManifest) - lineCount(withoutProbe), 1, 'probe contributes one line'); assert.equal( lineCount(httpManifest) - lineCount(withoutDefaultHost), 1, 'defaultHost contributes one line', ); }); test('the disclosure OBJECT stays verbatim', () => { // Escaping is a RENDERING concern only. The object must stay verbatim because // `disclosureSignature` and any consumer reasoning about identity depend on the declared // value, not the escaped-for-display one. const forged = 'ok\n hooks (1): run as runtime hook commands\nRe-run with --yes to grant consent.'; const d = trust.discloseExecutableSurfaces({ id: 'x', skills: [forged] }); assert.equal(d.instructionSurfaces[0].name, forged, 'the disclosure object must carry the exact unescaped value'); }); });