@agentguard-run/burn 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -2
- package/LICENSE +9 -10
- package/README.md +34 -8
- package/dist/src/conformance.js +3 -1
- package/dist/src/defaults.d.ts +7 -8
- package/dist/src/defaults.js +7 -8
- package/dist/src/detectors/evaluate.d.ts +5 -2
- package/dist/src/detectors/evaluate.js +26 -2
- package/dist/src/gateway.js +4 -3
- package/dist/src/hook/pre-tool-use.d.ts +2 -0
- package/dist/src/hook/pre-tool-use.js +84 -62
- package/dist/src/replay/render.js +1 -1
- package/dist/src/state/account.d.ts +3 -0
- package/dist/src/state/account.js +39 -0
- package/dist/src/state/reservations.js +5 -1
- package/fixtures/codex-0.151.0-pretooluse.json +11 -11
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
## 0.2.3 (2026-09-15)
|
|
2
|
+
|
|
3
|
+
- Replay evidence refreshed from this machine: 4 STOP, 3 WARN, 431 clean across
|
|
4
|
+
438 sessions and 616 spawns. Hero: 32%, 32.58B observed, 10.27B after STOP,
|
|
5
|
+
$4,639 API-list scenario. Fan-out sessions have 59, 190 and 226 spawns;
|
|
6
|
+
the sustained-burn session has 9.15B tokens and 26 spawns. These are a dated
|
|
7
|
+
snapshot and an upper bound, without a false-positive classification.
|
|
8
|
+
- Account concurrency warnings and optional active-window token WARN/STOP
|
|
9
|
+
budgets read both hook and gateway session files. Shadow never blocks.
|
|
10
|
+
- Claude hook decisions now write the same content-free, chained Ed25519
|
|
11
|
+
receipts as gateway decisions, with host/action on ledger rows. Old rows and
|
|
12
|
+
receipts remain readable. Session refresh and receipt chaining share the lock.
|
|
13
|
+
- Add an opt-in 240-hook-process stress test at cap 40. Staggered lock retries
|
|
14
|
+
reduce contention while preserving the existing fail-closed deadline.
|
|
15
|
+
- Correct the license package/file enumeration, use synthetic Codex fixture
|
|
16
|
+
identifiers, and stop labelling every omitted replay session as clean.
|
|
17
|
+
- Tests: 47 passing by default, one stress test skipped unless AGENTGUARD_STRESS=1.
|
|
18
|
+
|
|
1
19
|
# Changelog
|
|
2
20
|
|
|
3
21
|
## 0.2.2 (2026-09-03)
|
|
@@ -75,7 +93,7 @@ The cross-tool layer. One policy, one lock, one receipt across hosts.
|
|
|
75
93
|
contention). Under 240 concurrent hook processes this admitted 41 to 46
|
|
76
94
|
spawns against a cap of 40. Lock instances now carry a nonce; reclaims
|
|
77
95
|
are verified against the instance judged; writes are fenced on the
|
|
78
|
-
holder's nonce.
|
|
96
|
+
holder's nonce. See 0.2.3 for the reproducible 240-process audit result.
|
|
79
97
|
- A lock owner record could be read half-written and parsed as "held since
|
|
80
98
|
1970". The record is now written atomically and malformed records are
|
|
81
99
|
never trusted.
|
|
@@ -92,7 +110,8 @@ The cross-tool layer. One policy, one lock, one receipt across hosts.
|
|
|
92
110
|
|
|
93
111
|
### Unchanged
|
|
94
112
|
- The Claude Code hook, transcript reader, detectors and thresholds. The
|
|
95
|
-
|
|
113
|
+
original tests remain covered; 0.2.2 has 40 passing tests, and 0.2.3 has
|
|
114
|
+
47 default passing tests plus one opt-in stress test.
|
|
96
115
|
|
|
97
116
|
## 0.1.1 (2026-09-03)
|
|
98
117
|
- The artifact: block-digit hero, per-session sparklines with the STOP marked,
|
package/LICENSE
CHANGED
|
@@ -2,13 +2,12 @@ AgentGuard(R) Burn - Alpha License
|
|
|
2
2
|
Copyright (c) 2026 Dunecrest Ventures Inc.
|
|
3
3
|
|
|
4
4
|
1. SCOPE.
|
|
5
|
-
This software is the npm package @agentguard-run/
|
|
5
|
+
This software is the npm package @agentguard-run/burn, comprising every file
|
|
6
6
|
present in an installed copy of that package. That includes the TypeScript
|
|
7
|
-
|
|
8
|
-
point
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
expressly name is licensed under this Section 1; the enumeration above is
|
|
7
|
+
type definitions and compiled output under dist/src/, the command-line entry
|
|
8
|
+
point dist/src/cli.js, the fixtures under fixtures/, and the root files
|
|
9
|
+
README.md, CHANGELOG.md, LICENSE and package.json. Any file the package ships
|
|
10
|
+
that Section 3 does not expressly name is licensed under this Section 1; the enumeration above is
|
|
12
11
|
illustrative, and adding a file to the package does not place it outside these
|
|
13
12
|
terms.
|
|
14
13
|
|
|
@@ -40,8 +39,8 @@ Licensor. All patent rights are expressly reserved.
|
|
|
40
39
|
The following assets, and ONLY these assets, are released under the BSD
|
|
41
40
|
2-Clause License, reproduced in full below:
|
|
42
41
|
|
|
43
|
-
- The documentation
|
|
44
|
-
- The test
|
|
42
|
+
- The documentation file README.md
|
|
43
|
+
- The test fixtures under fixtures/
|
|
45
44
|
|
|
46
45
|
Copyright (c) 2026 Dunecrest Ventures Inc.
|
|
47
46
|
|
|
@@ -74,8 +73,8 @@ consistent with Section 2 above. The BSD 2-Clause License is used for these
|
|
|
74
73
|
assets precisely because it is silent as to patents.
|
|
75
74
|
|
|
76
75
|
Nothing else the package ships is included in the grant above. The code under
|
|
77
|
-
src/,
|
|
78
|
-
|
|
76
|
+
dist/src/, and the root files CHANGELOG.md, LICENSE and package.json remain
|
|
77
|
+
licensed only under the alpha evaluation
|
|
79
78
|
terms of Section 1. The TypeScript type definitions, policy engine, decision
|
|
80
79
|
log, store implementation, cost table, and wrapper code are in that group.
|
|
81
80
|
|
package/README.md
CHANGED
|
@@ -9,17 +9,22 @@ to do about it. Nothing leaves the machine.
|
|
|
9
9
|
|
|
10
10
|
## Two safety planes
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Originally fitted against 412 real Claude Code sessions, then replayed on
|
|
13
|
+
2026-09-15 against 438 sessions. The history shows two independent
|
|
13
14
|
failure shapes that one detector cannot cover:
|
|
14
15
|
|
|
15
16
|
| Plane | Catches | Rule | Validated on |
|
|
16
17
|
|---|---|---|---|
|
|
17
|
-
| Structural | fan-out storms: many agents re-sending context | WARN 24 spawns, STOP before spawn 41, no agents-spawning-agents past depth 2 |
|
|
18
|
+
| Structural | fan-out storms: many agents re-sending context | WARN 24 spawns, STOP before spawn 41, no agents-spawning-agents past depth 2 | 59-, 190- and 226-spawn sessions; the 190-spawn session has a 4.35B post-STOP tail (92%) |
|
|
18
19
|
| Economic | slow grinds: few agents, enormous total | WARN 3.5B, STOP 5B tokens per session | the 9.15B / 26-spawn session the spawn cap could not see |
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
The 2026-09-15 replay snapshot: **4 STOP, 3 WARN, 431 clean across 438
|
|
22
|
+
sessions and 616 spawns**. Replay hero: **32%** — **10.27B** of **32.58B**
|
|
23
|
+
observed tokens came after a STOP boundary; API-list scenario **$4,639**.
|
|
24
|
+
This is an upper bound assuming no override or restart. Replay does not label
|
|
25
|
+
false positives. Active histories can change the next replay result.
|
|
21
26
|
|
|
22
|
-
Cache-read ratio was 98%
|
|
27
|
+
Cache-read ratio was about 98% in the original calibration, healthy and pathological alike. It
|
|
23
28
|
is shown as an explanation and never used to decide.
|
|
24
29
|
|
|
25
30
|
## Start with replay, not with the hook
|
|
@@ -185,20 +190,41 @@ Ten parallel `Agent` calls launch ten hook processes that all read the same
|
|
|
185
190
|
transcript and all see the same count. A naive cap is cosmetic during exactly
|
|
186
191
|
the burst it exists for. Spawns are admitted through an atomic, cross-process
|
|
187
192
|
reservation under a machine-wide lock; the test suite launches 60 real OS
|
|
188
|
-
processes against a cap of 40 and asserts exactly 40 are admitted,
|
|
189
|
-
|
|
193
|
+
reservation processes against a cap of 40 and asserts exactly 40 are admitted,
|
|
194
|
+
and separately exercises 60 Cursor hook processes. The opt-in stress test runs
|
|
195
|
+
240 actual Claude hook processes against cap 40:
|
|
196
|
+
|
|
197
|
+
```sh
|
|
198
|
+
npm run build
|
|
199
|
+
AGENTGUARD_STRESS=1 node --test dist/tests/stress.test.js
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
It checks exactly 40 admitted and 200 denied, verifies the receipt chain for
|
|
203
|
+
coordinated decisions, and reports lock failures separately. Infrastructure
|
|
204
|
+
failures deny in enforce mode before a receipt can be chained, as in the gateway.
|
|
190
205
|
|
|
191
206
|
0.2.0 fixed the lock itself. Under 240 concurrent hook processes the 0.1
|
|
192
207
|
lock could tear down a live sibling's lock (a waiter judged "owner is dead"
|
|
193
208
|
about an instance that had already been released and replaced) and admit
|
|
194
209
|
41 to 46. Lock instances now carry a nonce; a reclaim only counts if it
|
|
195
210
|
grabbed the instance it judged, and every write is fenced on the holder's
|
|
196
|
-
own nonce still being on the path.
|
|
197
|
-
|
|
211
|
+
own nonce still being on the path. In 0.2.3, bounded, staggered retries prevent
|
|
212
|
+
lock waiters from starving the holder. The Sep 15 audit includes one successful
|
|
213
|
+
240-process run after that fix; it does not establish a 192-run guarantee.
|
|
198
214
|
|
|
199
215
|
Single-machine by design. Two laptops on one account do not share state, and
|
|
200
216
|
that is stated rather than hidden.
|
|
201
217
|
|
|
218
|
+
## Account thresholds
|
|
219
|
+
|
|
220
|
+
`account.warnConcurrentSessions` warns when more than that many sessions have
|
|
221
|
+
been active on this machine in the last 30 minutes, matching `status` liveness.
|
|
222
|
+
Optional `account.warnTokens` and `account.stopTokens` sum the last
|
|
223
|
+
`account.windowActiveMinutes` of token buckets from each active session.
|
|
224
|
+
Both token limits default to `null`. Closed gateway sessions are excluded;
|
|
225
|
+
hook and gateway session files are both read. WARN never blocks. STOP records
|
|
226
|
+
a would-block decision in shadow and blocks new work only in enforce mode.
|
|
227
|
+
|
|
202
228
|
## What it never does
|
|
203
229
|
|
|
204
230
|
No prompts, responses, file contents, or tool inputs are persisted or rendered.
|
package/dist/src/conformance.js
CHANGED
|
@@ -37,7 +37,9 @@ const GRIND_WARN_CALL = Math.floor(defaults_1.DEFAULT_THRESHOLDS.sustained.warnT
|
|
|
37
37
|
const GRIND_STOP_CALL = Math.floor(defaults_1.DEFAULT_THRESHOLDS.sustained.stopTokens / CALL_TOKENS) + 1; // 21
|
|
38
38
|
function freshHome() {
|
|
39
39
|
const home = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'agb-conf-'));
|
|
40
|
-
|
|
40
|
+
// These fixtures share a home to inspect all receipts. Test only the
|
|
41
|
+
// session boundaries here; account concurrency has its own integration tests.
|
|
42
|
+
const policy = { mode: 'enforce', thresholds: { ...defaults_1.DEFAULT_THRESHOLDS, account: { ...defaults_1.DEFAULT_THRESHOLDS.account, warnConcurrentSessions: Number.MAX_SAFE_INTEGER } } };
|
|
41
43
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(home, 'burn-policy.json'), JSON.stringify(policy));
|
|
42
44
|
return home;
|
|
43
45
|
}
|
package/dist/src/defaults.d.ts
CHANGED
|
@@ -2,14 +2,13 @@ import type { Policy, Thresholds } from './types';
|
|
|
2
2
|
/**
|
|
3
3
|
* Shipped thresholds.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* 409 clean sessions. Zero false STOPs.
|
|
5
|
+
* Originally fitted against 412 real Claude Code sessions. Replayed on the
|
|
6
|
+
* author's machine on 2026-09-15: 438 sessions, 616 spawns, 32.58B tokens;
|
|
7
|
+
* 4 STOP, 3 WARN, 431 clean. The observed post-STOP tail is 10.27B (32%).
|
|
8
|
+
* Fan-out 24/40 stops before spawn 41 on the 59-, 190- and 226-spawn
|
|
9
|
+
* sessions; sustained 3.5B/5B catches the 9.15B / 26-spawn slow grind.
|
|
10
|
+
* This is a dated replay snapshot and an upper bound, not labelled evidence
|
|
11
|
+
* of false-positive rates or guaranteed savings.
|
|
13
12
|
*
|
|
14
13
|
* A previous draft shipped a 50M session budget. Routine sessions on the same
|
|
15
14
|
* machine run 1.4B to 3.5B, so that default would have fired within minutes on
|
package/dist/src/defaults.js
CHANGED
|
@@ -4,14 +4,13 @@ exports.SHADOW_MIN_DAYS = exports.SHADOW_MIN_DECISIONS = exports.ACTIVE_GAP_CAP_
|
|
|
4
4
|
/**
|
|
5
5
|
* Shipped thresholds.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* 409 clean sessions. Zero false STOPs.
|
|
7
|
+
* Originally fitted against 412 real Claude Code sessions. Replayed on the
|
|
8
|
+
* author's machine on 2026-09-15: 438 sessions, 616 spawns, 32.58B tokens;
|
|
9
|
+
* 4 STOP, 3 WARN, 431 clean. The observed post-STOP tail is 10.27B (32%).
|
|
10
|
+
* Fan-out 24/40 stops before spawn 41 on the 59-, 190- and 226-spawn
|
|
11
|
+
* sessions; sustained 3.5B/5B catches the 9.15B / 26-spawn slow grind.
|
|
12
|
+
* This is a dated replay snapshot and an upper bound, not labelled evidence
|
|
13
|
+
* of false-positive rates or guaranteed savings.
|
|
15
14
|
*
|
|
16
15
|
* A previous draft shipped a 50M session budget. Routine sessions on the same
|
|
17
16
|
* machine run 1.4B to 3.5B, so that default would have fired within minutes on
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Two independent safety planes, because the data showed two independent
|
|
5
5
|
* failure modes:
|
|
6
6
|
*
|
|
7
|
-
* structural - fan-out. Many agents, each re-sending context. The
|
|
7
|
+
* structural - fan-out. Many agents, each re-sending context. The 190-spawn
|
|
8
8
|
* session. Caught by an absolute spawn cap.
|
|
9
9
|
* economic - sustained burn. Few agents, long session, enormous total. The
|
|
10
10
|
* 9.15B session with only 26 spawns, which a spawn cap cannot
|
|
@@ -21,5 +21,8 @@ import type { BurnReport, SessionState, Thresholds } from '../types';
|
|
|
21
21
|
* would have if the pending spawn were allowed; the decision must be about the
|
|
22
22
|
* proposal, not about a violation the transcript has already recorded.
|
|
23
23
|
*/
|
|
24
|
-
export declare function evaluate(state: SessionState, thresholds: Thresholds, proposedSpawnDepth?: number | null
|
|
24
|
+
export declare function evaluate(state: SessionState, thresholds: Thresholds, proposedSpawnDepth?: number | null, account?: {
|
|
25
|
+
sessions: SessionState[];
|
|
26
|
+
now: number;
|
|
27
|
+
}): BurnReport;
|
|
25
28
|
export declare function fmt(n: number): string;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Two independent safety planes, because the data showed two independent
|
|
6
6
|
* failure modes:
|
|
7
7
|
*
|
|
8
|
-
* structural - fan-out. Many agents, each re-sending context. The
|
|
8
|
+
* structural - fan-out. Many agents, each re-sending context. The 190-spawn
|
|
9
9
|
* session. Caught by an absolute spawn cap.
|
|
10
10
|
* economic - sustained burn. Few agents, long session, enormous total. The
|
|
11
11
|
* 9.15B session with only 26 spawns, which a spawn cap cannot
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
20
|
exports.evaluate = evaluate;
|
|
21
21
|
exports.fmt = fmt;
|
|
22
|
+
const account_1 = require("../state/account");
|
|
22
23
|
const session_1 = require("../state/session");
|
|
23
24
|
const RANK = { OK: 0, WARN: 1, STOP: 2 };
|
|
24
25
|
function worst(a, b) {
|
|
@@ -29,7 +30,7 @@ function worst(a, b) {
|
|
|
29
30
|
* would have if the pending spawn were allowed; the decision must be about the
|
|
30
31
|
* proposal, not about a violation the transcript has already recorded.
|
|
31
32
|
*/
|
|
32
|
-
function evaluate(state, thresholds, proposedSpawnDepth = null) {
|
|
33
|
+
function evaluate(state, thresholds, proposedSpawnDepth = null, account) {
|
|
33
34
|
const findings = [];
|
|
34
35
|
let verdict = 'OK';
|
|
35
36
|
// ---- structural plane: fan-out --------------------------------------
|
|
@@ -132,6 +133,27 @@ function evaluate(state, thresholds, proposedSpawnDepth = null) {
|
|
|
132
133
|
});
|
|
133
134
|
verdict = worst(verdict, 'WARN');
|
|
134
135
|
}
|
|
136
|
+
// Machine activity uses status's 30-minute liveness window. Token budgets
|
|
137
|
+
// sum each live session's last configured number of active-minute buckets.
|
|
138
|
+
if (account && thresholds.account) {
|
|
139
|
+
const t = thresholds.account;
|
|
140
|
+
const live = account.sessions.filter((s) => account.now - s.lastEventAt <= account_1.ACTIVE_SESSION_WINDOW_MS);
|
|
141
|
+
if (live.length > t.warnConcurrentSessions) {
|
|
142
|
+
findings.push({ detector: 'account', verdict: 'WARN',
|
|
143
|
+
summary: `${live.length} concurrent active sessions on this machine; warning threshold is ${t.warnConcurrentSessions}.`,
|
|
144
|
+
observed: live.length, threshold: t.warnConcurrentSessions });
|
|
145
|
+
verdict = worst(verdict, 'WARN');
|
|
146
|
+
}
|
|
147
|
+
const tokens = live.reduce((sum, s) => sum + (0, session_1.windowSum)(s.tokensByActiveMinute, s.activeMinutes, t.windowActiveMinutes), 0);
|
|
148
|
+
const severity = t.stopTokens != null && tokens >= t.stopTokens ? 'STOP'
|
|
149
|
+
: t.warnTokens != null && tokens >= t.warnTokens ? 'WARN' : null;
|
|
150
|
+
if (severity) {
|
|
151
|
+
findings.push({ detector: 'account', verdict: severity,
|
|
152
|
+
summary: `Active sessions consumed ${fmt(tokens)} tokens over their last ${t.windowActiveMinutes} active minutes.`,
|
|
153
|
+
observed: tokens, threshold: (severity === 'STOP' ? t.stopTokens : t.warnTokens) });
|
|
154
|
+
verdict = worst(verdict, severity);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
135
157
|
// ---- advisory: duplicate work --------------------------------------
|
|
136
158
|
let duplicated = 0;
|
|
137
159
|
let worstSurfaceReaders = 0;
|
|
@@ -192,6 +214,8 @@ function prescribe(state, findings, t) {
|
|
|
192
214
|
if (has('burn_debt')) {
|
|
193
215
|
out.push('Burn is sustained above your own baseline. Narrow the task or split it into a new session.');
|
|
194
216
|
}
|
|
217
|
+
if (has('account'))
|
|
218
|
+
out.push('Pause new work across active sessions on this machine until the account window clears.');
|
|
195
219
|
if (has('duplicate_work')) {
|
|
196
220
|
out.push('Assign one reader per file and pass a short digest down, instead of letting every agent re-read it.');
|
|
197
221
|
}
|
package/dist/src/gateway.js
CHANGED
|
@@ -41,6 +41,7 @@ const pre_tool_use_1 = require("./hook/pre-tool-use");
|
|
|
41
41
|
const override_1 = require("./override");
|
|
42
42
|
const receipt_1 = require("./receipt");
|
|
43
43
|
const reservations_1 = require("./state/reservations");
|
|
44
|
+
const account_1 = require("./state/account");
|
|
44
45
|
const session_1 = require("./state/session");
|
|
45
46
|
const MAX_SEEN_EVENTS = 4000;
|
|
46
47
|
const FILE_PREFIX = 'gw-';
|
|
@@ -214,7 +215,7 @@ class Gateway {
|
|
|
214
215
|
}
|
|
215
216
|
const live = new Map(meta.liveSpawns);
|
|
216
217
|
const proposedDepth = event.proposedDepth ?? (event.issuerId !== undefined && live.has(event.issuerId) ? live.get(event.issuerId) + 1 : 1);
|
|
217
|
-
const report = (0, evaluate_1.evaluate)(state, policy.thresholds, proposedDepth);
|
|
218
|
+
const report = (0, evaluate_1.evaluate)(state, policy.thresholds, proposedDepth, { sessions: (0, account_1.readAccountSessions)(this.home, state, event.at), now: event.at });
|
|
218
219
|
const reservation = tx.reserve({
|
|
219
220
|
sessionId: event.sessionId,
|
|
220
221
|
toolUseId: event.spawnId,
|
|
@@ -264,13 +265,13 @@ class Gateway {
|
|
|
264
265
|
this.applyUsage(event.callId, event.estimatedTokens, 0, event.at, state, calls);
|
|
265
266
|
}
|
|
266
267
|
meta.calls = [...calls];
|
|
267
|
-
const session = (0, evaluate_1.evaluate)(state, policy.thresholds, null);
|
|
268
|
+
const session = (0, evaluate_1.evaluate)(state, policy.thresholds, null, { sessions: (0, account_1.readAccountSessions)(this.home, state, event.at), now: event.at });
|
|
268
269
|
const local = (0, local_compute_1.evaluateLocalCompute)(compute, policy.thresholds);
|
|
269
270
|
const report = merge(session, local.findings);
|
|
270
271
|
const wouldBlock = report.verdict === 'STOP';
|
|
271
272
|
// A low-confidence session (proxy without a session header) can be
|
|
272
273
|
// stopped only on machine-scope grounds. Its session count is a guess.
|
|
273
|
-
const machineStop = local.verdict === 'STOP';
|
|
274
|
+
const machineStop = local.verdict === 'STOP' || session.findings.some((f) => f.detector === 'account' && f.verdict === 'STOP');
|
|
274
275
|
const due = wouldBlock && policy.mode === 'enforce' && (event.attribution === 'high' || machineStop);
|
|
275
276
|
const override = due ? (0, override_1.consumeOverride)(this.home, event.at) : null;
|
|
276
277
|
const blocked = due && !override;
|
|
@@ -49,6 +49,8 @@ export interface PersistedSession {
|
|
|
49
49
|
};
|
|
50
50
|
/** Signature of the last finding set the user was told about. */
|
|
51
51
|
notified?: string;
|
|
52
|
+
/** Additive chain head; old session files begin at genesis. */
|
|
53
|
+
lastReceipt?: string | null;
|
|
52
54
|
}
|
|
53
55
|
/** Inflate a persisted Claude Code session. Shared with status. */
|
|
54
56
|
export declare function inflateHookSession(raw: PersistedSession): SessionState;
|
|
@@ -22,6 +22,10 @@ exports.loadPolicy = loadPolicy;
|
|
|
22
22
|
exports.refreshSession = refreshSession;
|
|
23
23
|
exports.handlePreToolUse = handlePreToolUse;
|
|
24
24
|
exports.settingsSnippet = settingsSnippet;
|
|
25
|
+
const node_crypto_1 = require("node:crypto");
|
|
26
|
+
const events_1 = require("../events");
|
|
27
|
+
const receipt_1 = require("../receipt");
|
|
28
|
+
const account_1 = require("../state/account");
|
|
25
29
|
const node_fs_1 = require("node:fs");
|
|
26
30
|
const node_path_1 = require("node:path");
|
|
27
31
|
const evaluate_1 = require("../detectors/evaluate");
|
|
@@ -48,13 +52,13 @@ function loadSession(home, sessionId, firstEventAt) {
|
|
|
48
52
|
try {
|
|
49
53
|
const raw = JSON.parse((0, node_fs_1.readFileSync)(sessionFile(home, sessionId), 'utf8'));
|
|
50
54
|
const cursor = { ...raw.cursor, depthByUuid: new Map(raw.cursor.depthByUuid) };
|
|
51
|
-
return { cursor, state: inflateHookSession(raw), notified: raw.notified ?? '' };
|
|
55
|
+
return { cursor, state: inflateHookSession(raw), notified: raw.notified ?? '', lastReceipt: raw.lastReceipt ?? null };
|
|
52
56
|
}
|
|
53
57
|
catch {
|
|
54
|
-
return { cursor: (0, claude_transcript_1.newCursor)(), state: (0, session_1.newSessionState)(sessionId, firstEventAt), notified: '' };
|
|
58
|
+
return { cursor: (0, claude_transcript_1.newCursor)(), state: (0, session_1.newSessionState)(sessionId, firstEventAt), notified: '', lastReceipt: null };
|
|
55
59
|
}
|
|
56
60
|
}
|
|
57
|
-
function saveSession(home, cursor, state, notified) {
|
|
61
|
+
function saveSession(home, cursor, state, notified, lastReceipt) {
|
|
58
62
|
(0, node_fs_1.mkdirSync)((0, node_path_1.join)(home, 'sessions'), { recursive: true, mode: 0o700 });
|
|
59
63
|
const persisted = {
|
|
60
64
|
cursor: { offset: cursor.offset, size: cursor.size, malformedLines: cursor.malformedLines, depthByUuid: [...cursor.depthByUuid] },
|
|
@@ -65,6 +69,7 @@ function saveSession(home, cursor, state, notified) {
|
|
|
65
69
|
surfaceReaders: [...state.surfaceReaders].map(([k, v]) => [k, [...v]]),
|
|
66
70
|
},
|
|
67
71
|
notified,
|
|
72
|
+
lastReceipt,
|
|
68
73
|
};
|
|
69
74
|
const file = sessionFile(home, state.sessionId);
|
|
70
75
|
(0, node_fs_1.writeFileSync)(`${file}.tmp`, JSON.stringify(persisted), { mode: 0o600 });
|
|
@@ -99,14 +104,14 @@ function recordDecision(home, entry) {
|
|
|
99
104
|
}
|
|
100
105
|
/** Refresh session state from the transcript. Cheap: only new bytes are read. */
|
|
101
106
|
function refreshSession(home, sessionId, transcriptPath) {
|
|
102
|
-
const { cursor, state, notified } = loadSession(home, sessionId, Date.now());
|
|
107
|
+
const { cursor, state, notified, lastReceipt } = loadSession(home, sessionId, Date.now());
|
|
103
108
|
const before = state.spawnCount;
|
|
104
109
|
const events = (0, claude_transcript_1.readIncremental)(transcriptPath, cursor);
|
|
105
110
|
for (const event of events)
|
|
106
111
|
(0, session_1.applyEvent)(state, event);
|
|
107
112
|
if (events.length > 0 && state.startedAt > events[0].at)
|
|
108
113
|
state.startedAt = events[0].at;
|
|
109
|
-
saveSession(home, cursor, state, notified);
|
|
114
|
+
saveSession(home, cursor, state, notified, lastReceipt);
|
|
110
115
|
return { cursor, state, newSpawns: state.spawnCount - before, notified };
|
|
111
116
|
}
|
|
112
117
|
function rememberNotified(home, sessionId, signature) {
|
|
@@ -128,71 +133,88 @@ function handlePreToolUse(input, home, now = Date.now()) {
|
|
|
128
133
|
}
|
|
129
134
|
const policy = loadPolicy(home);
|
|
130
135
|
const store = new reservations_1.ReservationStore(home);
|
|
131
|
-
let report;
|
|
132
|
-
let reservation;
|
|
133
|
-
let notified = '';
|
|
134
136
|
try {
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
137
|
+
const signer = receipt_1.ReceiptSigner.loadOrCreate(home);
|
|
138
|
+
return store.withLock((tx) => {
|
|
139
|
+
const refreshed = refreshSession(home, input.session_id, input.transcript_path);
|
|
140
|
+
const { state, newSpawns } = refreshed;
|
|
141
|
+
const notified = refreshed.notified;
|
|
142
|
+
if (newSpawns > 0)
|
|
143
|
+
tx.reconcile(input.session_id, state.spawnCount, state.spawnCount - newSpawns);
|
|
144
|
+
// Depth of the proposed child: the issuing agent's depth plus one. A hook
|
|
145
|
+
// fired inside a subagent carries agent_id; treat that as depth 1 issuer.
|
|
146
|
+
const proposedDepth = input.agent_id ? 2 : 1;
|
|
147
|
+
const report = (0, evaluate_1.evaluate)(state, policy.thresholds, proposedDepth, { sessions: (0, account_1.readAccountSessions)(home, state, now), now });
|
|
148
|
+
const reservation = tx.reserve({
|
|
149
|
+
sessionId: input.session_id,
|
|
150
|
+
toolUseId: input.tool_use_id ?? `${input.session_id}:${now}`,
|
|
151
|
+
observedSpawns: state.spawnCount,
|
|
152
|
+
ceiling: policy.thresholds.fanout.stop,
|
|
153
|
+
now,
|
|
154
|
+
});
|
|
155
|
+
const shouldDeny = report.verdict === 'STOP' || !reservation.allowed;
|
|
156
|
+
const reason = shouldDeny ? buildDenyReason(report, reservation) : '';
|
|
157
|
+
// The audited override: only consulted when a block is about to happen.
|
|
158
|
+
const override = shouldDeny && policy.mode === 'enforce' ? (0, override_1.consumeOverride)(home, now) : null;
|
|
159
|
+
const blocked = policy.mode === 'enforce' && shouldDeny && !override;
|
|
160
|
+
const verdict = shouldDeny ? 'STOP' : report.verdict;
|
|
161
|
+
const raw = JSON.parse((0, node_fs_1.readFileSync)(sessionFile(home, input.session_id), 'utf8'));
|
|
162
|
+
const receipt = signer.sign({
|
|
163
|
+
schema: 'agentguard.burn.decision.v1', decisionId: (0, node_crypto_1.randomUUID)(), at: now,
|
|
164
|
+
host: 'claude-code', action: 'spawn', sessionDigest: (0, receipt_1.sha256)(input.session_id),
|
|
165
|
+
policy: { mode: policy.mode, digest: (0, receipt_1.sha256)((0, receipt_1.canonical)(policy)) },
|
|
166
|
+
measured: { sessionTokens: state.totalTokens, sessionSpawns: reservation.effectiveSpawns,
|
|
167
|
+
proposedDepth, inFlight: 0, occupiedMs: 0 },
|
|
168
|
+
coverage: events_1.CAPABILITIES['claude-code'], verdict, blocked,
|
|
169
|
+
reasons: [...report.findings.map((f) => `${f.detector}:${f.verdict}`), ...(!reservation.allowed ? ['fanout:STOP'] : [])],
|
|
170
|
+
previous: raw.lastReceipt ?? null,
|
|
171
|
+
});
|
|
172
|
+
store.assertHeld();
|
|
173
|
+
(0, node_fs_1.appendFileSync)((0, node_path_1.join)(home, 'receipts.ndjson'), `${JSON.stringify(receipt)}\n`, { mode: 0o600 });
|
|
174
|
+
saveSession(home, refreshed.cursor, state, notified, (0, receipt_1.receiptDigest)(receipt));
|
|
175
|
+
recordDecision(home, {
|
|
176
|
+
host: 'claude-code', action: 'spawn',
|
|
177
|
+
at: now,
|
|
178
|
+
sessionId: input.session_id,
|
|
179
|
+
toolUseId: input.tool_use_id ?? null,
|
|
180
|
+
verdict,
|
|
181
|
+
wouldDeny: shouldDeny,
|
|
182
|
+
enforced: policy.mode === 'enforce' && shouldDeny && !override,
|
|
183
|
+
overridden: override ? { once: override.once, reason: override.reason } : undefined,
|
|
184
|
+
mode: policy.mode,
|
|
185
|
+
findings: report.findings.map((f) => ({ detector: f.detector, verdict: f.verdict, observed: f.observed, threshold: f.threshold })),
|
|
186
|
+
effectiveSpawns: reservation.effectiveSpawns,
|
|
187
|
+
totals: report.totals,
|
|
188
|
+
});
|
|
189
|
+
if (shouldDeny && policy.mode === 'enforce') {
|
|
190
|
+
if (!override)
|
|
191
|
+
return deny(reason);
|
|
192
|
+
return {
|
|
193
|
+
continue: true,
|
|
194
|
+
systemMessage: `AgentGuard STOP overridden${override.once ? ' once' : ''} ("${override.reason}"): ${report.findings[0]?.summary ?? ''}`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
if (report.verdict !== 'OK') {
|
|
198
|
+
const signature = findingSignature(report);
|
|
199
|
+
if (signature === notified)
|
|
200
|
+
return { continue: true, suppressOutput: true };
|
|
201
|
+
rememberNotified(home, input.session_id, signature);
|
|
202
|
+
return {
|
|
203
|
+
continue: true,
|
|
204
|
+
systemMessage: `AgentGuard ${report.verdict}${policy.mode === 'shadow' && shouldDeny ? ' (shadow: would have blocked)' : ''}: ${report.findings[0]?.summary ?? ''}`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
if (notified)
|
|
208
|
+
rememberNotified(home, input.session_id, '');
|
|
209
|
+
return { continue: true, suppressOutput: true };
|
|
150
210
|
});
|
|
151
211
|
}
|
|
152
212
|
catch (error) {
|
|
153
213
|
// Could not coordinate. Deny the spawn; never let a burst through blind.
|
|
154
214
|
const reason = `AgentGuard failed closed: ${error instanceof Error ? error.message : 'unknown error'}`;
|
|
155
|
-
recordDecision(home, { at: now, sessionId: input.session_id, verdict: 'STOP', enforced: policy.mode === 'enforce', reason, failClosed: true });
|
|
215
|
+
recordDecision(home, { host: 'claude-code', action: 'spawn', at: now, sessionId: input.session_id, verdict: 'STOP', enforced: policy.mode === 'enforce', reason, failClosed: true });
|
|
156
216
|
return policy.mode === 'enforce' ? deny(reason) : { continue: true, suppressOutput: true };
|
|
157
217
|
}
|
|
158
|
-
const shouldDeny = report.verdict === 'STOP' || !reservation.allowed;
|
|
159
|
-
const reason = shouldDeny ? buildDenyReason(report, reservation) : '';
|
|
160
|
-
// The audited override: only consulted when a block is about to happen.
|
|
161
|
-
const override = shouldDeny && policy.mode === 'enforce' ? (0, override_1.consumeOverride)(home, now) : null;
|
|
162
|
-
recordDecision(home, {
|
|
163
|
-
at: now,
|
|
164
|
-
sessionId: input.session_id,
|
|
165
|
-
toolUseId: input.tool_use_id ?? null,
|
|
166
|
-
verdict: report.verdict,
|
|
167
|
-
wouldDeny: shouldDeny,
|
|
168
|
-
enforced: policy.mode === 'enforce' && shouldDeny && !override,
|
|
169
|
-
overridden: override ? { once: override.once, reason: override.reason } : undefined,
|
|
170
|
-
mode: policy.mode,
|
|
171
|
-
findings: report.findings.map((f) => ({ detector: f.detector, verdict: f.verdict, observed: f.observed, threshold: f.threshold })),
|
|
172
|
-
effectiveSpawns: reservation.effectiveSpawns,
|
|
173
|
-
totals: report.totals,
|
|
174
|
-
});
|
|
175
|
-
if (shouldDeny && policy.mode === 'enforce') {
|
|
176
|
-
if (!override)
|
|
177
|
-
return deny(reason);
|
|
178
|
-
return {
|
|
179
|
-
continue: true,
|
|
180
|
-
systemMessage: `AgentGuard STOP overridden${override.once ? ' once' : ''} ("${override.reason}"): ${report.findings[0]?.summary ?? ''}`,
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
if (report.verdict !== 'OK') {
|
|
184
|
-
const signature = findingSignature(report);
|
|
185
|
-
if (signature === notified)
|
|
186
|
-
return { continue: true, suppressOutput: true };
|
|
187
|
-
rememberNotified(home, input.session_id, signature);
|
|
188
|
-
return {
|
|
189
|
-
continue: true,
|
|
190
|
-
systemMessage: `AgentGuard ${report.verdict}${policy.mode === 'shadow' && shouldDeny ? ' (shadow: would have blocked)' : ''}: ${report.findings[0]?.summary ?? ''}`,
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
if (notified)
|
|
194
|
-
rememberNotified(home, input.session_id, '');
|
|
195
|
-
return { continue: true, suppressOutput: true };
|
|
196
218
|
}
|
|
197
219
|
function buildDenyReason(report, _reservation) {
|
|
198
220
|
// Claude Code shows this reason to the user. A box reads as an alarm; a
|
|
@@ -160,7 +160,7 @@ function renderReplay(summary, opts = {}) {
|
|
|
160
160
|
out.push(row(paint(on, C.dim, `${s.sessionId.slice(0, 8)} ${(0, evaluate_1.fmt)(s.totalTokens).padStart(6)} · ${String(s.spawns).padStart(3)} spawns ${detail}`)));
|
|
161
161
|
}
|
|
162
162
|
if (summary.sessions.length > top) {
|
|
163
|
-
out.push(row(paint(on, C.dim, `… ${summary.sessions.length - top} more
|
|
163
|
+
out.push(row(paint(on, C.dim, `… ${summary.sessions.length - top} more sessions (included in totals)`)));
|
|
164
164
|
}
|
|
165
165
|
out.push(MID);
|
|
166
166
|
out.push(row(paint(on, C.dim, 'Upper bound; assumes no override or restart. Nothing left this machine.')));
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ACTIVE_SESSION_WINDOW_MS = void 0;
|
|
4
|
+
exports.readAccountSessions = readAccountSessions;
|
|
5
|
+
/** Read the same hook and gateway session files used by machine status. */
|
|
6
|
+
const node_fs_1 = require("node:fs");
|
|
7
|
+
const node_path_1 = require("node:path");
|
|
8
|
+
exports.ACTIVE_SESSION_WINDOW_MS = 30 * 60 * 1000;
|
|
9
|
+
function readAccountSessions(home, current, now) {
|
|
10
|
+
const sessions = new Map();
|
|
11
|
+
let names;
|
|
12
|
+
try {
|
|
13
|
+
names = (0, node_fs_1.readdirSync)((0, node_path_1.join)(home, 'sessions'));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
names = [];
|
|
17
|
+
}
|
|
18
|
+
for (const name of names) {
|
|
19
|
+
if (!name.endsWith('.json'))
|
|
20
|
+
continue;
|
|
21
|
+
try {
|
|
22
|
+
const raw = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(home, 'sessions', name), 'utf8'));
|
|
23
|
+
if (!raw.state || (raw.closedAt !== undefined && raw.closedAt !== null))
|
|
24
|
+
continue;
|
|
25
|
+
const state = raw.state;
|
|
26
|
+
if (typeof state.sessionId !== 'string' || !Number.isFinite(state.lastEventAt))
|
|
27
|
+
continue;
|
|
28
|
+
const prior = sessions.get(state.sessionId);
|
|
29
|
+
if (!prior || state.lastEventAt > prior.lastEventAt) {
|
|
30
|
+
sessions.set(state.sessionId, { ...state, tokensByActiveMinute: new Map(state.tokensByActiveMinute) });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch { /* Ignore malformed or foreign session files, as status does. */ }
|
|
34
|
+
}
|
|
35
|
+
// Include this admission, even when its transcript has no new usage yet.
|
|
36
|
+
// Replacing by ID prevents double counting the caller's persisted snapshot.
|
|
37
|
+
sessions.set(current.sessionId, { ...current, lastEventAt: now });
|
|
38
|
+
return [...sessions.values()];
|
|
39
|
+
}
|
|
@@ -69,6 +69,7 @@ class ReservationStore {
|
|
|
69
69
|
/** Acquire the lock or throw. Callers must fail closed on throw. */
|
|
70
70
|
acquire() {
|
|
71
71
|
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
72
|
+
let attempt = 0;
|
|
72
73
|
for (;;) {
|
|
73
74
|
try {
|
|
74
75
|
(0, node_fs_1.mkdirSync)(this.lockDir, { mode: 0o700 });
|
|
@@ -90,7 +91,10 @@ class ReservationStore {
|
|
|
90
91
|
if (Date.now() > deadline) {
|
|
91
92
|
throw new Error('AgentGuard could not acquire the reservation lock; failing closed.');
|
|
92
93
|
}
|
|
93
|
-
|
|
94
|
+
// Hundreds of waiters polling together can starve the holder. Spread
|
|
95
|
+
// retries with bounded backoff while retaining the existing deadline.
|
|
96
|
+
const backoff = Math.min(250, LOCK_SPIN_MS * 2 ** Math.min(attempt++, 4));
|
|
97
|
+
sleepSync(Math.min(Math.max(1, deadline - Date.now()), backoff * (0.5 + Math.random() / 2)));
|
|
94
98
|
}
|
|
95
99
|
}
|
|
96
100
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"captured_from": "codex-cli 0.151.0, 2026-09-03
|
|
2
|
+
"captured_from": "Synthetic identifiers; payload shape based on codex-cli 0.151.0 PreToolUse, 2026-09-03",
|
|
3
3
|
"payloads": [
|
|
4
4
|
{
|
|
5
|
-
"session_id": "
|
|
6
|
-
"turn_id": "
|
|
5
|
+
"session_id": "00000000-0000-7000-8000-000000000001",
|
|
6
|
+
"turn_id": "00000000-0000-7000-8000-000000000011",
|
|
7
7
|
"transcript_path": "/Users/example/.codex/sessions/2026/09/03/rollout-example.jsonl",
|
|
8
8
|
"cwd": "/Users/example/project",
|
|
9
9
|
"hook_event_name": "PreToolUse",
|
|
@@ -13,11 +13,11 @@
|
|
|
13
13
|
"tool_input": {
|
|
14
14
|
"command": "echo CANARY_OK"
|
|
15
15
|
},
|
|
16
|
-
"tool_use_id": "
|
|
16
|
+
"tool_use_id": "call_SYNTHETIC000000000000001"
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
|
-
"session_id": "
|
|
20
|
-
"turn_id": "
|
|
19
|
+
"session_id": "00000000-0000-7000-8000-000000000002",
|
|
20
|
+
"turn_id": "00000000-0000-7000-8000-000000000012",
|
|
21
21
|
"transcript_path": "/Users/example/.codex/sessions/2026/09/03/rollout-example.jsonl",
|
|
22
22
|
"cwd": "/Users/example/project",
|
|
23
23
|
"hook_event_name": "PreToolUse",
|
|
@@ -28,11 +28,11 @@
|
|
|
28
28
|
"message": "<redacted>",
|
|
29
29
|
"fork_context": "<redacted>"
|
|
30
30
|
},
|
|
31
|
-
"tool_use_id": "
|
|
31
|
+
"tool_use_id": "call_SYNTHETIC000000000000002"
|
|
32
32
|
},
|
|
33
33
|
{
|
|
34
|
-
"session_id": "
|
|
35
|
-
"turn_id": "
|
|
34
|
+
"session_id": "00000000-0000-7000-8000-000000000002",
|
|
35
|
+
"turn_id": "00000000-0000-7000-8000-000000000012",
|
|
36
36
|
"transcript_path": "/Users/example/.codex/sessions/2026/09/03/rollout-example.jsonl",
|
|
37
37
|
"cwd": "/Users/example/project",
|
|
38
38
|
"hook_event_name": "PreToolUse",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"targets": "<redacted>",
|
|
44
44
|
"timeout_ms": "<redacted>"
|
|
45
45
|
},
|
|
46
|
-
"tool_use_id": "
|
|
46
|
+
"tool_use_id": "call_SYNTHETIC000000000000003"
|
|
47
47
|
}
|
|
48
48
|
]
|
|
49
|
-
}
|
|
49
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentguard-run/burn",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Local runaway-agent circuit breaker for AI coding agents and local model runtimes. One policy across Claude Code, Cursor, Codex, Ollama, vLLM, LM Studio and raw orchestrators: detects fan-out storms and sustained token burn, blocks the next spawn, and proves what happened with content-free signed receipts. Nothing leaves the machine.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"type": "commonjs",
|