From 71420f881d315f7ff8439e57dff303a545649a61 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Fri, 7 Aug 2026 20:36:01 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=90=9B=20(wrappers):=20Emit=20an=20au?= =?UTF-8?q?dit=20event=20when=20a=20tool=20call=20is=20denied?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit withAssembly called gateway.check and never gateway.record, so a denied call produced no audit event at all — the thrown PolicyViolationError was the only trace and it never leaves the process. Record the deny before throwing, on both the policy-deny and the approval-rejected path. GatewayRecordEvent has no tool-name field, so the tool is named inside reason by reusing the error's own message. Rejections are swallowed so a failing sink cannot mask the enforcement decision. Refs AAASM-5665 --- src/wrappers/with-assembly.ts | 39 +++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/src/wrappers/with-assembly.ts b/src/wrappers/with-assembly.ts index bdaa407ca..9dad4960f 100644 --- a/src/wrappers/with-assembly.ts +++ b/src/wrappers/with-assembly.ts @@ -133,6 +133,37 @@ function hasInvoke( return typeof tool.invoke === "function"; } +/** + * Hand a pre-execution deny to the gateway's audit sink (AAASM-5665). + * + * Before this, `withAssembly` called `check` and never `record`, so a denied + * call produced no audit event at all — the thrown `PolicyViolationError` was + * the only trace, and it never leaves the process. + * + * `GatewayRecordEvent` carries no tool-name field and adding one is a wire + * change, so the tool is named inside `reason` — reusing the thrown error's own + * message, so the audit event and the error a caller sees cannot drift apart. + * + * Rejections are swallowed. A failing audit sink must not convert a policy deny + * into some other error: the throw that follows this call is the enforcement + * decision and has to survive. This matches the fire-and-forget `.catch()` the + * `ai-sdk` and `openai-agents` hooks already use for `recordResult`. + * + * What this does *not* do is make a deny observable in a released binary. Both + * shipped clients discard the event — `createNoopGatewayClient` returns + * undefined, `createNativeGatewayClient` fires only a one-time `AA_DEBUG` note + * — so a deny stays Unmeasured in audit evidence (ADR 0033 §6). Supplying a + * sink that retains it is tracked as AAASM-5681. + */ +async function recordDeny( + gateway: GatewayClient, + action: string, + runId: string, + reason: string +): Promise { + await gateway.record({ action, runId, reason }).catch(() => undefined); +} + /** * Run the full pre-execution governance chain for one wrapped tool call. * @@ -162,15 +193,19 @@ async function enforceGovernance( }); if (decision.denied) { - throw new PolicyViolationError(`Tool '${name}' blocked: ${decision.reason ?? "Denied"}`); + const error = new PolicyViolationError(`Tool '${name}' blocked: ${decision.reason ?? "Denied"}`); + await recordDeny(gateway, "tool_call_denied", runId, error.message); + throw error; } if (decision.pending) { const finalDecision = await waitForApprovalWithTimeout(gateway, name, runId, approvalTimeoutMs); if (finalDecision.denied) { - throw new PolicyViolationError( + const error = new PolicyViolationError( `Approval rejected for '${name}': ${finalDecision.reason ?? "Rejected"}` ); + await recordDeny(gateway, "tool_call_approval_rejected", runId, error.message); + throw error; } } } From dfd28ef93e406890217d4a447a0937abf9e729aa Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Fri, 7 Aug 2026 20:37:21 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E2=9C=85=20(tests):=20Assert=20the=20denie?= =?UTF-8?q?d=20call=20emits=20an=20audit=20event?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a control over the audit event withAssembly hands the gateway on a deny: the action, the run id correlating it with the decision, and the tool named in the reason. Assert auditResults stays empty so an implementation recording a bogus empty result cannot pass. Refs AAASM-5665 --- tests/quickstart-negative-control.test.ts | 42 +++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/quickstart-negative-control.test.ts b/tests/quickstart-negative-control.test.ts index 71a5ff616..f998407a8 100644 --- a/tests/quickstart-negative-control.test.ts +++ b/tests/quickstart-negative-control.test.ts @@ -239,6 +239,48 @@ describe("quick-start negative control: what a deny is and is not attributed to" }); }); +describe("quick-start negative control: a deny reaches the audit sink", () => { + it("hands the gateway an audit event naming the denied tool and its run", async () => { + const effect = fileEffect(); + const gateway = createPolicyGatewayClient({ denyTools: ["write_file"] }); + const tools = { + write_file: { execute: async (content: string) => effect.write(content) } + }; + + withAssembly(tools, { gatewayClient: gateway, agentId: AGENT_ID }); + + const outcome = await settle(tools.write_file.execute("denied-content")); + + // Absence of the effect first, as everywhere else in this file. + expect(effect.occurred()).toBe(false); + expect(outcome).toBeInstanceOf(PolicyViolationError); + + // The load-bearing assertion for AAASM-5665: the audit event the wrapper + // handed the gateway. Before this, `withAssembly` called `check` and never + // `record`, so `auditEvents` stayed empty on a deny while `decisions` was + // 1 — a deny existed only in the decision log. + // + // Scope of the evidence: this is the fixture's in-process array. Both + // shipped GatewayClient implementations discard the event + // (createNoopGatewayClient, createNativeGatewayClient), so a deny is + // Unmeasured in audit evidence on the shipped path — AAASM-5681. What this + // pins is the wrapper's call. + expect(gateway.auditEvents).toHaveLength(1); + const event = gateway.auditEvents[0]; + expect(event?.action).toBe("tool_call_denied"); + // Correlates the audit event with the decision that produced it. + expect(event?.runId).toBe(gateway.decisions[0]?.runId); + // GatewayRecordEvent has no tool-name field, so the tool is named in the + // reason. Asserting that keeps the event attributable without a wire change. + expect(event?.reason).toContain("write_file"); + + // The allowed-result sink is still untouched on a deny: the tool never ran, + // so there is no result to record. Without this, an implementation that + // recorded a bogus empty result would pass the assertions above. + expect(gateway.auditResults).toHaveLength(0); + }); +}); + describe("quick-start negative control: an ungoverned seam cannot look protected", () => { it("a tool with no execute/invoke seam is warned about and still performs its effect", async () => { const effect = fileEffect(); From e0c7a74cf464dfc1f6db17b5a721ed1a667fd783 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Fri, 7 Aug 2026 21:32:50 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=90=9B=20(wrappers):=20Contain=20a=20?= =?UTF-8?q?synchronously-throwing=20audit=20sink?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recordDeny attached .catch() to record()'s returned promise, so a caller-supplied record() that throws synchronously produced no promise to attach to and escaped past the PolicyViolationError — the audit failure became the caller's error and a `catch (e) { e instanceof PolicyViolationError }` no longer recognised the deny. gatewayClient is a documented public injection point, so this is reachable user code. Wrap the call instead, matching the Python companion's try/except around the same hook (AAASM-4782). Also correct the docstring: this is awaited, not the fire-and-forget idiom it claimed to match. Refs AAASM-5665 --- src/wrappers/with-assembly.ts | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/wrappers/with-assembly.ts b/src/wrappers/with-assembly.ts index 9dad4960f..6f996e4a0 100644 --- a/src/wrappers/with-assembly.ts +++ b/src/wrappers/with-assembly.ts @@ -144,10 +144,20 @@ function hasInvoke( * change, so the tool is named inside `reason` — reusing the thrown error's own * message, so the audit event and the error a caller sees cannot drift apart. * - * Rejections are swallowed. A failing audit sink must not convert a policy deny - * into some other error: the throw that follows this call is the enforcement - * decision and has to survive. This matches the fire-and-forget `.catch()` the - * `ai-sdk` and `openai-agents` hooks already use for `recordResult`. + * **Every failure mode is swallowed, synchronous throws included.** A failing + * audit sink must not convert a policy deny into some other error: the throw + * that follows this call is the enforcement decision and has to survive. The + * `try`/`catch` is load-bearing rather than stylistic — `gatewayClient` is a + * documented public injection point, so a caller-supplied `record` that throws + * synchronously never produces a promise for a trailing `.catch()` to attach + * to, and would escape past the `PolicyViolationError`. Mirrors the Python + * companion's `try/except Exception` around the same hook (AAASM-4782). + * + * This is deliberately awaited, unlike the `void ... .catch()` in + * `recordToolResultNonBlocking` (`hooks/ai-sdk.ts`, `hooks/openai-agents.ts`): + * the event is handed over before the deny is thrown, so it cannot be lost if + * the caller exits on the error. The cost is that a `record` which hangs delays + * the deny — there is no timeout here, unlike `waitForApprovalWithTimeout`. * * What this does *not* do is make a deny observable in a released binary. Both * shipped clients discard the event — `createNoopGatewayClient` returns @@ -161,7 +171,11 @@ async function recordDeny( runId: string, reason: string ): Promise { - await gateway.record({ action, runId, reason }).catch(() => undefined); + try { + await gateway.record({ action, runId, reason }); + } catch { + // Intentionally ignored — see the note above on why the deny must survive. + } } /** From fba52fc6f7e395101d33cc6c039670be59f583e3 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Fri, 7 Aug 2026 21:32:50 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(wrappers):=20Merge=20?= =?UTF-8?q?the=20two=20deny=20paths=20onto=20one=20record-then-throw?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The policy-deny and approval-rejected routes each carried their own recordDeny call. Review found the approval-rejected copy could be deleted outright with the whole suite still green — five tests executed the line and none asserted on it, so codecov reported it covered. Converge both routes on a single record-then-throw, mirroring the Python companion's single merged path. One call site cannot drift from itself. Refs AAASM-5665 --- src/wrappers/with-assembly.ts | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/src/wrappers/with-assembly.ts b/src/wrappers/with-assembly.ts index 6f996e4a0..bd7486881 100644 --- a/src/wrappers/with-assembly.ts +++ b/src/wrappers/with-assembly.ts @@ -206,22 +206,34 @@ async function enforceGovernance( runId }); - if (decision.denied) { - const error = new PolicyViolationError(`Tool '${name}' blocked: ${decision.reason ?? "Denied"}`); - await recordDeny(gateway, "tool_call_denied", runId, error.message); - throw error; - } + // Both refusal routes converge on a single record-then-throw below rather + // than each carrying its own copy (AAASM-5665). Two call sites is an + // invitation to fix one and miss the other — which is exactly what happened + // in review: the approval-rejected copy could be deleted outright with the + // whole suite still green. One site cannot drift from itself. + let denial: { action: string; error: PolicyViolationError } | undefined; - if (decision.pending) { + if (decision.denied) { + denial = { + action: "tool_call_denied", + error: new PolicyViolationError(`Tool '${name}' blocked: ${decision.reason ?? "Denied"}`) + }; + } else if (decision.pending) { const finalDecision = await waitForApprovalWithTimeout(gateway, name, runId, approvalTimeoutMs); if (finalDecision.denied) { - const error = new PolicyViolationError( - `Approval rejected for '${name}': ${finalDecision.reason ?? "Rejected"}` - ); - await recordDeny(gateway, "tool_call_approval_rejected", runId, error.message); - throw error; + denial = { + action: "tool_call_approval_rejected", + error: new PolicyViolationError( + `Approval rejected for '${name}': ${finalDecision.reason ?? "Rejected"}` + ) + }; } } + + if (denial) { + await recordDeny(gateway, denial.action, runId, denial.error.message); + throw denial.error; + } } /** From 721a8e1196869fbf236e2ffb2ec0f9293e4aead6 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Fri, 7 Aug 2026 21:33:05 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=E2=9C=85=20(tests):=20Add=20fixtures=20for?= =?UTF-8?q?=20the=20approval-reject=20and=20failing-sink=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createPendingThenRejectGatewayClient drives the second refusal route; createFailingRecordGatewayClient fails record() either as a rejected promise or as a synchronous throw, and counts attempts so a control can tell "the sink failed" from "the sink was never called". Refs AAASM-5665 --- tests/helpers/negative-control.ts | 82 +++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/tests/helpers/negative-control.ts b/tests/helpers/negative-control.ts index 38867ac40..8a0cbdaf8 100644 --- a/tests/helpers/negative-control.ts +++ b/tests/helpers/negative-control.ts @@ -163,6 +163,88 @@ export interface PolicyGatewayClient extends GatewayClient { readonly auditResults: readonly GatewayResultRecord[]; } +/** + * A {@link GatewayClient} whose `check` returns `pending` and whose approval is + * then rejected — the second refusal route through `enforceGovernance`, which + * emits a different audit action from a straight policy deny. + */ +export function createPendingThenRejectGatewayClient(): PolicyGatewayClient { + const decisions: RecordedCheck[] = []; + const checkRequests: GatewayCheckRequest[] = []; + const auditEvents: GatewayRecordEvent[] = []; + const auditResults: GatewayResultRecord[] = []; + + return { + mode: "sdk-only", + decisions, + checkRequests, + auditEvents, + auditResults, + start: async () => undefined, + close: async () => undefined, + check: async (request: GatewayCheckRequest): Promise => { + checkRequests.push(request); + decisions.push({ + toolName: request.toolName, + action: request.action, + runId: request.runId, + denied: false + }); + return { denied: false, pending: true }; + }, + waitForApproval: async () => ({ denied: true, reason: "approver said no" }), + record: async (event: GatewayRecordEvent) => { + auditEvents.push(event); + }, + recordResult: async (record: GatewayResultRecord) => { + auditResults.push(record); + }, + scanPrompts: async () => undefined + }; +} + +/** A denying {@link GatewayClient} whose audit `record` always fails. */ +export interface FailingRecordGatewayClient extends GatewayClient { + /** How many times `record` was entered, so a test can tell "failed" from "never called". */ + readonly recordAttempts: number; +} + +/** + * A denying client whose `record` fails in one of two shapes. + * + * `sync-throw` is the load-bearing one: it throws *before* returning a promise, + * so a trailing `.catch()` on the return value never sees it. Only a + * `try`/`catch` around the call contains it. + */ +export function createFailingRecordGatewayClient( + mode: "async-reject" | "sync-throw" +): FailingRecordGatewayClient { + let attempts = 0; + return { + mode: "sdk-only", + get recordAttempts() { + return attempts; + }, + start: async () => undefined, + close: async () => undefined, + check: async (request: GatewayCheckRequest): Promise => ({ + denied: true, + pending: false, + reason: `tool '${request.toolName}' is denied by policy` + }), + waitForApproval: async () => ({ denied: true }), + record: (() => { + attempts += 1; + if (mode === "sync-throw") { + throw new Error("audit sink down (sync)"); + } + return Promise.reject(new Error("audit sink down (async)")); + }) as GatewayClient["record"], + recordResult: async () => undefined, + scanPrompts: async () => undefined + }; +} + export function createPolicyGatewayClient(options: { denyTools: readonly string[]; }): PolicyGatewayClient { From 93ec47676c2108e56c5a9b972f6412e59b0edd2c Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Fri, 7 Aug 2026 21:33:05 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=E2=9C=85=20(tests):=20Pin=20the=20approval?= =?UTF-8?q?-reject=20action=20and=20the=20failing-sink=20deny?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two call sites went unasserted. The approval-rejected audit action was executed by five tests and checked by none, and no control covered a record() that fails. Also rename the describe: it called the fixture's in-process array "the audit sink", the same over-claim that blocked the go-sdk PR, in the string CI prints. It now names the call, which is what the block reads. Refs AAASM-5665 --- tests/quickstart-negative-control.test.ts | 59 ++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/tests/quickstart-negative-control.test.ts b/tests/quickstart-negative-control.test.ts index f998407a8..1d24a3fdd 100644 --- a/tests/quickstart-negative-control.test.ts +++ b/tests/quickstart-negative-control.test.ts @@ -30,8 +30,10 @@ import { PolicyViolationError } from "../src/errors/policy-violation-error.js"; import { initAssembly } from "../src/core/init-assembly.js"; import { withAssembly } from "../src/wrappers/with-assembly.js"; import { + createFailingRecordGatewayClient, createFileSideEffect, createNetworkSideEffect, + createPendingThenRejectGatewayClient, createPolicyGatewayClient, type FileSideEffect, type NetworkSideEffect @@ -239,7 +241,7 @@ describe("quick-start negative control: what a deny is and is not attributed to" }); }); -describe("quick-start negative control: a deny reaches the audit sink", () => { +describe("quick-start negative control: a deny is handed to the gateway's record call", () => { it("hands the gateway an audit event naming the denied tool and its run", async () => { const effect = fileEffect(); const gateway = createPolicyGatewayClient({ denyTools: ["write_file"] }); @@ -279,6 +281,61 @@ describe("quick-start negative control: a deny reaches the audit sink", () => { // recorded a bogus empty result would pass the assertions above. expect(gateway.auditResults).toHaveLength(0); }); + + it("hands the gateway a distinct audit event when an approval is rejected", async () => { + // The second refusal route. Review round 1 found this call site executed by + // five tests and asserted by none: deleting it left the suite bit-identical, + // while deleting its policy-deny sibling failed a test. Execution is not + // pinning, so the action string gets its own control. + const effect = fileEffect(); + const gateway = createPendingThenRejectGatewayClient(); + const tools = { + write_file: { execute: async (content: string) => effect.write(content) } + }; + + withAssembly(tools, { gatewayClient: gateway, agentId: AGENT_ID }); + + const outcome = await settle(tools.write_file.execute("denied-content")); + + expect(effect.occurred()).toBe(false); + expect(outcome).toBeInstanceOf(PolicyViolationError); + + expect(gateway.auditEvents).toHaveLength(1); + const event = gateway.auditEvents[0]; + // Distinct from the policy-deny action, so the two routes stay + // distinguishable in whatever sink eventually retains them. + expect(event?.action).toBe("tool_call_approval_rejected"); + expect(event?.runId).toBe(gateway.decisions[0]?.runId); + expect(event?.reason).toContain("write_file"); + expect(gateway.auditResults).toHaveLength(0); + }); + + it("still throws the policy violation when the record call itself fails", async () => { + // A caller-supplied gatewayClient is a documented public injection point, so + // a record() that throws is reachable user code. Both failure shapes are + // covered: a rejected promise, and a SYNCHRONOUS throw — the latter produces + // no promise for a trailing .catch() to attach to and escaped past the deny + // before this was a try/catch. + for (const mode of ["async-reject", "sync-throw"] as const) { + const effect = fileEffect(); + const gateway = createFailingRecordGatewayClient(mode); + const tools = { + write_file: { execute: async (content: string) => effect.write(content) } + }; + + withAssembly(tools, { gatewayClient: gateway, agentId: AGENT_ID }); + + const outcome = await settle(tools.write_file.execute("denied-content")); + + expect(effect.occurred(), `[${mode}] the denied tool body ran`).toBe(false); + // The enforcement decision survives the audit failure — a caller doing + // `catch (e) { if (e instanceof PolicyViolationError) ... }` still sees it. + expect(outcome, `[${mode}] the audit failure replaced the deny`).toBeInstanceOf( + PolicyViolationError + ); + expect(gateway.recordAttempts, `[${mode}] record() was never attempted`).toBe(1); + } + }); }); describe("quick-start negative control: an ungoverned seam cannot look protected", () => { From c413700eaf86b0700aa8c2842320d1b03a9c5fa2 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Fri, 7 Aug 2026 21:58:40 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E2=9C=85=20(tests):=20Cover=20an=20approva?= =?UTF-8?q?l=20rejection=20that=20carries=20no=20reason?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The approval-rejected control always supplied an approver reason, leaving the wrapper's `?? "Rejected"` fallback a partial branch — the one line codecov flagged. A gateway is not obliged to explain a rejection, and attribution has to survive that: the tool name comes from the wrapper's own message, not the gateway's text. Run the control both ways. Refs AAASM-5665 --- tests/helpers/negative-control.ts | 9 +++-- tests/quickstart-negative-control.test.ts | 42 +++++++++++++---------- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/tests/helpers/negative-control.ts b/tests/helpers/negative-control.ts index 8a0cbdaf8..024c6ff59 100644 --- a/tests/helpers/negative-control.ts +++ b/tests/helpers/negative-control.ts @@ -168,7 +168,9 @@ export interface PolicyGatewayClient extends GatewayClient { * then rejected — the second refusal route through `enforceGovernance`, which * emits a different audit action from a straight policy deny. */ -export function createPendingThenRejectGatewayClient(): PolicyGatewayClient { +export function createPendingThenRejectGatewayClient( + approvalReason?: string +): PolicyGatewayClient { const decisions: RecordedCheck[] = []; const checkRequests: GatewayCheckRequest[] = []; const auditEvents: GatewayRecordEvent[] = []; @@ -192,7 +194,10 @@ export function createPendingThenRejectGatewayClient(): PolicyGatewayClient { }); return { denied: false, pending: true }; }, - waitForApproval: async () => ({ denied: true, reason: "approver said no" }), + // Omitting the reason is the case that exercises the wrapper's `?? "Rejected"` + // fallback — a gateway is not obliged to explain itself. + waitForApproval: async () => + approvalReason === undefined ? { denied: true } : { denied: true, reason: approvalReason }, record: async (event: GatewayRecordEvent) => { auditEvents.push(event); }, diff --git a/tests/quickstart-negative-control.test.ts b/tests/quickstart-negative-control.test.ts index 1d24a3fdd..0956cb06f 100644 --- a/tests/quickstart-negative-control.test.ts +++ b/tests/quickstart-negative-control.test.ts @@ -287,27 +287,33 @@ describe("quick-start negative control: a deny is handed to the gateway's record // five tests and asserted by none: deleting it left the suite bit-identical, // while deleting its policy-deny sibling failed a test. Execution is not // pinning, so the action string gets its own control. - const effect = fileEffect(); - const gateway = createPendingThenRejectGatewayClient(); - const tools = { - write_file: { execute: async (content: string) => effect.write(content) } - }; - - withAssembly(tools, { gatewayClient: gateway, agentId: AGENT_ID }); + // Run with and without an approver reason. A gateway is not obliged to + // explain a rejection, and attribution must survive that: the tool name + // comes from the wrapper's own message, not from the gateway's text. + for (const approvalReason of ["approver said no", undefined]) { + const effect = fileEffect(); + const gateway = createPendingThenRejectGatewayClient(approvalReason); + const tools = { + write_file: { execute: async (content: string) => effect.write(content) } + }; + const label = approvalReason === undefined ? "no reason" : "with reason"; - const outcome = await settle(tools.write_file.execute("denied-content")); + withAssembly(tools, { gatewayClient: gateway, agentId: AGENT_ID }); - expect(effect.occurred()).toBe(false); - expect(outcome).toBeInstanceOf(PolicyViolationError); + const outcome = await settle(tools.write_file.execute("denied-content")); - expect(gateway.auditEvents).toHaveLength(1); - const event = gateway.auditEvents[0]; - // Distinct from the policy-deny action, so the two routes stay - // distinguishable in whatever sink eventually retains them. - expect(event?.action).toBe("tool_call_approval_rejected"); - expect(event?.runId).toBe(gateway.decisions[0]?.runId); - expect(event?.reason).toContain("write_file"); - expect(gateway.auditResults).toHaveLength(0); + expect(effect.occurred(), `[${label}] the rejected tool body ran`).toBe(false); + expect(outcome, `[${label}]`).toBeInstanceOf(PolicyViolationError); + + expect(gateway.auditEvents, `[${label}]`).toHaveLength(1); + const event = gateway.auditEvents[0]; + // Distinct from the policy-deny action, so the two routes stay + // distinguishable in whatever sink eventually retains them. + expect(event?.action, `[${label}]`).toBe("tool_call_approval_rejected"); + expect(event?.runId, `[${label}]`).toBe(gateway.decisions[0]?.runId); + expect(event?.reason, `[${label}]`).toContain("write_file"); + expect(gateway.auditResults, `[${label}]`).toHaveLength(0); + } }); it("still throws the policy violation when the record call itself fails", async () => {