diff --git a/src/gateway/client.ts b/src/gateway/client.ts index e8212bbb..aac747ec 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -117,7 +117,18 @@ export function createNativeGatewayClient( return { denied: failClosed, pending: false }; } }, - waitForApproval: async () => ({ denied: false }), + // A `pending` verdict routes here to solicit an approval decision. The node + // SDK does not yet wire a real approval channel (poll/stream), so no + // decision can be obtained (AAASM-4129). Under `enforce` this must fail + // closed — deny — rather than silently downgrade an approval-required + // verdict to allow, matching python's `_resolve_pending_approval` and go's + // `WaitForApproval`, both of which deny when no approval channel is wired. + // In any advisory posture (observe / disabled / unset) it stays neutral so + // a missing approval channel never blocks the agent. + waitForApproval: async () => + failClosed + ? { denied: true, reason: "approval required but no approval channel is configured" } + : { denied: false }, record: async () => undefined, recordResult: async () => undefined, scanPrompts: async () => undefined diff --git a/tests/native-gateway-enforcement.test.ts b/tests/native-gateway-enforcement.test.ts index 63421bc7..1c20a448 100644 --- a/tests/native-gateway-enforcement.test.ts +++ b/tests/native-gateway-enforcement.test.ts @@ -169,13 +169,39 @@ describe("native gateway enforcement (AAASM-3050)", () => { const executeFn = vi.fn(async () => "approved-and-ran"); const tools = { wire_transfer: { description: "money", execute: executeFn } }; - // No approval push is wired in the node SDK yet, so the noop approval path - // resolves to "not denied" and the tool proceeds (per the shared contract). + // No approval channel is wired in the node SDK yet; in the ADVISORY posture + // (no enforcementMode) that resolves to "not denied" and the tool proceeds, + // so a missing approval channel never blocks the agent (AAASM-4129). withAssembly(tools, { gatewayClient: gateway }); await expect(tools.wire_transfer.execute()).resolves.toBe("approved-and-ran"); expect(executeFn).toHaveBeenCalledOnce(); }); + + it("PENDING under enforce: no approval channel fails closed and blocks the tool (AAASM-4129)", async () => { + // py/go parity: python's `_resolve_pending_approval` and go's + // `WaitForApproval` DENY a pending verdict when no approval channel is + // wired. Under enforce the node SDK must not auto-approve — the tool is + // blocked (never runs its body) rather than silently downgraded to allow. + const gateway = createNativeGatewayClient( + "napi-inprocess", + fakeNativeClient(async () => ({ + denied: false, + pending: true, + reason: "awaiting approval" + })), + "agent-1", + undefined, + "enforce" + ); + + const executeFn = vi.fn(async () => "should-not-run-without-approval"); + 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(); + }); }); /**