diff --git a/server/src/computer/gateway.ts b/server/src/computer/gateway.ts index b33d99205..2d67f0bbe 100644 --- a/server/src/computer/gateway.ts +++ b/server/src/computer/gateway.ts @@ -50,6 +50,7 @@ import { type ActionPolicy, evaluateActionPolicy, type PolicyContext, + type PolicyDecider, type PolicyDecision, policyInitiator, } from "./policy"; @@ -115,6 +116,13 @@ export type ComputerGatewayOptions = { auditStore: AuditStore; /** Absent denies everything. See evaluateActionPolicy. */ policy: () => ActionPolicy | undefined; + /** + * Replace the built-in policy evaluator. + * + * When absent, `policy()` is evaluated by `evaluateActionPolicy` exactly as before. A decider that + * throws or rejects propagates that error; the action is not carried out. + */ + decide?: PolicyDecider; /** True on a laptop, where browsing private network addresses is required. */ allowPrivateHosts?: boolean; /** The secret that agent-computer requires on each request. */ @@ -260,6 +268,9 @@ export function createComputerGateway( options: ComputerGatewayOptions, ): ComputerGateway { const { provider, auditStore } = options; + const decide: PolicyDecider = + options.decide ?? + ((policyContext) => evaluateActionPolicy(options.policy(), policyContext)); const transport = createComputerTransport({ ...(options.token ? { token: options.token } : {}), ...(options.allowPrivateHosts !== undefined @@ -584,7 +595,7 @@ export function createComputerGateway( mcp: { server: "", tool: "", effect: "" }, }; - const decision = evaluateActionPolicy(options.policy(), context); + const decision = await decide(context); await write(auditStore, { toolName, botId, diff --git a/server/src/computer/policy.ts b/server/src/computer/policy.ts index 570b3f56f..5d44dc820 100644 --- a/server/src/computer/policy.ts +++ b/server/src/computer/policy.ts @@ -205,6 +205,17 @@ export type PolicyDecision = { reason: string; }; +/** + * The decision point the gateway asks before an action runs. + * + * The built-in evaluator is the default. A deployment may replace it when the answer depends on a + * policy engine or state the gateway does not hold. Errors are not converted into an allow: they + * propagate to the caller and the action is not carried out. + */ +export type PolicyDecider = ( + context: PolicyContext, +) => PolicyDecision | Promise; + /** * String helpers, registered as CEL globals. * diff --git a/server/tests/computer-policy-decider.test.ts b/server/tests/computer-policy-decider.test.ts new file mode 100644 index 000000000..516f7dceb --- /dev/null +++ b/server/tests/computer-policy-decider.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, test } from "bun:test"; +import type { AuditEventInput, AuditStore } from "../src/audit"; +import { + ActionRefusedError, + createComputerGateway, +} from "../src/computer/gateway"; +import type { + ActionPolicy, + PolicyContext, + PolicyDecider, + PolicyDecision, +} from "../src/computer/policy"; +import type { ComputerProvider } from "../src/computer/provider"; + +const ACTOR = { id: "dev-local-user" }; +const PERMISSIVE: ActionPolicy = { + mode: "enforce", + deny: [], + allow: ["true"], +}; + +function decision(overrides: Partial = {}): PolicyDecision { + return { + allowed: true, + mode: "enforce", + matched: "custom", + source: "allow", + forward: true, + reason: "Permitted by the custom decider.", + ...overrides, + }; +} + +function harness(options: { + policy?: () => ActionPolicy | undefined; + decide?: PolicyDecider; +}) { + const calls: string[] = []; + const rows: AuditEventInput[] = []; + const provider: ComputerProvider = { + name: "test", + isolation: "per-bot", + locate: async () => "http://agent-computer:4100", + status: async (botId) => ({ botId, state: "ready" }), + stop: async () => ({ wasRunning: true }), + reset: async () => ({ cleared: true }), + list: async () => [], + }; + const auditStore: AuditStore = { + insert: async (event) => void rows.push(event), + }; + const fetchImpl = (async (input: RequestInfo | URL) => { + const path = new URL(String(input)).pathname; + if (path !== "/navigate") { + throw new Error(`unexpected computer request: ${path}`); + } + calls.push("navigate"); + return Response.json({ + url: "https://example.com/", + title: "Example", + elapsedMs: 1, + }); + }) as typeof fetch; + + const gateway = createComputerGateway({ + provider, + fetchImpl, + auditStore, + policy: options.policy ?? (() => PERMISSIVE), + ...(options.decide ? { decide: options.decide } : {}), + }); + + return { gateway, calls, rows }; +} + +describe("the computer policy decision point", () => { + test("uses the built-in evaluator when no custom decider is injected", async () => { + const { gateway, calls, rows } = harness({}); + + await gateway.navigate("bot-1", ACTOR, "https://example.com/order"); + + expect(calls).toEqual(["navigate"]); + expect(rows[0]?.eventType).toBe("computer.action_allowed"); + expect(rows[0]?.payload.decision).toMatchObject({ + allowed: true, + source: "allow", + rule: "true", + }); + }); + + test("awaits an injected decider and gives it the resolved policy context", async () => { + let seen: PolicyContext | undefined; + const { gateway, calls, rows } = harness({ + policy: () => { + throw new Error("the built-in policy must not be read"); + }, + decide: async (context) => { + seen = context; + return decision(); + }, + }); + + await gateway.navigate("bot-1", ACTOR, "https://example.com/order"); + + expect(seen).toMatchObject({ + tool: { name: "computer_navigate" }, + bot: { id: "bot-1" }, + actor: { id: ACTOR.id }, + page: { url: "https://example.com/order", host: "example.com" }, + }); + expect(calls).toEqual(["navigate"]); + expect(rows[0]?.eventType).toBe("computer.action_allowed"); + }); + + test("records a custom refusal and never reaches the computer", async () => { + const { gateway, calls, rows } = harness({ + decide: () => + decision({ + allowed: false, + forward: false, + matched: "custom-deny", + source: "deny", + reason: "Refused by the custom decider.", + }), + }); + + const error = await gateway + .navigate("bot-1", ACTOR, "https://example.com/order") + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ActionRefusedError); + expect((error as ActionRefusedError).rule).toBe("custom-deny"); + expect(calls).toEqual([]); + expect(rows[0]?.eventType).toBe("computer.action_refused"); + }); + + test("propagates a decider error and does not carry out the action", async () => { + const failure = new Error("policy service unavailable"); + const { gateway, calls, rows } = harness({ + decide: () => { + throw failure; + }, + }); + + await expect( + gateway.navigate("bot-1", ACTOR, "https://example.com/order"), + ).rejects.toBe(failure); + expect(calls).toEqual([]); + expect(rows).toEqual([]); + }); + + test("the default evaluator still fails closed when no rules permit the action", async () => { + const { gateway, calls, rows } = harness({ policy: () => undefined }); + + await expect( + gateway.navigate("bot-1", ACTOR, "https://example.com/order"), + ).rejects.toThrow(ActionRefusedError); + + expect(calls).toEqual([]); + expect(rows[0]?.eventType).toBe("computer.action_refused"); + expect(rows[0]?.payload.decision).toMatchObject({ + allowed: false, + carriedOut: false, + source: "default", + rule: null, + }); + }); +});