Skip to content

Commit 93ff1b1

Browse files
authored
Count a Bot repeating itself, and hand the count to the boundary (#17)
* Count a Bot repeating itself, and hand the count to the boundary A model that cannot get something to work retries. It clicks the same button, reloads the same page, writes the same file, and nothing counted it. Every one of those attempts is a real action on somebody's live website and a real charge against somebody's model credit, and the audit trail recorded them one row at a time with no way to see that they were the same row thirty times over. The gateway is the one place this can be counted, because it is the one place every governed action already goes through and already writes a row for. `computer/repeat.ts` keys a call on the tool plus the argument that says which thing it acted on, over a sliding three-minute window, and the gateway asks it before it asks the policy. The count lands in `PolicyContext` as `repeat.count`, so a deployment can write `repeat.count >= 10` in its deny list and stop a Bot going in circles with the boundary it already has, on the page it already uses. The detector does not refuse anything, on purpose. Blocking here would be a second boundary with rules of its own, invisible on the Boundaries page, unanswerable to dry-run, and impossible to relax for the one Bot whose job really is to poll something. It observes; the policy decides. Crossing 3, 10, or 25 writes one `computer.action_repeated` row each, carrying the tool, a readable fingerprint and the count. It is deliberately not filed as a refusal: nothing was forbidden and nothing was stopped, and a trail that files an observation as a refusal teaches a reader to skim past the refusals that are real. The audit page gives it its own filter and its own words for the same reason. The window costs something and the preset says so. It is time-based, so a Bot slow enough to spread its attempts wider than the window never trips it, and one that varies a single argument each time round is ten different calls. COMPUTER_REPEAT_WINDOW_MS widens it for a deployment whose provider is slow enough to need that, and refuses to start rather than falling back, because a rule about repetition that never fires looks exactly like a Bot behaving itself. * Bound what the repeat detector remembers, and stop it dropping the key it is about to need Three things the detector claimed and did not do. The outer map was described as bounded by how many Bots a deployment has. It is not: the id it counts against is the `:botId` in the request path, checked against a session and against nothing else, because no acting route resolves it to a row in `bots` first. A signed-in caller looping over invented ids bought a map, an inner map and an occurrence record each time round, before the policy was consulted and whether or not any such Bot existed, and none of it was ever given back. Both levels are capped now and neither drops anything that is still inside the window, so what is held is the recent past and nothing else. Eviction was least recently seen, described as the key furthest from tripping anything. For the one behaviour this feature exists to catch it is the nearest: a Bot going round a loop of more distinct calls than the cap holds lost each key exactly one step before it came back, so twenty rounds of a sixty-five step circle reported every call as a first attempt and no rule about repetition could fire. Nothing live is evicted at all now. A place is freed when a call ages out of the window, and while every place is held by something still inside it, a call the Bot has not made before is not counted rather than something live being thrown out for it. The cost is a Bot whose first sixty-four distinct calls are honest work and which only then gets stuck: its loop is invisible until one of those falls out of the window, at most one window away. A blind spot that clears itself is worth more than an eviction rule that can be wrong for as long as the loop lasts, and it buys the other half: a call already being counted can no longer be pushed out by a Bot doing other things in between. The observational audit row blocked. `recordAuditEvent` rethrows, and that row is written ahead of the policy, so a moment's trouble at the audit store refused every third, tenth and twenty-fifth identical call, on an action nothing objected to, from the one part of this that is not allowed to refuse anything. It is swallowed and logged now. The invariant is untouched: the decision row goes to the same store a few lines below, and a store that is really down stops the action there. Also honest about the costs where an operator sees them. The Boundaries preset named only the two ways the rule under-fires and never the way it over-fires, which is the failure that costs somebody their Bot: a Bot typing ten different searches into one box is ten repeats, and the rule refuses the tenth. That admission, the fact that the count lives in one process and so splits across replicas, and the fact that MCP tool calls always report one, now reach the preset, the `PolicyContext` docblock, `.env.example` and the architecture note. The threshold docblock said a count that falls out of the window and climbs again reports again; it is the window emptying completely that ends a run, which is what the code has always done and what the type now says.
1 parent f1670fd commit 93ff1b1

15 files changed

Lines changed: 1287 additions & 2 deletions

File tree

.env.example

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,17 @@ AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true
110110
# which Bot took it. A rule can still restrict a single Bot with `bot.id`.
111111
#
112112
# Attributes: tool.name, bot.id, actor.id, page.url, page.host, element.ref/role/name/type,
113-
# key, file.path, file.name, file.extension.
113+
# key, file.path, file.name, file.extension, repeat.count.
114+
#
115+
# repeat.count is how many times this Bot has just made this exact call, counting the one being
116+
# decided. A stuck model retries, and each retry is a real action on somebody's live website that is
117+
# perfectly reasonable on its own terms; only the count tells the thirtieth click apart from the
118+
# first. `repeat.count >= 10` in `deny` stops a Bot going in circles. Two calls are the same call
119+
# when the thing acted on is the same, whatever was typed into it, so ten searches typed into one box
120+
# are ten repeats and a rule about repetition refuses the tenth: try one in `dry-run` first. The
121+
# count is held in memory by the process that served the call, so a deployment running two API
122+
# replicas splits every count and a rule about ten attempts fires at twenty or never, and calls to
123+
# another server's tools over MCP are not counted at all.
114124
#
115125
# Name every route to the same effect. A form submits from a keypress in any of its fields, so a rule
116126
# that only blocks a Submit button does not block Enter from another field. The example below refuses
@@ -121,6 +131,16 @@ AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true
121131
#
122132
# AGENT_COMPUTER_POLICY={"mode":"enforce","deny":["(intent == \"activate\" && contains(element.name, \"submit\")) || (tool.name == \"computer_key\" && key == \"Enter\")"],"allow":["true"]}
123133

134+
# How long two identical calls count as the same repetition, in ms. Three minutes unset, which
135+
# assumes a retry loop is a model round trip apart: call the tool, read the failure, try again.
136+
# Widen it for a deployment whose provider is slow or heavily queued, where genuine retries arrive
137+
# minutes apart and every attempt would otherwise be counted as the first one. Widen it too far and
138+
# honest work starts to accumulate: a Bot told to watch a dashboard all morning reloads the same page
139+
# and is not stuck. Anything that is not a positive whole number stops the server rather than falling
140+
# back to the default, because a rule about repetition that never fires looks exactly like a Bot
141+
# behaving itself.
142+
# COMPUTER_REPEAT_WINDOW_MS=180000
143+
124144
# How long one action waits for its element, in ms. Read by agent-computer, not the server.
125145
# ACTION_TIMEOUT_MS=10000
126146

app/src/routes/_authed/admin/audit.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ const FILTERS = [
3939
"?eventType=computer.action_refused,mcp.call_rejected,component.refused,component.function_refused",
4040
},
4141
{ label: "Did not happen", search: "?eventType=computer.action_failed" },
42+
{
43+
// Its own filter rather than a place in "Blocked". A Bot repeating itself has not been stopped by
44+
// anything, and putting it beside the refusals would make the refusals look less real.
45+
label: "Going in circles",
46+
search: "?eventType=computer.action_repeated",
47+
},
4248
] as const;
4349

