From 91efabfaf4e41cf865fb18cf808246b93e6e3239 Mon Sep 17 00:00:00 2001 From: aaronormerod <137795541+aaronormerod@users.noreply.github.com> Date: Sun, 8 Nov 2026 13:11:59 +0000 Subject: [PATCH] fix(agt): withhold Claude Code stderr with stdout at the result gate A deny replaced stdout and delivered stderr. Add `outputs.companions` to the hookmap: text fields that are not copies of the leaf. A withholding replaces them, a redaction leaves them. PostToolUse declares stderr. Part of #375 Signed-off-by: aaronormerod <137795541+aaronormerod@users.noreply.github.com> --- .../claude-code/claude-code.hookmap.yaml | 5 + .../test/result-companions.test.ts | 237 +++++++++++++++ .../host-adapter/src/build-envelope.ts | 53 +++- .../host-adapter/src/result-output.ts | 119 +++++++- .../test/result-companions.test.ts | 274 ++++++++++++++++++ 5 files changed, 685 insertions(+), 3 deletions(-) create mode 100644 reference-implementations/agt/hosts/claude-code/test/result-companions.test.ts create mode 100644 reference-implementations/agt/packages/host-adapter/test/result-companions.test.ts diff --git a/reference-implementations/agt/hosts/claude-code/claude-code.hookmap.yaml b/reference-implementations/agt/hosts/claude-code/claude-code.hookmap.yaml index e77ea83c..ac69e4c5 100644 --- a/reference-implementations/agt/hosts/claude-code/claude-code.hookmap.yaml +++ b/reference-implementations/agt/hosts/claude-code/claude-code.hookmap.yaml @@ -157,6 +157,11 @@ hooks: outputs: from: $.tool_response.stdout within: $.tool_response + # stderr is the other text Bash returns. A withholding replaces it with + # stdout, so a deny cannot deliver it. A redaction of stdout leaves it as + # produced, because the policy evaluates stdout alone. + companions: + - $.tool_response.stderr # Claude Code fires a SEPARATE PostToolUseFailure event for the failing # case (present in 2.1.227's hook schema, not wired in this slice), so # this event genuinely means success. A literal, not a field read: the diff --git a/reference-implementations/agt/hosts/claude-code/test/result-companions.test.ts b/reference-implementations/agt/hosts/claude-code/test/result-companions.test.ts new file mode 100644 index 00000000..602d482d --- /dev/null +++ b/reference-implementations/agt/hosts/claude-code/test/result-companions.test.ts @@ -0,0 +1,237 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmdirSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +// Test-only import, the precedent post-tool-use.test.ts and posture.test.ts set. +import { startGuardian, type StartedGuardian } from "guardian"; + +/** + * Bash returns two text fields, `stdout` and `stderr`, and the model reads both. + * The policy evaluates `stdout`. A withholding at the result gate has to replace + * `stderr` as well, or a deny delivers whatever the command wrote there. + * + * Each test runs the real shim as a subprocess, the way Claude Code does, and + * asserts on what the shim writes and how it exits. + */ +const SHIM_PATH = fileURLToPath(new URL("../acs-hook.ts", import.meta.url)); +const MANIFEST = "policy/manifest.yaml"; +const SESSION = "companions-1"; +const SECRET = "ghp_ABCDEF123456"; +const WITHHELD = "[OUTPUT WITHHELD BY POLICY]"; + +const dirs: string[] = []; +function scratch(): string { + const dir = mkdtempSync(join(tmpdir(), "acs-result-companions-")); + dirs.push(dir); + return dir; +} + +/** Removes exactly what a run can leave behind, by name, never recursively. */ +function cleanupScratch(dir: string): void { + const sessionsDir = join(dir, "sessions"); + for (const file of [join(dir, "audit.jsonl"), join(sessionsDir, `${SESSION}.json`)]) { + if (existsSync(file)) { + unlinkSync(file); + } + } + if (existsSync(sessionsDir)) { + rmdirSync(sessionsDir); + } + rmdirSync(dir); +} + +afterEach(() => { + while (dirs.length > 0) { + cleanupScratch(dirs.pop() as string); + } +}); + +type ShimRun = { stdout: string; stderr: string; exitCode: number }; + +function payload(fields: Record): Record { + return { + session_id: SESSION, + transcript_path: "/path/to/transcript.jsonl", + cwd: "/current/dir", + hook_event_name: "PostToolUse", + tool_name: "Bash", + tool_input: { command: "cat .env" }, + tool_response: toolResponse(fields), + }; +} + +/** Bash's real `tool_response` shape, with the given fields overriding the empty defaults. */ +function toolResponse(fields: Record): Record { + return { stdout: "", stderr: "", interrupted: false, isImage: false, noOutputExpected: false, ...fields }; +} + +async function runShim(body: Record, dir: string, guardianUrl: string): Promise { + const proc = Bun.spawn({ + cmd: ["bun", "run", SHIM_PATH], + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + ACS_GUARDIAN_URL: guardianUrl, + ACS_SESSION_DIR: join(dir, "sessions"), + ACS_AUDIT_LOG: join(dir, "audit.jsonl"), + }, + }); + proc.stdin.write(JSON.stringify(body)); + proc.stdin.end(); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { stdout, stderr, exitCode }; +} + +function hookOutput(out: ShimRun): Record { + expect({ exitCode: out.exitCode, stderr: out.stderr }).toEqual({ exitCode: 0, stderr: "" }); + return JSON.parse(out.stdout).hookSpecificOutput as Record; +} + +function auditOf(dir: string): Record[] { + return readFileSync(join(dir, "audit.jsonl"), "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); +} + +/** A stub Guardian that negotiates a session and answers the step call with `decision`. */ +async function answering(decision: Record, body: (url: string) => Promise): Promise { + const stub = Bun.serve({ + port: 0, + async fetch(req) { + const rpc = (await req.json()) as { id: string | number; method: string }; + if (rpc.method === "handshake/hello") { + return Response.json({ + jsonrpc: "2.0", + id: rpc.id, + result: { + negotiated_version: "0.1.0", + methods_evaluated: ["steps/toolCallResult"], + selected_transport: "http", + timeout_config: { default_ms: 5000 }, + on_decision_failure: "proceed", + }, + }); + } + return Response.json({ jsonrpc: "2.0", id: rpc.id, result: decision }); + }, + }); + try { + return await body(`http://localhost:${stub.port}/acs`); + } finally { + stub.stop(true); + } +} + +let guardian: StartedGuardian; + +beforeAll(async () => { + guardian = await startGuardian({ port: 0, manifestPath: MANIFEST }); +}); + +afterAll(async () => { + await guardian.close(); +}); + +describe("the result gate withholds stderr with stdout", () => { + it("on a policy deny, replaces both", async () => { + const dir = scratch(); + const out = await answering({ decision: "deny", reasoning: "no" }, (url) => + runShim(payload({ stdout: `TOKEN=${SECRET}`, stderr: `warn ${SECRET}` }), dir, url), + ); + + expect(hookOutput(out).updatedToolOutput).toEqual( + toolResponse({ stdout: WITHHELD, stderr: WITHHELD }), + ); + expect(out.stdout).not.toContain(SECRET); + }); + + it("on a deny, leaves an empty stderr empty", async () => { + const dir = scratch(); + const out = await answering({ decision: "deny", reasoning: "no" }, (url) => + runShim(payload({ stdout: `TOKEN=${SECRET}` }), dir, url), + ); + + expect(hookOutput(out).updatedToolOutput).toEqual(toolResponse({ stdout: WITHHELD })); + }); + + it("on a deny with nothing in stdout, still replaces a stderr that holds text", async () => { + const dir = scratch(); + const out = await answering({ decision: "deny", reasoning: "no" }, (url) => + runShim(payload({ stdout: "", stderr: `fatal ${SECRET}` }), dir, url), + ); + + expect(hookOutput(out).updatedToolOutput).toEqual(toolResponse({ stdout: WITHHELD, stderr: WITHHELD })); + expect(out.stdout).not.toContain(SECRET); + }); + + it("when the Guardian is gone under the deny posture, replaces both", async () => { + const dir = scratch(); + const live = await startGuardian({ port: 0, manifestPath: MANIFEST, onDecisionFailure: "deny" }); + try { + // Hook 1 negotiates while the Guardian is alive, with nothing to hide. + await runShim(payload({ stdout: "ok" }), dir, live.url); + } finally { + await live.close(); + } + + const out = await runShim(payload({ stdout: `TOKEN=${SECRET}`, stderr: `warn ${SECRET}` }), dir, live.url); + + expect(hookOutput(out).updatedToolOutput).toEqual(toolResponse({ stdout: WITHHELD, stderr: WITHHELD })); + expect(out.stdout).not.toContain(SECRET); + expect(auditOf(dir)[0]).toMatchObject({ posture: "deny", outcome: "blocked" }); + }); + + it("under the deny posture, withholds stderr from a result that carries no stdout", async () => { + const dir = scratch(); + const live = await startGuardian({ port: 0, manifestPath: MANIFEST, onDecisionFailure: "deny" }); + try { + await runShim(payload({ stdout: "ok" }), dir, live.url); + } finally { + await live.close(); + } + + const { stdout: _stdout, ...noStdout } = toolResponse({ stderr: `fatal ${SECRET}` }); + const out = await runShim({ ...payload({}), tool_response: noStdout }, dir, live.url); + + // Before, this was exit 2 with empty stdout, and Claude Code showed the + // model the stderr text. Now the shim answers, and adds no field the tool + // did not produce. + const updated = hookOutput(out).updatedToolOutput as Record; + expect(updated.stderr).toBe(WITHHELD); + expect("stdout" in updated).toBe(false); + expect(out.stdout).not.toContain(SECRET); + expect(auditOf(dir)[0]).toMatchObject({ posture: "deny", outcome: "blocked" }); + }); +}); + +describe("the result gate leaves stderr alone when nothing is withheld", () => { + it("on an allow", async () => { + const dir = scratch(); + const out = await answering({ decision: "allow" }, (url) => + runShim(payload({ stdout: "fine", stderr: "a warning" }), dir, url), + ); + + expect("updatedToolOutput" in hookOutput(out)).toBe(false); + }); + + it("on a redaction, because the policy evaluates stdout alone", async () => { + const dir = scratch(); + const out = await runShim( + payload({ stdout: `TOKEN=${SECRET}`, stderr: "a warning" }), + dir, + guardian.url, + ); + + expect(hookOutput(out).updatedToolOutput).toEqual( + toolResponse({ stdout: "TOKEN=[REDACTED]", stderr: "a warning" }), + ); + }); +}); diff --git a/reference-implementations/agt/packages/host-adapter/src/build-envelope.ts b/reference-implementations/agt/packages/host-adapter/src/build-envelope.ts index faa49e00..3d6285c0 100644 --- a/reference-implementations/agt/packages/host-adapter/src/build-envelope.ts +++ b/reference-implementations/agt/packages/host-adapter/src/build-envelope.ts @@ -61,6 +61,18 @@ export type HookmapOutputs = { * to say where the copies are; nothing here could infer it. */ mirrors?: string[]; + /** + * Further paths inside `within` that hold text the model also reads, different + * text from the leaf's. A mirror holds a copy of the leaf. A companion does not. + * + * The policy evaluates the leaf alone, so a redaction leaves a companion as the + * host produced it. A withholding does not: when the output is withheld, every + * companion that holds text is replaced too, because withholding the leaf while a + * neighbour still carries what the tool printed withholds nothing. A companion the + * payload does not carry is skipped. One that holds something other than text + * throws, the way a leaf of another type does. + */ + companions?: string[]; }; /** The members every hook entry carries, whichever payload it builds. */ @@ -408,6 +420,42 @@ function assertMirrorsWellFormed(hookmap: Hookmap, path: string): void { } } +function assertCompanionsWellFormed(hookmap: Hookmap, path: string): void { + for (const [hookEventName, entry] of Object.entries(hookmap.hooks ?? {})) { + const outputs = isPlainObject(entry) ? (entry as { outputs?: unknown }).outputs : undefined; + if (!isPlainObject(outputs)) { + continue; + } + const rawCompanions = outputs.companions ?? undefined; + if (rawCompanions === undefined) { + continue; + } + if ( + !Array.isArray(rawCompanions) || + rawCompanions.some((companion) => typeof companion !== "string" || companion.length === 0) + ) { + throw new Error( + `loadHookmap: ${path}'s "hooks.${hookEventName}.outputs.companions" is ${JSON.stringify(rawCompanions)} -- ` + + `when present, "companions" must be a list of non-empty JSONPath-lite strings, each a further path ` + + `inside "outputs.within"`, + ); + } + const within = typeof outputs.within === "string" ? outputs.within : ""; + if (within.length === 0) { + continue; + } + for (const companion of rawCompanions as string[]) { + if (!companion.startsWith(`${within}.`) || companion.length <= within.length + 1) { + throw new Error( + `loadHookmap: ${path}'s "hooks.${hookEventName}.outputs.companions" entry ${JSON.stringify(companion)} is ` + + `not a field inside "outputs.within" ${JSON.stringify(within)} -- a companion is a further path into ` + + `the same object the leaf lives in, the same relation "outputs.from" has to it`, + ); + } + } + } +} + /** * Rejects a hookmap entry whose `tools` is malformed, at load time -- the * same seam `assertMirrorsWellFormed` uses, and for the identical reason: a @@ -650,7 +698,9 @@ function normalizeTools(hookmap: Hookmap): Hookmap { * `deny`, or if any declared entry is not a renderable rule -- see * assertRenderableDecisions. Also throws if any entry's `outputs.mirrors` is * malformed or names a field outside `outputs.within` -- see - * assertMirrorsWellFormed. Also throws if any entry's `tools` is malformed -- + * assertMirrorsWellFormed. Also throws if any entry's `outputs.companions` is + * malformed or names a field outside `outputs.within` -- see + * assertCompanionsWellFormed. Also throws if any entry's `tools` is malformed -- * see assertToolsWellFormed. Also throws if any entry's `exit_status` * declares both `literal` and `from` -- see assertExitStatusNotBothForms. * Also throws if a request-gate entry (one declaring `arguments`) also @@ -664,6 +714,7 @@ export function loadHookmap(path: string): Hookmap { const hookmap = Bun.YAML.parse(readFileSync(path, "utf8")) as Hookmap; assertRenderableDecisions(hookmap, path); assertMirrorsWellFormed(hookmap, path); + assertCompanionsWellFormed(hookmap, path); assertToolsWellFormed(hookmap, path); assertExitStatusNotBothForms(hookmap, path); assertRequestGateDeclaresNoOutputs(hookmap, path); diff --git a/reference-implementations/agt/packages/host-adapter/src/result-output.ts b/reference-implementations/agt/packages/host-adapter/src/result-output.ts index 93dfadc8..3af0f3e6 100644 --- a/reference-implementations/agt/packages/host-adapter/src/result-output.ts +++ b/reference-implementations/agt/packages/host-adapter/src/result-output.ts @@ -457,6 +457,121 @@ export function replacingOutput(location: HostOutputLocation, replacement: unkno return patched; } +/** + * The replacing output for a withholding: `replacingOutput` with + * `WITHHELD_OUTPUT`, and every declared companion that holds text replaced the + * same way. + * + * A companion is a field the model reads that is not a copy of the leaf (see + * `HookmapOutputs.companions`). The policy evaluates the leaf alone, so a + * withholding is the one decision that can say something about the rest of the + * object: the output is not deliverable, and withholding the leaf while a + * neighbour still carries what the tool printed withholds nothing. + * + * Without `companions` this is `replacingOutput(location, WITHHELD_OUTPUT)`, + * unchanged. With them: + * + * - a companion the payload does not carry is skipped, and an empty one is + * left as it is, because neither holds anything to withhold; + * - a companion holding something other than text throws, for the reason + * `replacingOutput` throws on a leaf of another type; + * - an absent leaf is tolerated when at least one companion holds text. The + * replacement then patches the fields that exist and adds none, which keeps + * the tool's own shape. With no leaf and no text to withhold it throws, as + * `replacingOutput` does. + * + * A redaction does not come through here. `replacingOutput` carries a rewrite of + * the leaf, and the companions stay as the host produced them. + */ +export function withheldOutput(location: HostOutputLocation): Record { + const { payload, outputs } = location; + const declared = outputs.companions ?? []; + if (declared.length === 0) { + return replacingOutput(location, WITHHELD_OUTPUT); + } + + const withinSegments = pathSegments(outputs.within); + const fromSegments = pathSegments(outputs.from); + const leafSegments = fromSegments.slice(withinSegments.length); + const container = resolvePath(payload, outputs.within); + const pathsRelate = withinSegments.every((segment, index) => fromSegments[index] === segment); + if (!isPlainObject(container) || !pathsRelate || leafSegments.length === 0) { + // `replacingOutput` owns the message for each of these three. + return replacingOutput(location, WITHHELD_OUTPUT); + } + + const companions = declared.map((companion) => { + const segments = pathSegments(companion); + if (!withinSegments.every((segment, index) => segments[index] === segment)) { + throw new Error( + `result-output: hookmap companion ${JSON.stringify(companion)} is not inside ` + + `${JSON.stringify(outputs.within)}, so it names no field of the output object`, + ); + } + const relative = segments.slice(withinSegments.length); + if (relative.length === 0) { + throw new Error( + `result-output: hookmap companion ${JSON.stringify(companion)} names the same object as ` + + `"outputs.within" ${JSON.stringify(outputs.within)} itself, not a field inside it`, + ); + } + if (pathsOverlap(leafSegments, relative)) { + throw new Error( + `result-output: hookmap companion ${JSON.stringify(companion)} and leaf path ` + + `${JSON.stringify(outputs.from)} are not disjoint -- a companion names a field DIFFERENT from the leaf`, + ); + } + for (const mirror of outputs.mirrors ?? []) { + if (pathsOverlap(pathSegments(mirror).slice(withinSegments.length), relative)) { + throw new Error( + `result-output: hookmap companion ${JSON.stringify(companion)} and mirror ${JSON.stringify(mirror)} ` + + `are not disjoint -- a companion holds text of its own, a mirror holds a copy of the leaf`, + ); + } + } + return { companion, relative }; + }); + for (let i = 0; i < companions.length; i += 1) { + for (let j = i + 1; j < companions.length; j += 1) { + const first = companions[i] as { companion: string; relative: string[] }; + const second = companions[j] as { companion: string; relative: string[] }; + if (pathsOverlap(first.relative, second.relative)) { + throw new Error( + `result-output: hookmap companions ${JSON.stringify(first.companion)} and ` + + `${JSON.stringify(second.companion)} are not disjoint (equal, ancestor, or descendant)`, + ); + } + } + } + + const leafPresent = resolveSegments(container, leafSegments) !== undefined; + let patched: Record = leafPresent ? replacingOutput(location, WITHHELD_OUTPUT) : { ...container }; + let withheld = leafPresent; + for (const { companion, relative } of companions) { + const value = resolveSegments(patched, relative); + if (value === undefined || value === "") { + continue; + } + if (typeof value !== "string") { + throw new Error( + `result-output: hookmap companion ${JSON.stringify(companion)} holds a ${typeof value}, not text -- ` + + `the host validates a replacement against the tool's own output shape and delivers the ORIGINAL when ` + + `it does not match, so a text replacement there withholds nothing`, + ); + } + patched = patchedClone(patched, relative, WITHHELD_OUTPUT, companion); + withheld = true; + } + if (!withheld) { + throw new Error( + `result-output: hookmap path ${JSON.stringify(outputs.from)} resolves to no value in this payload and no ` + + `declared companion holds text, so there is nothing to withhold and no shape to patch -- a replacement ` + + `that added a field could be declined by the host, and a declined replacement delivers the original output`, + ); + } + return patched; +} + /** * Throws unless a replacing output can be built for this payload at all -- * asked, deliberately, by building one. @@ -490,7 +605,7 @@ export function replacingOutput(location: HostOutputLocation, replacement: unkno * is in whether it could be made. */ export function assertOutputIsReplaceable(location: HostOutputLocation): void { - replacingOutput(location, WITHHELD_OUTPUT); + withheldOutput(location); } /** @@ -703,7 +818,7 @@ export function withResultOutput(decision: AcsDecision, location: HostOutputLoca return decision; } if (withholdsAtResultGate(decision.decision)) { - return { ...decision, applied_output: replacingOutput(location, WITHHELD_OUTPUT) } satisfies ValidatedAcsDecision; + return { ...decision, applied_output: withheldOutput(location) } satisfies ValidatedAcsDecision; } if (decision.decision === DELIVERS_A_REPLACEMENT && (decision as ValidatedAcsDecision).applied_output === undefined) { throw new Error( diff --git a/reference-implementations/agt/packages/host-adapter/test/result-companions.test.ts b/reference-implementations/agt/packages/host-adapter/test/result-companions.test.ts new file mode 100644 index 00000000..38e79a2a --- /dev/null +++ b/reference-implementations/agt/packages/host-adapter/test/result-companions.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from "bun:test"; +import { mkdtempSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadHookmap } from "../src/build-envelope.ts"; +import { + assertOutputIsReplaceable, + replacingOutput, + withheldOutput, + withResultOutput, + WITHHELD_OUTPUT, + type HostOutputLocation, +} from "../src/result-output.ts"; + +/** + * A host's result object can carry more than one field the model reads. The + * policy evaluates one of them, the leaf. A companion is another text field in + * the same object that is not a copy of the leaf. A withholding replaces every + * companion that holds text along with the leaf, and a redaction leaves them as + * the host produced them. + * + * The paths below are the shape of Claude Code's Bash result, which is what the + * shipped hookmap declares, but this module names no host field. + */ +const SECRET = "ghp_ABCDEF123456"; + +function bash(response: Record, companions: string[] = ["$.tool_response.stderr"]): HostOutputLocation { + return { + payload: { tool_response: response }, + outputs: { from: "$.tool_response.stdout", within: "$.tool_response", companions }, + }; +} + +const FULL = { stdout: `TOKEN=${SECRET}`, stderr: `warn ${SECRET}`, interrupted: false, isImage: false }; + +describe("withheldOutput", () => { + it("replaces the leaf and a companion that holds text, keeping every other field", () => { + expect(withheldOutput(bash(FULL))).toEqual({ + stdout: WITHHELD_OUTPUT, + stderr: WITHHELD_OUTPUT, + interrupted: false, + isImage: false, + }); + }); + + it("replaces the companion when the leaf is already empty", () => { + expect(withheldOutput(bash({ ...FULL, stdout: "" }))).toEqual({ + stdout: WITHHELD_OUTPUT, + stderr: WITHHELD_OUTPUT, + interrupted: false, + isImage: false, + }); + }); + + it("leaves an empty companion empty", () => { + expect(withheldOutput(bash({ ...FULL, stderr: "" }))).toEqual({ + stdout: WITHHELD_OUTPUT, + stderr: "", + interrupted: false, + isImage: false, + }); + }); + + it("skips a companion the payload does not carry, and adds no field", () => { + const { stderr: _stderr, ...withoutStderr } = FULL; + const out = withheldOutput(bash(withoutStderr)); + expect(out).toEqual({ stdout: WITHHELD_OUTPUT, interrupted: false, isImage: false }); + expect("stderr" in out).toBe(false); + }); + + it("withholds a companion when the leaf is absent, and adds no leaf", () => { + const { stdout: _stdout, ...withoutStdout } = FULL; + const out = withheldOutput(bash(withoutStdout)); + expect(out).toEqual({ stderr: WITHHELD_OUTPUT, interrupted: false, isImage: false }); + expect("stdout" in out).toBe(false); + }); + + it("throws when the leaf is absent and no companion holds text", () => { + expect(() => withheldOutput(bash({ stderr: "", interrupted: false }))).toThrow(/nothing to withhold/); + expect(() => withheldOutput(bash({ interrupted: false }))).toThrow(/nothing to withhold/); + }); + + it("throws on a companion that holds something other than text", () => { + const location = bash(FULL, ["$.tool_response.interrupted"]); + expect(() => withheldOutput(location)).toThrow(/holds a boolean, not text/); + }); + + it("patches a nested companion and a mirror together with the leaf", () => { + const location: HostOutputLocation = { + payload: { result: { output: "S", metadata: { output: "S", log: { tail: "T" } }, exit: 0 } }, + outputs: { + from: "$.result.output", + within: "$.result", + mirrors: ["$.result.metadata.output"], + companions: ["$.result.metadata.log.tail"], + }, + }; + expect(withheldOutput(location)).toEqual({ + output: WITHHELD_OUTPUT, + metadata: { output: WITHHELD_OUTPUT, log: { tail: WITHHELD_OUTPUT } }, + exit: 0, + }); + }); + + it("does not mutate the payload it was handed", () => { + const location = bash({ ...FULL }); + withheldOutput(location); + expect(location.payload).toEqual({ tool_response: FULL }); + }); + + it("is replacingOutput, unchanged, when no companions are declared", () => { + const location = bash(FULL, []); + expect(withheldOutput(location)).toEqual(replacingOutput(location, WITHHELD_OUTPUT)); + expect(withheldOutput(location).stderr).toBe(FULL.stderr); + + const undeclared: HostOutputLocation = { + payload: location.payload, + outputs: { from: "$.tool_response.stdout", within: "$.tool_response" }, + }; + expect(withheldOutput(undeclared)).toEqual(replacingOutput(undeclared, WITHHELD_OUTPUT)); + }); + + it("still throws what replacingOutput throws when there is no leaf and no companions", () => { + const location = bash({ stderr: "x" }, []); + expect(() => withheldOutput(location)).toThrow(/resolves to no value/); + }); + + it("still throws what replacingOutput throws when `within` is not an object", () => { + const location: HostOutputLocation = { + payload: { tool_response: "plain text" }, + outputs: { from: "$.tool_response.stdout", within: "$.tool_response", companions: ["$.tool_response.stderr"] }, + }; + expect(() => withheldOutput(location)).toThrow(/does not resolve to an object/); + }); +}); + +describe("withheldOutput rejects a companion that cannot be patched safely", () => { + it("outside `within`", () => { + expect(() => withheldOutput(bash(FULL, ["$.tool_input.command"]))).toThrow(/is not inside/); + }); + + it("equal to `within`", () => { + expect(() => withheldOutput(bash(FULL, ["$.tool_response"]))).toThrow(/names the same object/); + }); + + it("equal to the leaf", () => { + expect(() => withheldOutput(bash(FULL, ["$.tool_response.stdout"]))).toThrow(/not disjoint/); + }); + + it("overlapping a mirror", () => { + const location: HostOutputLocation = { + payload: { r: { out: "S", meta: { out: "S" } } }, + outputs: { from: "$.r.out", within: "$.r", mirrors: ["$.r.meta.out"], companions: ["$.r.meta"] }, + }; + expect(() => withheldOutput(location)).toThrow(/mirror .* not disjoint/); + }); + + it("overlapping another companion, whichever is listed first", () => { + const payload = { r: { out: "S", a: { b: "x" } } }; + for (const companions of [["$.r.a", "$.r.a.b"], ["$.r.a.b", "$.r.a"], ["$.r.a", "$.r.a"]]) { + const location: HostOutputLocation = { payload, outputs: { from: "$.r.out", within: "$.r", companions } }; + expect(() => withheldOutput(location)).toThrow(/companions .* not disjoint/); + } + }); +}); + +describe("a redaction leaves companions as produced", () => { + it("replacingOutput patches the leaf alone", () => { + expect(replacingOutput(bash(FULL), "TOKEN=[REDACTED]")).toEqual({ + stdout: "TOKEN=[REDACTED]", + stderr: FULL.stderr, + interrupted: false, + isImage: false, + }); + }); +}); + +describe("the callers that build a withholding", () => { + it("assertOutputIsReplaceable asks withheldOutput, so a bad companion fails at preflight", () => { + expect(() => assertOutputIsReplaceable(bash(FULL))).not.toThrow(); + expect(() => assertOutputIsReplaceable(bash(FULL, ["$.tool_input.command"]))).toThrow(/is not inside/); + expect(() => assertOutputIsReplaceable(bash(FULL, ["$.tool_response.interrupted"]))).toThrow(/not text/); + }); + + it("withResultOutput withholds companions on deny, ask and defer", () => { + for (const decision of ["deny", "ask", "defer"]) { + const out = withResultOutput({ decision }, bash(FULL)) as { applied_output?: Record }; + expect(out.applied_output).toEqual({ + stdout: WITHHELD_OUTPUT, + stderr: WITHHELD_OUTPUT, + interrupted: false, + isImage: false, + }); + } + }); + + it("withResultOutput leaves allow alone and passes a modify through untouched", () => { + expect(withResultOutput({ decision: "allow" }, bash(FULL))).toEqual({ decision: "allow" }); + const modify = { decision: "modify", applied_output: { stdout: "TOKEN=[REDACTED]", stderr: FULL.stderr } }; + expect(withResultOutput(modify, bash(FULL))).toBe(modify); + }); +}); + +describe("outputs.companions at load time", () => { + function withHookmapFile(content: string, fn: (path: string) => void): void { + const dir = mkdtempSync(join(tmpdir(), "acs-hookmap-companions-")); + const path = join(dir, "hookmap.yaml"); + writeFileSync(path, content); + try { + fn(path); + } finally { + unlinkSync(path); + rmdirSync(dir); + } + } + const POST_TOOL_USE = + "host: claude-code\nhooks:\n PostToolUse:\n acs_method: steps/toolCallResult\n" + + " tool_name: $.tool_name\n exit_status: { literal: success }\n" + + " decisions:\n" + + " allow: { output: { x: { value: y } } }\n" + + " deny: { output: { x: { value: y } } }\n"; + const outputsWith = (companions: string): string => + `${POST_TOOL_USE} outputs: { from: $.tool_response.stdout, within: $.tool_response, companions: ${companions} }\n`; + + it("accepts a well-formed list", () => { + withHookmapFile(outputsWith("[$.tool_response.stderr]"), (path) => { + expect(loadHookmap(path).hooks.PostToolUse?.outputs?.companions).toEqual(["$.tool_response.stderr"]); + }); + }); + + it("throws when it is not a list of non-empty strings", () => { + for (const bad of ["42", "[1]", '[""]', "stderr"]) { + withHookmapFile(outputsWith(bad), (path) => { + expect(() => loadHookmap(path)).toThrow(/"hooks\.PostToolUse\.outputs\.companions" is /); + }); + } + }); + + it("throws when an entry is not a field inside `within`", () => { + for (const bad of ["[$.tool_input.command]", "[$.tool_response]", "[$.tool_response.]"]) { + withHookmapFile(outputsWith(bad), (path) => { + expect(() => loadHookmap(path)).toThrow(/companions" entry .* is not a field inside "outputs\.within"/); + }); + } + }); +}); + +describe("the shipped Claude Code hookmap", () => { + const outputs = loadHookmap("hosts/claude-code/claude-code.hookmap.yaml").hooks.PostToolUse?.outputs; + + it("declares stderr as a companion of stdout", () => { + expect(outputs?.from).toBe("$.tool_response.stdout"); + expect(outputs?.companions).toEqual(["$.tool_response.stderr"]); + }); + + it("withholds both on a real Bash result, and leaves a silent command alone", () => { + if (outputs === undefined) { + throw new Error('test fixture assumption broken: "PostToolUse" declares no "outputs" block'); + } + const full = { stdout: `a ${SECRET}`, stderr: `b ${SECRET}`, interrupted: false, isImage: false, noOutputExpected: false }; + const withheld = withheldOutput({ payload: { tool_response: full }, outputs }); + expect(withheld).toEqual({ + stdout: WITHHELD_OUTPUT, + stderr: WITHHELD_OUTPUT, + interrupted: false, + isImage: false, + noOutputExpected: false, + }); + expect(JSON.stringify(withheld)).not.toContain(SECRET); + + const silent = { stdout: "", stderr: "", interrupted: false, isImage: false, noOutputExpected: true }; + expect(withheldOutput({ payload: { tool_response: silent }, outputs })).toEqual({ ...silent, stdout: WITHHELD_OUTPUT }); + }); +});