Skip to content

Commit 7e6397f

Browse files
kevin9327claudedavidmckayv
authored
Score each action once in a policy dry-run, not once per audit row it wrote (#294)
Testing a candidate boundary against recent history replays the audit trail's computer-action rows. It replayed three event types, and one is a duplicate: a permitted action that fails is recorded twice — the decision row written before it was attempted (action_allowed / action_refused) and a failure row written when it did not succeed (action_failed) — and both carry the same action and are returned by the query. So every failed action was scanned and scored twice. And because a dry-run policy carries a refused action out, a refused action can fail too, leaving two rows that disagree on the baseline: the decision row records "refused", while the failure row has no decision of its own and fell to the "allowed" branch. A candidate policy that refused the same action identically was then reported as a new refusal it never introduced. The failure row is an outcome, not a decision. The replay now skips it and scores each action once from its decision row, and the query no longer fetches it — so it also stops spending the scan budget on rows that would be dropped. The baseline for a permitted-but-failed action is still "allowed", because that is what its decision row says. Co-authored-by: kevin9327 <kevin9327@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
1 parent c936c2b commit 7e6397f

3 files changed

Lines changed: 68 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
88

99
## Unreleased
1010

11+
### A policy dry-run no longer counts a failed action twice, or invents a change it did not make
12+
13+
Testing a boundary against recent history replayed three kinds of audit row, and one of them is a
14+
duplicate: a permitted action that fails is recorded both as the decision that allowed it and as a
15+
separate failure row, so every failed action was scanned and scored twice. Worse, a dry-run policy
16+
carries a refused action out, so a refused action can fail too — and its two rows disagree, the
17+
decision row saying "refused" and the failure row reading as "allowed", so a candidate policy that
18+
refused it identically was reported as a new refusal it never introduced. The replay now scores each
19+
action once, from the row that recorded its decision.
1120
### A message no longer routes to a specialist because a longer word contained a connector's name
1221

1322
When the intent router falls back — it is unreachable, or it declines — and exactly one coworker can

server/src/computer/policy-dry-run.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,17 @@ import {
2020
type PolicyContext,
2121
} from "./policy";
2222

23-
/** The event types the gateway writes for a judged computer action. In one place, for the query. */
23+
/**
24+
* The event types that record a policy DECISION about a computer action. In one place, for the query.
25+
*
26+
* Not `computer.action_failed`: the gateway writes that beside the decision row when a permitted
27+
* action is attempted and does not succeed, so it is a second row for an action already recorded
28+
* here, not a decision of its own. Replaying it too would score the action twice — see
29+
* {@link dryRunAgainstHistory}.
30+
*/
2431
export const REPLAYABLE_EVENT_TYPES = [
2532
"computer.action_allowed",
2633
"computer.action_refused",
27-
"computer.action_failed",
2834
] as const;
2935

3036
/** One action the candidate policy would have decided differently. */
@@ -38,7 +44,7 @@ export type DryRunChange = {
3844
element: { role: string; name: string } | null;
3945
command: string | null;
4046
file: string | null;
41-
/** What actually happened, from the trail. A failed action was permitted first, so it was allowed. */
47+
/** What the policy in force decided, from the action's decision row. */
4248
was: "allowed" | "refused";
4349
would: "allowed" | "refused";
4450
/** The candidate rule that decided it, or null for the default refusal. */
@@ -120,6 +126,14 @@ export function contextFromAuditPayload(
120126
* a dry-run policy's refusals, which were recorded and then carried out. That is the honest
121127
* baseline: the question this answers is "what would decide differently than was decided", not
122128
* "what would run differently than ran".
129+
*
130+
* A `computer.action_failed` row is skipped rather than scored. It is the outcome of an action whose
131+
* decision row is already in this history, so counting it would score that action twice — and worse,
132+
* a dry-run policy carries a refused action out, so a refused-then-failed action would arrive as a
133+
* decision row that says "refused" and a failure row that reads as "allowed", inventing a change no
134+
* policy made. The decision is on the decision row; the failure row only says it did not finish.
135+
* Callers should exclude it from the query too ({@link REPLAYABLE_EVENT_TYPES}); this guards the
136+
* function against being handed one regardless.
123137
*/
124138
export function dryRunAgainstHistory(
125139
policy: ActionPolicy,
@@ -134,6 +148,7 @@ export function dryRunAgainstHistory(
134148
};
135149

136150
for (const event of events) {
151+
if (event.eventType === "computer.action_failed") continue;
137152
const context = contextFromAuditPayload(event.payload);
138153
if (!context) continue;
139154
report.scanned += 1;

server/tests/policy-dry-run.test.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,23 +122,62 @@ describe("dryRunAgainstHistory", () => {
122122
expect(report.changes[0]?.would).toBe("allowed");
123123
});
124124

125-
test("a failed action was permitted first, so it counts as allowed", () => {
125+
test("a permitted action that failed is counted once, from its decision row", () => {
126+
// The gateway records a permitted-but-failed action twice: the decision row written before the
127+
// attempt, and a failure row written when it did not succeed. Both carry the same action, and
128+
// both are returned by the trail query, so scoring the failure row too would count the action a
129+
// second time. Its baseline is still "allowed" — from the decision row that permitted it.
126130
const deny: ActionPolicy = {
127131
mode: "enforce",
128132
deny: ['tool.name == "computer_click"'],
129133
allow: ["true"],
130134
};
131135
const report = dryRunAgainstHistory(deny, [
132136
event({
133-
id: "f",
137+
id: "dec",
138+
eventType: "computer.action_allowed",
139+
payload: CLICK_SUBMIT,
140+
}),
141+
event({
142+
id: "fail",
134143
eventType: "computer.action_failed",
135144
payload: CLICK_SUBMIT,
136145
}),
137146
]);
147+
expect(report.scanned).toBe(1);
138148
expect(report.wouldRefuse).toBe(1);
149+
expect(report.changes).toHaveLength(1);
150+
expect(report.changes[0]?.id).toBe("dec");
139151
expect(report.changes[0]?.was).toBe("allowed");
140152
});
141153

154+
test("a dry-run refusal that was carried out and then failed invents no change", () => {
155+
// In dry-run mode a refused action is still carried out, so a refused action can also fail. The
156+
// decision row says "refused"; the failure row, read on its own, would read as "allowed" and a
157+
// candidate that refuses the same action would then look like a new refusal. Skipping the failure
158+
// row leaves only the honest baseline: it was refused, a policy that refuses it changes nothing.
159+
const denyClicks: ActionPolicy = {
160+
mode: "enforce",
161+
deny: ['tool.name == "computer_click"'],
162+
allow: ["true"],
163+
};
164+
const report = dryRunAgainstHistory(denyClicks, [
165+
event({
166+
id: "dec",
167+
eventType: "computer.action_refused",
168+
payload: CLICK_SUBMIT,
169+
}),
170+
event({
171+
id: "fail",
172+
eventType: "computer.action_failed",
173+
payload: CLICK_SUBMIT,
174+
}),
175+
]);
176+
expect(report.scanned).toBe(1);
177+
expect(report.wouldRefuse).toBe(0);
178+
expect(report.unchanged).toBe(1);
179+
});
180+
142181
test("a rule naming a command does not refuse a click, because absent facts are neutral", () => {
143182
const candidate: ActionPolicy = {
144183
mode: "enforce",

0 commit comments

Comments
 (0)