4450
function AuditPage() {
@@ -163,6 +169,10 @@ function Row({
163169
</span>
164170
) : null}
165171
</span>
172+
) : typeof payload.fingerprint === "string" ? (
173+
// A repeat row has no element and no file of its own: what it is about is the call, which
174+
// the fingerprint names in full.
175+
<span className="font-mono text-xs">{payload.fingerprint}</span>
166176
) : typeof payload.file === "string" ? (
167177
<span className="font-mono text-xs">{payload.file}</span>
168178
) : typeof element === "object" && element?.name ? (
@@ -223,6 +233,12 @@ function Row({
223233
<span className="italic">, reported by the Bot itself</span>
224234
</div>
225235
) : null}
236+
{event.eventType === "computer.action_repeated" &&
237+
typeof payload.count === "number" ? (
238+
<div className="mt-0.5 text-xs text-muted-foreground">
239+
{payload.count} times within a few minutes
240+
</div>
241+
) : null}
226242
{failed && typeof payload.failure === "string" ? (
227243
<div className="mt-0.5 text-xs text-muted-foreground">
228244
{payload.failure}
@@ -268,6 +284,8 @@ const DECISIONS: Record<string, string> = {
268284
"computer.secret_supplied": "A person supplied a secret",
269285
"computer.reset": "The computer was reset",
270286
"computer.stopped": "A person pressed stop",
287+
// Not "Blocked". Nothing refused this; the Bot did the same thing again and the trail is saying so.
288+
"computer.action_repeated": "The Bot repeated itself",
271289

272290
"component.granted": "Granted to this Bot",
273291
"component.revoked": "Taken away from this Bot",

app/src/routes/_authed/admin/boundaries.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ const PRESETS: { label: string; rule: string; cost?: string }[] = [
3232
rule: 'intent == "type" && contains(element.name, "password")',
3333
cost: "A password box the page labels something else is not covered, the rule matches the label.",
3434
},
35+
{
36+
label: "Stop a Bot repeating itself",
37+
// The count includes the attempt being decided, so this refuses the tenth, not the eleventh.
38+
rule: "repeat.count >= 10",
39+
cost: "Two calls count as the same call when the thing acted on is the same, whatever was typed into it, so a Bot running ten searches from one box, or reading one file ten times, is refused on the tenth. It misses the other way too: a Bot slow enough to spread its attempts wider than a few minutes is never caught, one that changes a single argument each time is ten different calls, and calls to another server's tools are not counted at all. Worth adding while a match is recorded and allowed, before it starts refusing anybody's work.",
40+
},
3541
{
3642
label: "Stay off social media",
3743
rule: 'intent == "navigate" && (contains(page.host, "facebook.com") || contains(page.host, "x.com"))',

docs/architecture.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ Policy rules can inspect:
5656
- `key`
5757
- `file.path`, `file.name`, `file.extension`
5858
- `mcp.server`, `mcp.tool`, `mcp.effect`
59+
- `repeat.count`
60+
61+
`repeat.count` is how many times that Bot has just made that exact call, counting the one being
62+
decided. The gateway keys it on the tool plus the ref, key, file path, or target URL, over a sliding
63+
window that defaults to three minutes and is set by `COMPUTER_REPEAT_WINDOW_MS`. Crossing 3, 10, or
64+
25 writes one `computer.action_repeated` row each; the detector itself never refuses anything, so
65+
`repeat.count >= 10` in `deny` is what stops a Bot going in circles. The count is held in memory by
66+
the process that served the call, so two API replicas split it, and it covers the browser and the
67+
workspace only: a call to another server's tools over MCP always reports one.
5968

6069
Rules use CEL expressions plus case-insensitive `contains()` and `matches()`.
6170
Deny rules are evaluated before allow rules. The policy engine fails closed: a

server/src/audit.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,22 @@ export const auditEventTypes = [
5050
// Permitted by policy, attempted, and did not succeed. Its own type because "allowed" reads as
5151
// "happened", and a trail that cannot tell those apart misleads exactly when it matters most.
5252
"computer.action_failed",
53+
/**
54+
* The same call, again, and again.
55+
*
56+
* The rows above record actions one at a time, which is the only way to record them and the reason
57+
* a Bot stuck in a retry loop is invisible here: thirty identical rows look like thirty rows. This
58+
* one says the thing the sequence cannot, that these are the same call, and how many times.
59+
*
60+
* It is not a refusal. Nothing was forbidden and nothing was stopped; a Bot did the same thing
61+
* again, which is often merely a retry that is about to work. Filing it as a refusal would teach a
62+
* reader to skim past the refusals that are real, so it is its own type and the audit page gives it
63+
* its own words.
64+
*
65+
* Written when a count crosses a threshold rather than on every repeat, because a row per attempt
66+
* would bury the attempts themselves under the observation that they kept happening.
67+
*/
68+
"computer.action_repeated",
5369
// A person taking the wheel and giving it back. Recorded as a period rather than as keystrokes: the
5470
// useful fact for an investigator is that a human drove this browser between these two times, and
5571
// logging every click a person made would bury it while telling nobody anything.

server/src/computer/gateway.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
type PolicyContext,
2626
type PolicyDecision,
2727
} from "./policy";
28+
import { createRepeatDetector, type RepeatDetector } from "./repeat";
2829
import type {
2930
ClickInput,
3031
KeyInput,
@@ -76,6 +77,15 @@ export type ComputerGatewayOptions = {
7677
auditStore: AuditStore;
7778
/** Absent denies everything. See evaluateActionPolicy. */
7879
policy: () => ActionPolicy | undefined;
80+
/**
81+
* Counts a Bot repeating itself, so that the policy can be told how many times.
82+
*
83+
* Absent, the gateway makes its own, which is what almost every deployment gets. Passed in only to
84+
* widen the window for a slow provider, or to hand a test a clock it can move, because otherwise
85+
* proving that a window expires means a test that waits three minutes, and a test that waits three
86+
* minutes is a test somebody eventually deletes.
87+
*/
88+
repeat?: RepeatDetector;
7989
};
8090

8191
/**
@@ -95,6 +105,7 @@ type CachedSnapshot = {
95105
export function createComputerGateway(options: ComputerGatewayOptions) {
96106
const { client, auditStore, supervisor } = options;
97107
const snapshots = new Map<string, CachedSnapshot>();
108+
const repeat = options.repeat ?? createRepeatDetector();
98109

99110
/**
100111
* The computer, addressed as the Bot that is asking.
@@ -169,11 +180,29 @@ export function createComputerGateway(options: ComputerGatewayOptions) {
169180

170181
const intent = intentOf(toolName, subject.key);
171182

183+
/*
184+
* Counted before the policy is asked, so that a rule written against the count decides the very
185+
* attempt that crossed the line rather than the one after it. Off by one here would mean a
186+
* deployment forbidding a tenth identical click allows the tenth and refuses the eleventh, which
187+
* is the kind of thing nobody notices until they are counting rows in an incident.
188+
*
189+
* Reading a page never reaches this function, so nothing counts a Bot looking at the same screen
190+
* over and over. That is the cheapest thing it does and the one nobody minds.
191+
*/
192+
const repetition = repeat.observe(botId, {
193+
tool: toolName,
194+
ref,
195+
key: subject.key,
196+
filePath,
197+
targetUrl: subject.targetUrl,
198+
});
199+
172200
const context: PolicyContext = {
173201
tool: { name: toolName },
174202
bot: { id: botId },
175203
actor: { id: actor.id },
176204
page: { url: pageUrl, host: hostOf(pageUrl) },
205+
repeat: { count: repetition.count },
177206
...(intent ? { intent } : {}),
178207
...(subject.key ? { key: subject.key } : {}),
179208
...(element
@@ -189,6 +218,44 @@ export function createComputerGateway(options: ComputerGatewayOptions) {
189218
...(filePath ? { file: describeFile(filePath) } : {}),
190219
};
191220

221+
if (repetition.threshold !== null && repetition.fingerprint) {
222+
/*
223+
* Ahead of the decision row, so the trail reads in the order the thing happened: this was the
224+
* tenth identical attempt, and this is what the policy did about it. Filed the other way round
225+
* a reader has to deduce the cause from a row written after its effect.
226+
*
227+
* Its failure is swallowed, which nothing else in this file does. This row is an observation,
228+
* and an observation is not allowed to refuse anything: letting a lost insert throw from here
229+
* would stop every third, tenth and twenty-fifth identical call before the policy had even
230+
* been asked, so a deployment that permits an action would lose it to a moment's trouble at the
231+
* audit store. Nothing is weakened by that. An action that was not recorded still does not
232+
* happen, because the decision row goes to the same store a few lines below, and a store that
233+
* is genuinely down refuses the action there.
234+
*/
235+
try {
236+
await writeRepeat(auditStore, {
237+
toolName,
238+
botId,
239+
actor,
240+
computerId,
241+
pageUrl,
242+
filePath,
243+
fingerprint: repetition.fingerprint,
244+
count: repetition.count,
245+
});
246+
} catch (error) {
247+
console.error(
248+
JSON.stringify({
249+
type: "computer-repeat-row-lost",
250+
bot: botId,
251+
fingerprint: repetition.fingerprint,
252+
count: repetition.count,
253+
error: String(error),
254+
}),
255+
);
256+
}
257+
}
258+
192259
const decision = evaluateActionPolicy(options.policy(), context);
193260
await write(auditStore, {
194261
toolName,
@@ -732,6 +799,49 @@ async function write(
732799
});
733800
}
734801

802+
/**
803+
* One row for a Bot going round in circles.
804+
*
805+
* Separate from `write` because there is no policy decision to record. This row is an observation
806+
* about the call that is about to be decided, not the decision, and giving it a `decision` block
807+
* would mean inventing an answer the policy was never asked for. It is also why it is not a refusal:
808+
* nothing was forbidden here.
809+
*
810+
* The fingerprint goes in as written, which is why `repeat.ts` builds a readable one. A reader
811+
* arriving at "the same call, 25 times" needs to be told which call in the row itself.
812+
*/
813+
async function writeRepeat(
814+
auditStore: AuditStore,
815+
entry: {
816+
toolName: string;
817+
botId: string;
818+
actor: ActionActor;
819+
computerId: string;
820+
pageUrl: string;
821+
filePath: string | undefined;
822+
fingerprint: string;
823+
count: number;
824+
},
825+
) {
826+
await recordAuditEvent(auditStore, {
827+
eventType: "computer.action_repeated",
828+
targetType: "computer",
829+
targetId: entry.computerId,
830+
...(entry.actor.userId ? { actorUserId: entry.actor.userId } : {}),
831+
payload: {
832+
action: entry.toolName,
833+
bot: entry.botId,
834+
actor: entry.actor.id,
835+
// The page, for a browser action only. A file call has nothing to do with whatever the browser
836+
// happens to be showing, and naming a host on that row sends a reader somewhere irrelevant, the
837+
// same trap `describeRefusal` avoids.
838+
...(entry.filePath ? {} : { page: entry.pageUrl }),
839+
fingerprint: entry.fingerprint,
840+
count: entry.count,
841+
},
842+
});
843+
}
844+
735845
/**
736846
* The host a rule can match on, or empty.
737847
*

server/src/computer/policy.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,34 @@ export type PolicyContext = {
4646
bot: { id: string };
4747
page: { url: string; host: string };
4848
actor: { id: string };
49+
/**
50+
* How many times this Bot has just made this exact call, counting the one being decided.
51+
*
52+
* A stuck model retries, and every retry is a real action on somebody's live website. Each one is
53+
* permitted on its own terms, because each one is: the rule that would refuse the thirtieth click
54+
* on a button would refuse the first, and refusing the first is refusing the product. Only the
55+
* count separates them, so the count is here, and a deployment that wants to stop a Bot going in
56+
* circles writes `repeat.count >= 10` and nothing else changes.
57+
*
58+
* Always present, at one on a call the Bot has not made before, so that a rule mentioning it is
59+
* evaluable on every action. An absent field would throw inside CEL, and a deny rule that throws
60+
* denies, so an optional `repeat` would turn one rule about repetition into a deployment that
61+
* refuses everything.
62+
*
63+
* It is wrong in both directions, and a rule written against it has to be worth both. Under, three
64+
* ways: the window is time-based, so a Bot slow enough to spread its attempts wider than the window
65+
* never trips this, and one that varies a single argument each time round is thirty calls; the
66+
* count is held by the process that served the call, so a deployment behind two API replicas
67+
* splits every count and a rule about ten attempts fires at twenty or never; and a call to another
68+
* server's tools over MCP is not counted at all, because only the computer gateway counts.
69+
*
70+
* Over, once, and that one costs somebody their Bot rather than their evidence. Two calls are the
71+
* same call when the thing acted on is the same, whatever was typed into it, so ten searches typed
72+
* into one box and one file read ten times while a Bot works through it are both ten repeats, and
73+
* `repeat.count >= 10` refuses the tenth. It is a backstop against the loop that actually happens,
74+
* not a guarantee, which is the argument for trying a rule about it in `dry-run` first.
75+
*/
76+
repeat: { count: number };
4977
element?: {
5078
ref: string;
5179
role: string;

0 commit comments

Comments
 (0)