Skip to content

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
CopilotKit:mainfrom
kevin9327:fix/mcp-dry-run-refusal-row
Open

Record what a rule in dry-run would refuse a Bot's tools, not only its browser#301
kevin9327 wants to merge 1 commit into
CopilotKit:mainfrom
kevin9327:fix/mcp-dry-run-refusal-row

Conversation

@kevin9327

Copy link
Copy Markdown
Contributor

What this changes

dry-run exists so a boundary can be measured against live traffic before it starts refusing
anybody. evaluateActionPolicy returns allowed: false with forward: true
(server/src/computer/policy.ts:264-277, :292-301), so the policy refuses and the mode lets the
action 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.

  • Browser / file / shell — server/src/computer/gateway.ts:1074-1078 keys the event type on the
    decision: entry.decision.allowed ? "computer.action_allowed" : "computer.action_refused".
    A matched action is recorded as refused and then carried out. Correct.
  • Connectors — server/src/plugins/store.ts:2883 keyed its row on verdict.forward, so in
    dry-run it wrote nothing before the call, and then wrote mcp.call_succeeded after it. The same
    decision, on the sibling surface, recorded as its opposite. Nothing in server/src/plugins/
    branches on verdict.allowed at all — it only fills decided.decision.allowed.

So: an administrator adds deny: mcp.effect == "write" — a rule the Boundaries page itself
advertises (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, Blocked on the audit page is empty and every
eventType=mcp.call_rejected query answers zero. /policy-dry-run cannot compensate:
REPLAYABLE_EVENT_TYPES (policy-dry-run.ts:24-28) is browser-only, so its report says the rule
would 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.carriedOut is what tells the two rows apart: false
is a call this deployment stopped, true is one dry-run recorded and let past.

Two lines of behaviour change, and no change at all for an enforcing deployment:

mode policy before after
enforce deny mcp.call_rejected, throw identical
enforce allow outcome row only identical
dry-run deny outcome row only mcp.call_rejected (carriedOut: true) and the outcome row
dry-run allow outcome row only identical

Where it runs

  • New state that outlives a request? None. One extra recordAuditEvent on an existing
    store, on a branch that is only taken when the policy said no.
  • What happens on the second replica? The same. The decision is computed per call from
    options.policy(), which every replica reads from the same Postgres row through the existing
    LISTEN/NOTIFY policy store. Nothing is held between requests.
  • Anything serialised? Nothing new. audit_events is append-only and this is one more
    insert into it.
  • Anything fanned out to a browser? No.
  • New listener, port, or schedule? None.

Boundary and audit

  • Every acting call still goes through the gateway: resolve, decide, audit, then act. This
    restores the "audit" step for the one mode and surface where it was skipped.
  • New refusals and new failures each write a row. The refusal row is the point of the change;
    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.
  • Nothing new is trusted from the client.

Changelog

  • CHANGELOG.md, under Unreleased.

Proof

Added to server/tests/plugin-store.integration.test.ts, in the the policy is asked as well as the grant block: a granted tool, mode: "dry-run" with a deny rule naming it, the call let through, and
the assertion that mcp.call_rejected exists carrying allowed: false, mode: "dry-run",
carriedOut: true. On main that filter finds nothing, because no row is written.

Run locally against origin/main (fb0c797):

bun run format:check   -> Checked 487 files. No fixes applied.
bun run lint           -> Checked 490 files. No fixes applied.
bun run typecheck      -> app / server / worker all exit 0
bun test server/tests/computer-policy.test.ts server/tests/plugin-routes.test.ts \
         server/tests/plugin-selection.test.ts server/tests/plugin-catalogue.test.ts \
         server/tests/computer-gateway.test.ts
                       -> 181 pass, 0 fail (490 expect() calls)

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.

…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>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant