From fb9b2ab46945a3bee696eccc6a29ae32ec61e21f Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Thu, 2 Jul 2026 22:20:10 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=90=9B=20(sdk):=20Fail=20closed=20on?= =?UTF-8?q?=20runtime-down=20and=20unknown=20decision=20under=20enforce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The napi shim folds an unreachable/slow/closed runtime (QueryFailed, ChannelClosed, Shutdown) onto a NON-throwing `allow` + FAIL_OPEN_REASON, and an unspecified verdict onto the empty decision "". The AAASM-3996 gateway guard is catch-only, so these non-throwing sentinels bypass it and a policy-denied tool executes under `enforcementMode: "enforce"`. Make the raw-verdict mapping (`mapDecisionToPolicyResult`) fail-closed aware: under `failClosed` (set only for enforce), the fail-open sentinel and the unknown/empty decision map to `{ denied: true }` instead of allow. Genuine allow/redact and observe/disabled/unset postures are unchanged (fail open). This matches the Python SDK's enforce posture. Refs AAASM-4013 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01MvjnG3ysnqTY6Gu1wQ2h73 --- src/core/init-assembly.ts | 10 ++++++-- src/native/client.ts | 53 ++++++++++++++++++++++++++++++++++----- src/types/policy.ts | 8 ++++++ 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/src/core/init-assembly.ts b/src/core/init-assembly.ts index 9dc7b40a1..b1ddb1177 100644 --- a/src/core/init-assembly.ts +++ b/src/core/init-assembly.ts @@ -133,7 +133,10 @@ export function createClient( createNativeClient({ gateway: config.gatewayUrl ?? "", apiKey: config.apiKey ?? "", - mode: "napi-inprocess" + mode: "napi-inprocess", + // AAASM-4013: under enforce, a runtime that returns no authoritative + // verdict (fail-open sentinel / unknown decision) must deny, not allow. + failClosed: config.enforcementMode === "enforce" }); return createNativeGatewayClient(mode, nativeClient, config.agentId, httpBaseUrl, config.enforcementMode); } @@ -408,7 +411,10 @@ export async function initAssembly(config: AssemblyConfig = {}): Promise Promise; } +/** + * Reason string the native shim attaches to a fail-open `"allow"` when the + * runtime does not answer (AAASM-4013). It must stay byte-for-byte identical to + * `FAIL_OPEN_REASON` in `native/aa-ffi-node/src/lib.rs`: the shim folds + * `QueryFailed` / `ChannelClosed` / `Shutdown` onto `"allow"` + this reason, so + * it is the *only* signal the JS layer has to tell "the runtime failed open" + * apart from a genuine policy `"allow"`. Under `failClosed` that distinction is + * security-critical. + */ +export const FAIL_OPEN_REASON = "aa-runtime unreachable or slow; failing open (advisory SDK)"; + /** * Translate the native `{decision, reason}` verdict into the SDK's * `PolicyResult`. Only `"deny"` blocks; `"pending"` routes to the approval - * path; `"allow"` / `"redact"` / any unrecognized value proceed. This mirrors - * the shared enforcement contract across the Python / Go / Node SDKs. + * path; `"allow"` / `"redact"` proceed. This mirrors the shared enforcement + * contract across the Python / Go / Node SDKs. * - * The native primitive already fails open (returns `"allow"`) when the runtime - * is unreachable or too slow, so a missing or degraded runtime never blocks. + * **Fail-closed (AAASM-4013):** the native shim folds an unreachable / slow / + * closed runtime onto `"allow"` + {@link FAIL_OPEN_REASON}, and an + * unspecified / unrecognized runtime verdict onto the empty decision `""`. + * Neither is an authoritative allow — it is "no verdict came back". When + * `failClosed` is set (only under `enforcementMode: "enforce"`) these must + * **deny** rather than silently proceed, matching the Python SDK's enforce + * posture. When `failClosed` is `false` (observe / disabled / unset) they fail + * open, preserving the advisory behavior — the proxy / eBPF layers remain + * authoritative. */ -function mapDecisionToPolicyResult(verdict: NativePolicyDecision): PolicyResult { +function mapDecisionToPolicyResult( + verdict: NativePolicyDecision, + failClosed: boolean +): PolicyResult { switch (verdict.decision) { case "deny": return { denied: true, pending: false, reason: verdict.reason }; case "pending": return { denied: false, pending: true, reason: verdict.reason }; + case "allow": + if (failClosed && verdict.reason === FAIL_OPEN_REASON) { + return { denied: true, pending: false, reason: verdict.reason }; + } + return { denied: false, pending: false }; + case "redact": + return { denied: false, pending: false }; default: + // Empty / unrecognized decision: the runtime produced no authoritative + // verdict. Deny under enforce (fail closed); otherwise proceed. + if (failClosed) { + return { + denied: true, + pending: false, + reason: verdict.reason || "runtime returned no authoritative decision" + }; + } return { denied: false, pending: false }; } } @@ -200,6 +237,10 @@ function loadNativeBinding(): NativeBinding { export function createNativeClient(options: InitAssemblyOptions): NativeClient { const mode = options.mode ?? "grpc-sidecar"; + // Fail-closed posture (AAASM-4013): deny on a non-authoritative verdict only + // under enforce. Defaults to fail-open so observe / disabled / unset are + // unchanged. + const failClosed = options.failClosed ?? false; if (mode !== "napi-inprocess") { return { @@ -293,7 +334,7 @@ export function createNativeClient(options: InitAssemblyOptions): NativeClient { // or too slow, so a missing or degraded runtime never blocks the agent. const handle = await getHandle(); const verdict = await binding.queryPolicy(handle, action); - return mapDecisionToPolicyResult(verdict); + return mapDecisionToPolicyResult(verdict, failClosed); }, register: async (options: RegisterOptions) => { // Register on the same session the queryPolicy path uses, so the token diff --git a/src/types/policy.ts b/src/types/policy.ts index e35a855a7..730955e36 100644 --- a/src/types/policy.ts +++ b/src/types/policy.ts @@ -4,6 +4,14 @@ export interface InitAssemblyOptions { gateway: string; apiKey: string; mode?: RuntimeMode; + /** + * Fail-closed posture for the native policy check (AAASM-4013). When `true` + * (set only under `enforcementMode: "enforce"`), a runtime that returns no + * authoritative verdict — the fail-open sentinel or an unknown decision — is + * treated as a denial rather than an allow. Defaults to `false` (fail open), + * preserving the advisory behavior for `observe` / `disabled` / unset modes. + */ + failClosed?: boolean; } export interface AssemblyRuntimeHandle { From dfecedd5986a3eab28e17a4827bdd7f31e24827b Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Thu, 2 Jul 2026 22:20:10 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=85=20(sdk):=20Cover=20fail-closed=20?= =?UTF-8?q?on=20sentinel=20and=20unknown=20decision=20under=20enforce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add native-client tests exercising the REAL non-throwing allow sentinel (decision "allow" + FAIL_OPEN_REASON) and the unknown/empty decision under failClosed → denied, plus the fail-open counterparts. Add an end-to-end gateway+wrapper test proving the sentinel and unknown verdict block the tool under enforce through the real native client. Refs AAASM-4013 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01MvjnG3ysnqTY6Gu1wQ2h73 --- tests/native-client.test.ts | 100 +++++++++++++++++++++++ tests/native-gateway-enforcement.test.ts | 83 +++++++++++++++++++ 2 files changed, 183 insertions(+) diff --git a/tests/native-client.test.ts b/tests/native-client.test.ts index 2a91baa7b..827ae6150 100644 --- a/tests/native-client.test.ts +++ b/tests/native-client.test.ts @@ -187,6 +187,106 @@ describe("createNativeClient", () => { await client.close(); }); + // AAASM-4013: the native shim folds an unreachable/slow/closed runtime onto a + // NON-throwing `allow` + FAIL_OPEN_REASON, and an unspecified runtime verdict + // onto the empty decision "". Under `failClosed` (enforce) neither is an + // authoritative allow, so both must deny rather than silently proceed. + describe("fail-closed on non-authoritative verdicts (AAASM-4013)", () => { + it("napi-inprocess + failClosed: the fail-open allow sentinel denies", async () => { + const mod = await loadNativeClientWithBinding(() => ({ + connect: vi.fn(async () => ({ id: "handle-sentinel" })), + sendEvent: vi.fn(() => undefined), + // The REAL runtime-down verdict: a non-throwing allow tagged with the + // shim's fail-open reason (not a synthetic throw). + queryPolicy: vi.fn(() => ({ decision: "allow", reason: mod.FAIL_OPEN_REASON })), + disconnect: vi.fn(async () => undefined) + })); + + const client = mod.createNativeClient({ + gateway: "/tmp/aa.sock", + apiKey: "test-key", + mode: "napi-inprocess", + failClosed: true + }); + + await expect(client.queryPolicy({ action_type: "tool_call" })).resolves.toEqual({ + denied: true, + pending: false, + reason: mod.FAIL_OPEN_REASON + }); + await client.close(); + }); + + it("napi-inprocess + failClosed: an unknown/empty decision denies", async () => { + const mod = await loadNativeClientWithBinding(() => ({ + connect: vi.fn(async () => ({ id: "handle-unknown" })), + sendEvent: vi.fn(() => undefined), + // Unspecified/garbled runtime verdict maps to "" via decision_to_str. + queryPolicy: vi.fn(() => ({ decision: "", reason: "" })), + disconnect: vi.fn(async () => undefined) + })); + + const client = mod.createNativeClient({ + gateway: "/tmp/aa.sock", + apiKey: "test-key", + mode: "napi-inprocess", + failClosed: true + }); + + await expect(client.queryPolicy({ action_type: "tool_call" })).resolves.toEqual({ + denied: true, + pending: false, + reason: "runtime returned no authoritative decision" + }); + await client.close(); + }); + + it("napi-inprocess without failClosed: the sentinel still fails open (observe/disabled)", async () => { + const mod = await loadNativeClientWithBinding(() => ({ + connect: vi.fn(async () => ({ id: "handle-open" })), + sendEvent: vi.fn(() => undefined), + queryPolicy: vi.fn(() => ({ decision: "allow", reason: mod.FAIL_OPEN_REASON })), + disconnect: vi.fn(async () => undefined) + })); + + const client = mod.createNativeClient({ + gateway: "/tmp/aa.sock", + apiKey: "test-key", + mode: "napi-inprocess" + }); + + await expect(client.queryPolicy({ action_type: "tool_call" })).resolves.toEqual({ + denied: false, + pending: false + }); + await client.close(); + }); + + it("napi-inprocess + failClosed: a genuine authoritative allow still proceeds", async () => { + const mod = await loadNativeClientWithBinding(() => ({ + connect: vi.fn(async () => ({ id: "handle-genuine" })), + sendEvent: vi.fn(() => undefined), + // A real policy allow (not the fail-open sentinel) must not be denied, + // even under enforce. + queryPolicy: vi.fn(() => ({ decision: "allow", reason: "policy: permitted" })), + disconnect: vi.fn(async () => undefined) + })); + + const client = mod.createNativeClient({ + gateway: "/tmp/aa.sock", + apiKey: "test-key", + mode: "napi-inprocess", + failClosed: true + }); + + await expect(client.queryPolicy({ action_type: "tool_call" })).resolves.toEqual({ + denied: false, + pending: false + }); + await client.close(); + }); + }); + it("maps connect failure to NativeConnectError", async () => { const mod = await loadNativeClientWithBinding(() => ({ connect: vi.fn(async () => { diff --git a/tests/native-gateway-enforcement.test.ts b/tests/native-gateway-enforcement.test.ts index dff10f008..63421bc70 100644 --- a/tests/native-gateway-enforcement.test.ts +++ b/tests/native-gateway-enforcement.test.ts @@ -177,3 +177,86 @@ describe("native gateway enforcement (AAASM-3050)", () => { expect(executeFn).toHaveBeenCalledOnce(); }); }); + +/** + * AAASM-4013 — end-to-end fail-closed enforcement through the REAL native client. + * + * Unlike the suite above (which hand-rolls a `NativeClient`), these tests drive + * the actual `createNativeClient` over a mocked native binding so the real + * `{decision, reason}` → `PolicyResult` mapping runs. This is what makes the bug + * observable: the shim folds a killed / stalled / unreachable runtime onto a + * NON-throwing `allow` + `FAIL_OPEN_REASON` (not a thrown error), and folds an + * unspecified verdict onto the empty decision `""`. Under `enforcementMode: + * "enforce"` (→ `failClosed: true`) both must block the tool; without it they + * fail open, so the tool proceeds (observe / disabled posture). + */ +async function realNativeClientWithVerdict( + verdict: { decision: string; reason: string }, + failClosed: boolean +): Promise { + vi.resetModules(); + vi.doMock("node:module", () => ({ + createRequire: () => { + const binding = { + connect: vi.fn(async () => ({ id: "handle-4013" })), + sendEvent: vi.fn(() => undefined), + queryPolicy: vi.fn(() => verdict), + disconnect: vi.fn(async () => undefined) + }; + return () => binding; + } + })); + const mod = await import("../src/native/client.js"); + return mod.createNativeClient({ + gateway: "/tmp/aa.sock", + apiKey: "test-key", + mode: "napi-inprocess", + failClosed + }); +} + +describe("native gateway fail-closed under enforce (AAASM-4013)", () => { + const FAIL_OPEN_REASON = "aa-runtime unreachable or slow; failing open (advisory SDK)"; + + it("ENFORCE: a runtime-down allow sentinel blocks the tool (never runs its body)", async () => { + const nativeClient = await realNativeClientWithVerdict( + { decision: "allow", reason: FAIL_OPEN_REASON }, + true + ); + const gateway = createNativeGatewayClient("napi-inprocess", nativeClient, "agent-1"); + + const executeFn = vi.fn(async () => "ran-despite-runtime-down"); + const tools = { delete_all: { description: "danger", execute: executeFn } }; + withAssembly(tools, { gatewayClient: gateway }); + + await expect(tools.delete_all.execute()).rejects.toThrow(PolicyViolationError); + expect(executeFn).not.toHaveBeenCalled(); + }); + + it("ENFORCE: an unknown/empty runtime decision blocks the tool", async () => { + const nativeClient = await realNativeClientWithVerdict({ decision: "", reason: "" }, true); + const gateway = createNativeGatewayClient("napi-inprocess", nativeClient, "agent-1"); + + const executeFn = vi.fn(async () => "ran-on-unknown-decision"); + const tools = { wire_transfer: { description: "money", execute: executeFn } }; + withAssembly(tools, { gatewayClient: gateway }); + + await expect(tools.wire_transfer.execute()).rejects.toThrow(PolicyViolationError); + expect(executeFn).not.toHaveBeenCalled(); + }); + + it("OBSERVE (no failClosed): the same runtime-down sentinel fails open and the tool runs", async () => { + const nativeClient = await realNativeClientWithVerdict( + { decision: "allow", reason: FAIL_OPEN_REASON }, + false + ); + const gateway = createNativeGatewayClient("napi-inprocess", nativeClient, "agent-1"); + + const executeFn = vi.fn(async () => "ran-fail-open"); + const tools = { send_email: { description: "email", execute: executeFn } }; + withAssembly(tools, { gatewayClient: gateway }); + + await expect(tools.send_email.execute()).resolves.toBe("ran-fail-open"); + expect(executeFn).toHaveBeenCalledOnce(); + }); +}); From 437a427f1e0d57597e06b1f7e00a619b63c9aeb4 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Fri, 3 Jul 2026 09:51:18 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=85=20(sdk):=20Cover=20redact/fail-op?= =?UTF-8?q?en=20map=20branches=20+=20enforce=20plumbing=20(AAASM-4013)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests for the previously-uncovered new lines flagged by codecov/patch: - mapDecisionToPolicyResult: a REDACT verdict is authoritative and proceeds even under failClosed; an unknown/empty decision without failClosed still fails open (observe/disabled). - createClient: under enforcementMode enforce a runtime-down fail-open sentinel is denied end-to-end through check(); under observe the same sentinel proceeds — exercising the failClosed=enforce plumbing (both branches). --- tests/create-client-mode-routing.test.ts | 44 +++++++++++++++++++++ tests/native-client.test.ts | 50 ++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/tests/create-client-mode-routing.test.ts b/tests/create-client-mode-routing.test.ts index 7b2c18d9a..f8c7d1ca1 100644 --- a/tests/create-client-mode-routing.test.ts +++ b/tests/create-client-mode-routing.test.ts @@ -78,6 +78,50 @@ describe("createClient mode routing (AAASM-3050)", () => { await client.close(); }); + // AAASM-4013: `createClient` must derive the native fail-closed posture from + // `enforcementMode`. Under enforce, a runtime that fails open (returns the + // allow sentinel because it is unreachable / slow) must be DENIED end-to-end + // through `check()`; without enforce the same sentinel proceeds. This exercises + // the `failClosed: config.enforcementMode === "enforce"` plumbing (both ternary + // branches) via the real, non-overridden native-client path. + const FAIL_OPEN_REASON = "aa-runtime unreachable or slow; failing open (advisory SDK)"; + + it("napi-inprocess + enforce: a runtime-down fail-open sentinel is DENIED through check()", async () => { + const binding = makeBinding("allow", FAIL_OPEN_REASON); + const mod = await loadCreateClientWithBinding(binding); + + const client = mod.createClient({ + gatewayUrl: "/tmp/aa.sock", + apiKey: "k", + agentId: "agent-enforce", + mode: "napi-inprocess", + enforcementMode: "enforce" + }); + + await expect( + client.check({ action: "tool_call", toolName: "rm", runId: "run-enforce" }) + ).resolves.toEqual({ denied: true, pending: false, reason: FAIL_OPEN_REASON }); + await client.close(); + }); + + it("napi-inprocess + observe: the same fail-open sentinel proceeds (advisory)", async () => { + const binding = makeBinding("allow", FAIL_OPEN_REASON); + const mod = await loadCreateClientWithBinding(binding); + + const client = mod.createClient({ + gatewayUrl: "/tmp/aa.sock", + apiKey: "k", + agentId: "agent-observe", + mode: "napi-inprocess", + enforcementMode: "observe" + }); + + await expect( + client.check({ action: "tool_call", toolName: "search", runId: "run-observe" }) + ).resolves.toEqual({ denied: false, pending: false }); + await client.close(); + }); + it("sdk-only: check() is allow-by-default and never loads the native binding", async () => { const binding = makeBinding("deny", "must-not-be-consulted"); const mod = await loadCreateClientWithBinding(binding); diff --git a/tests/native-client.test.ts b/tests/native-client.test.ts index 827ae6150..7916b0264 100644 --- a/tests/native-client.test.ts +++ b/tests/native-client.test.ts @@ -285,6 +285,56 @@ describe("createNativeClient", () => { }); await client.close(); }); + + it("napi-inprocess + failClosed: a REDACT verdict is authoritative and proceeds", async () => { + // `redact` is a real runtime verdict (the tool runs, with output + // redaction applied downstream), NOT a "no verdict" sentinel — so it must + // proceed even under enforce. Only the fail-open sentinel / unknown + // decision deny under failClosed. + const mod = await loadNativeClientWithBinding(() => ({ + connect: vi.fn(async () => ({ id: "handle-redact" })), + sendEvent: vi.fn(() => undefined), + queryPolicy: vi.fn(() => ({ decision: "redact", reason: "secrets stripped" })), + disconnect: vi.fn(async () => undefined) + })); + + const client = mod.createNativeClient({ + gateway: "/tmp/aa.sock", + apiKey: "test-key", + mode: "napi-inprocess", + failClosed: true + }); + + await expect(client.queryPolicy({ action_type: "tool_call" })).resolves.toEqual({ + denied: false, + pending: false + }); + await client.close(); + }); + + it("napi-inprocess without failClosed: an unknown/empty decision still fails open (observe/disabled)", async () => { + // The observe/disabled counterpart of the failClosed unknown-decision + // test above: with no enforce posture, an unspecified runtime verdict must + // NOT start blocking — the proxy / eBPF layers stay authoritative. + const mod = await loadNativeClientWithBinding(() => ({ + connect: vi.fn(async () => ({ id: "handle-unknown-open" })), + sendEvent: vi.fn(() => undefined), + queryPolicy: vi.fn(() => ({ decision: "", reason: "" })), + disconnect: vi.fn(async () => undefined) + })); + + const client = mod.createNativeClient({ + gateway: "/tmp/aa.sock", + apiKey: "test-key", + mode: "napi-inprocess" + }); + + await expect(client.queryPolicy({ action_type: "tool_call" })).resolves.toEqual({ + denied: false, + pending: false + }); + await client.close(); + }); }); it("maps connect failure to NativeConnectError", async () => {