Skip to content
Closed
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
13 changes: 12 additions & 1 deletion server/src/computer/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
type ActionPolicy,
evaluateActionPolicy,
type PolicyContext,
type PolicyDecider,
type PolicyDecision,
policyInitiator,
} from "./policy";
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions server/src/computer/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PolicyDecision>;

/**
* String helpers, registered as CEL globals.
*
Expand Down
168 changes: 168 additions & 0 deletions server/tests/computer-policy-decider.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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,
});
});
});