On a deployment whose policy is in dry-run, the Audit page labels every allowed action "dry-run: recorded, not enforced", and it prints the same line under a tool call that content inspection actually stopped. The line exists to mark a refusal that dry-run let through, and today it cannot tell that case apart from either of the other two.
Three rows, one label
With { mode: "dry-run", deny: [], allow: ["true"] }, rendering the Audit page for these rows gives:
computer.action_refused allowed: false carriedOut: true Blocked dry-run: recorded, not enforced (correct)
computer.action_allowed allowed: true carriedOut: true Allowed dry-run: recorded, not enforced
mcp.call_rejected allowed: true carriedOut: true Blocked dry-run: recorded, not enforced
refusal: sensitive_tool_arguments
Only the first is a refusal that was not enforced. Nothing refused the second, and the third was stopped: the Bot got "The tool call was refused because its arguments contain credential material." and the vendor was never called.
Why it happens
The page draws the line when decision.mode === "dry-run" && decision.carriedOut (audit.tsx:381). carriedOut is the policy's forward, and an allowed action forwards in every mode (policy.ts:286), so every allowed row on a dry-run deployment qualifies. The browser gateway writes the field the same way (gateway.ts:1155).
The tool-call case is a second cause. Content inspection builds its refusal row by spreading the policy step's decided object (store.ts:3037-3039), which already holds carriedOut: verdict.forward (store.ts:2987). So the row for a call inspection refused says it was carried out, in enforce as well as dry-run. The changelog entry that introduced the field says it exists so "a reader can tell a call this deployment stopped from one dry-run recorded and let past", and this row reads the wrong way round.
Reproduction
- On a migrated test database, build a plugin store with the dry-run policy above, grant
google-drive/search_files to a Bot, and call it with { query: "quarterly report" }. The row carries decision: { allowed: true, mode: "dry-run", carriedOut: true }.
- Call it again with
{ query: "quarterly report", nested: { apiKey: "..." } }. The call throws the credential refusal and writes mcp.call_rejected with refusal: "sensitive_tool_arguments" and decision.carriedOut: true.
- Render
/admin/audit with those rows plus a dry-run deny that went ahead and an enforce refusal. The line appears under the first three and not under the enforce refusal.
Why it matters
Dry-run is how a rule is measured before it is switched on, and the Boundaries page promises "Nothing is stopped" in that mode. This line is how an operator reads what a rule would have refused. On every allowed row it stops meaning anything, and on a content refusal it tells them a Bot's call went out when it did not.
Severity
Low to medium. Enforcement is unaffected; only what the trail says is wrong. But the people who read these rows most carefully are the ones who switched dry-run on to read them.
On a deployment whose policy is in dry-run, the Audit page labels every allowed action "dry-run: recorded, not enforced", and it prints the same line under a tool call that content inspection actually stopped. The line exists to mark a refusal that dry-run let through, and today it cannot tell that case apart from either of the other two.
Three rows, one label
With
{ mode: "dry-run", deny: [], allow: ["true"] }, rendering the Audit page for these rows gives:Only the first is a refusal that was not enforced. Nothing refused the second, and the third was stopped: the Bot got "The tool call was refused because its arguments contain credential material." and the vendor was never called.
Why it happens
The page draws the line when
decision.mode === "dry-run" && decision.carriedOut(audit.tsx:381).carriedOutis the policy'sforward, and an allowed action forwards in every mode (policy.ts:286), so every allowed row on a dry-run deployment qualifies. The browser gateway writes the field the same way (gateway.ts:1155).The tool-call case is a second cause. Content inspection builds its refusal row by spreading the policy step's
decidedobject (store.ts:3037-3039), which already holdscarriedOut: verdict.forward(store.ts:2987). So the row for a call inspection refused says it was carried out, in enforce as well as dry-run. The changelog entry that introduced the field says it exists so "a reader can tell a call this deployment stopped from one dry-run recorded and let past", and this row reads the wrong way round.Reproduction
google-drive/search_filesto a Bot, and call it with{ query: "quarterly report" }. The row carriesdecision: { allowed: true, mode: "dry-run", carriedOut: true }.{ query: "quarterly report", nested: { apiKey: "..." } }. The call throws the credential refusal and writesmcp.call_rejectedwithrefusal: "sensitive_tool_arguments"anddecision.carriedOut: true./admin/auditwith those rows plus a dry-run deny that went ahead and an enforce refusal. The line appears under the first three and not under the enforce refusal.Why it matters
Dry-run is how a rule is measured before it is switched on, and the Boundaries page promises "Nothing is stopped" in that mode. This line is how an operator reads what a rule would have refused. On every allowed row it stops meaning anything, and on a content refusal it tells them a Bot's call went out when it did not.
Severity
Low to medium. Enforcement is unaffected; only what the trail says is wrong. But the people who read these rows most carefully are the ones who switched dry-run on to read them.