Keep a rule about one action surface from refusing another - #115
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
cel-js throws on an unbound identifier, and a thrown rule fails closed, so a policy expression that names a field the current action does not carry does not evaluate to false — it refuses the action. Each acting surface already builds its context with the other surfaces' fields at a neutral, matches-nothing value for exactly this reason: the MCP context carries an empty `element`, `key` and `file` so a browser rule does not catch a tool call, and the browser context carries them so a tool rule does not catch a click. Two fields were missed, one on each side: - the browser context (gateway) had no `mcp`, so `deny: mcp.effect == "write"` threw on every click, keypress, navigation and file action and refused all of them; - the MCP context had no `command`, so `deny: contains(command, "rm -rf")` threw on every MCP call and refused all of them. Either way an operator forbidding one surface silently disabled another — the one thing this policy exists to let a deployment reason about. Measured before the change: a `computer_click` under `deny: mcp.effect == "write"` is refused, and an MCP read under `deny: contains(command, "rm -rf")` is refused. Both are permitted after it, while a real MCP write and a real shell command are still refused. Fill the two neutral fields in, matching the ones already there. `mcp.effect` gains `""` as its neutral so that neither `== "read"` nor `== "write"` matches a non-MCP action, the same way the empty strings match no browser rule. The engine still fails closed on a genuinely unbound field, the deliberate floor the unit tests carry; this only completes what each surface hands it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2b0e892 to
eedab3f
Compare
davidmckayv
left a comment
There was a problem hiding this comment.
Verified the bug and the fix on a running deployment.
The bug is real and I saw it decide. Added mcp.effect == "write" on /admin/boundaries — a rule about somebody else's tools — then asked a Bot to open a page. On main that refuses every browser action, because cel-js throws on the unbound identifier and an unevaluable deny counts as a match. With this, the audit row reads:
computer.action_allowed computer_navigate allowed=true rule=true page=https://example.com
Allowed, carried out, no failure. An operator drawing a boundary around one surface no longer silently disables the other.
Not redundant with #183. #183 refuses an unresolvable ref inside the attempt, after the decision; this fixes context construction before it. #183's own comment leans on the neutral-value design this completes. Both are on the stacked branch together and neither disturbs the other.
The fail-closed floor is untouched, which is the thing I checked hardest: a genuinely unbound field still refuses. What changes is that a field belonging to the other surface is now neutrally present rather than absent, which is the distinction main's gateway comment already drew for command, key, file and element and simply missed for these two.
Checks: stacked with ten other candidates on current main. Typecheck, lint, format clean; 1371 tests pass, 0 fail.
What this changes
A policy expression that names a field the current action does not carry throws in cel-js, and a
thrown rule fails closed, so instead of evaluating to false it refuses the action. Each acting
surface already builds its context with the other surfaces' fields at a neutral, matches-nothing
value for exactly this reason — but two were missed, one on each side:
computer/gateway.ts) had nomcp, sodeny: mcp.effect == "write"threw on every click, keypress, navigation and file action and refused all of them;
plugins/store.ts) had nocommand, sodeny: contains(command, "rm -rf")threw on every MCP call and refused all of them.
So an operator forbidding one surface silently disabled another — the one thing this policy exists to
let a deployment reason about. This fills the two neutral fields in, matching the ones already there.
mcp.effectgains""as its neutral value so that neither== "read"nor== "write"matches anon-MCP action, the mirror of the empty strings that match no browser rule.
The engine itself still fails closed on a genuinely unbound field — the deliberate floor the policy
unit tests carry (
a rule about an element still decides when the element is unknown,unguarded, it refuses a navigation that has no key at all). This change does not touch that; itonly completes the context each surface hands the engine.
Where it runs
function of the action and the current policy on whichever replica evaluates it.
Boundary and audit
context handed to
evaluateActionPolicychanged, by gaining the neutral field it was missing.rule (and vice versa); a real MCP write and a real shell command are still refused and recorded.
Proof
Behaviour measured with
evaluateActionPolicy, before and after:computer_clickdeny: mcp.effect == "write"deny: contains(command, "rm -rf")deny: mcp.effect == "write"run_command(rm -rf /)deny: contains(command, "rm -rf")bun run typecheck(app/server/worker) — clean.bun test server/tests/computer-policy.test.ts server/tests/computer-gateway.test.ts— 80 pass.New: a
describe("a rule about one surface does not refuse another")covering both directions andproving real rules still fire, and a gateway test proving an MCP rule does not refuse a click at the
real call site.
Two neutral fields, one on each surface, matching the ones already there; no engine behaviour
changed.