Record what a rule in dry-run would refuse a Bot's tools, not only its browser - #301
Open
kevin9327 wants to merge 1 commit into
Open
Record what a rule in dry-run would refuse a Bot's tools, not only its browser#301kevin9327 wants to merge 1 commit into
kevin9327 wants to merge 1 commit into
Conversation
…s browser `dry-run` is how a boundary is measured against live traffic before it starts refusing anybody: `evaluateActionPolicy` returns `allowed: false` with `forward: true`, so the policy refuses and the mode lets the action through anyway. The value of the mode is entirely in the row it leaves behind. The browser gateway writes that row. It keys its event type on `decision.allowed`, so a matched action is recorded as `computer.action_refused` and then carried out. The connector path keyed its row on `verdict.forward` instead, so in dry-run it wrote nothing before the call and then wrote `mcp.call_succeeded` — the same decision, on the sibling surface, recorded as its opposite. So an administrator who wrote `deny: mcp.effect == "write"`, switched the mode to "Record it and allow it" exactly as the Boundaries page describes, and came back a week later found `Blocked` empty and every `eventType=mcp.call_rejected` query answering zero. The dry-run report could not compensate: its replay reads browser rows only. The rule looked inert, and enforcing it started refusing Bots with nothing in the trail that could have warned anybody. That is the outcome the mode exists to prevent. The refusal is now written on the policy's answer rather than on what the mode did with it, which is what the gateway has always done. `decision.carriedOut` tells the two rows apart: false is a call this deployment stopped, true is one dry-run recorded and let past. The outcome row is untouched, so a forwarded call still says separately whether the vendor answered, and an enforcing deployment writes exactly the rows it wrote before. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
kevin9327
requested review from
MikeRyanDev,
davidmckayv,
guidovizoso and
tylerslaton
as code owners
August 31, 2026 11:17
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
dry-runexists so a boundary can be measured against live traffic before it starts refusinganybody.
evaluateActionPolicyreturnsallowed: falsewithforward: true(
server/src/computer/policy.ts:264-277,:292-301), so the policy refuses and the mode lets theaction through — and the whole value of the mode is the row it leaves behind.
The two acting surfaces disagreed about which field that row keys on.
server/src/computer/gateway.ts:1074-1078keys the event type on thedecision:
entry.decision.allowed ? "computer.action_allowed" : "computer.action_refused".A matched action is recorded as refused and then carried out. Correct.
server/src/plugins/store.ts:2883keyed its row onverdict.forward, so indry-run it wrote nothing before the call, and then wrote
mcp.call_succeededafter it. The samedecision, on the sibling surface, recorded as its opposite. Nothing in
server/src/plugins/branches on
verdict.allowedat all — it only fillsdecided.decision.allowed.So: an administrator adds
deny: mcp.effect == "write"— a rule the Boundaries page itselfadvertises (
app/src/routes/_authed/admin/boundaries.tsx:179-180) — and sets the mode to"Record it and allow it", which the same page describes as "how a rule is tried on real traffic
before it starts refusing anybody". A week later,
Blockedon the audit page is empty and everyeventType=mcp.call_rejectedquery answers zero./policy-dry-runcannot compensate:REPLAYABLE_EVENT_TYPES(policy-dry-run.ts:24-28) is browser-only, so its report says the rulewould have refused none of them about traffic that was never in the sample. The rule looks inert,
enforce is switched on, and every matching tool call starts being refused with nothing in the trail
that could have warned anybody. That is exactly the outcome the mode exists to prevent.
The refusal is now written on the policy's answer rather than on what the mode did with it — which
is what the gateway has always done.
decision.carriedOutis what tells the two rows apart:falseis a call this deployment stopped,
trueis one dry-run recorded and let past.Two lines of behaviour change, and no change at all for an enforcing deployment:
mcp.call_rejected, throwmcp.call_rejected(carriedOut: true) and the outcome rowWhere it runs
recordAuditEventon an existingstore, on a branch that is only taken when the policy said no.
options.policy(), which every replica reads from the same Postgres row through the existingLISTEN/NOTIFYpolicy store. Nothing is held between requests.audit_eventsis append-only and this is one moreinsert into it.
Boundary and audit
restores the "audit" step for the one mode and surface where it was skipped.
the failure/outcome row is deliberately untouched, so the fix that moved it after the vendor
call — so a call that died at the vendor never claims
call_succeeded— is preserved intact.Changelog
CHANGELOG.md, underUnreleased.Proof
Added to
server/tests/plugin-store.integration.test.ts, in thethe policy is asked as well as the grantblock: a granted tool,mode: "dry-run"with a deny rule naming it, the call let through, andthe assertion that
mcp.call_rejectedexists carryingallowed: false,mode: "dry-run",carriedOut: true. Onmainthat filter finds nothing, because no row is written.Run locally against
origin/main(fb0c797):The new assertion is in an integration file, so it is CI that runs it — I have no PostgreSQL on this
machine and did not want to report a pass I had not seen. Happy to adjust it if the shape does not
match how you would rather this were pinned.