Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/core/init-assembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -408,7 +411,10 @@ export async function initAssembly(config: AssemblyConfig = {}): Promise<Assembl
nativeClient = createNativeClient({
gateway: resolvedGatewayUrl,
apiKey: resolvedApiKey,
mode: resolvedConfig.mode === "napi-inprocess" ? "napi-inprocess" : "grpc-sidecar"
mode: resolvedConfig.mode === "napi-inprocess" ? "napi-inprocess" : "grpc-sidecar",
// AAASM-4013: under enforce, a runtime that returns no authoritative
// verdict (fail-open sentinel / unknown decision) must deny, not allow.
failClosed: resolvedConfig.enforcementMode === "enforce"
});
}

Expand Down
53 changes: 47 additions & 6 deletions src/native/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,22 +117,59 @@ export interface NativeClient {
register: (options: RegisterOptions) => Promise<string>;
}

/**
* 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 };
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/types/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
44 changes: 44 additions & 0 deletions tests/create-client-mode-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
150 changes: 150 additions & 0 deletions tests/native-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,156 @@ 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("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 () => {
const mod = await loadNativeClientWithBinding(() => ({
connect: vi.fn(async () => {
Expand Down
Loading
Loading