@agentguard-run/burn 0.2.1 → 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 CHANGED
@@ -1,5 +1,32 @@
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
 
21
+ ## 0.2.2 (2026-09-03)
22
+
23
+ - Codex: `status` and `init codex --write` now say, every time, that the
24
+ hook must be trusted once with `/hooks` inside Codex. Verified that an
25
+ untrusted hook stalls `codex exec` with no output the first time a spawn
26
+ would be gated, from `hooks.json` and from `config.toml` alike.
27
+ - Verified the installed Claude Code hook end to end from the global
28
+ install: 0.2s on a 1.95B-token transcript, decision recorded.
29
+
3
30
  ## 0.2.1 (2026-09-03)
4
31
 
5
32
  Finishing the product so it can be used, and a live Codex canary.
@@ -66,7 +93,7 @@ The cross-tool layer. One policy, one lock, one receipt across hosts.
66
93
  contention). Under 240 concurrent hook processes this admitted 41 to 46
67
94
  spawns against a cap of 40. Lock instances now carry a nonce; reclaims
68
95
  are verified against the instance judged; writes are fenced on the
69
- holder's nonce. 192 runs at 240 concurrent processes admit exactly 40.
96
+ holder's nonce. See 0.2.3 for the reproducible 240-process audit result.
70
97
  - A lock owner record could be read half-written and parsed as "held since
71
98
  1970". The record is now written atomically and malformed records are
72
99
  never trusted.
@@ -83,7 +110,8 @@ The cross-tool layer. One policy, one lock, one receipt across hosts.
83
110
 
84
111
  ### Unchanged
85
112
  - The Claude Code hook, transcript reader, detectors and thresholds. The
86
- existing 24 tests pass as they were.
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.
87
115
 
88
116
  ## 0.1.1 (2026-09-03)
89
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/spend, comprising every file
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
- sources under src/, the compiled output under dist/, the command-line entry
8
- point under bin/, the documentation under docs/, the fixtures under tests/, and
9
- the root files README.md, README.es-419.md, README.pt-BR.md, CHANGELOG.md,
10
- PATENTS.md and package.json. Any file the package ships that Section 3 does not
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 files README.md, README.es-419.md and README.pt-BR.md
44
- - The test fixture under tests/
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/, dist/ and bin/, the documentation under docs/, and the root files
78
- CHANGELOG.md and PATENTS.md remain licensed only under the alpha evaluation
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
- Fitted against 412 real Claude Code sessions, which showed two independent
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 | 174- and 226-spawn sessions; STOP would have intercepted 91% of the runaway |
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
- Across all 412 sessions: 3 STOP, 2 WARN, 407 clean. Zero false STOPs.
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% on every session, healthy and pathological alike. It
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
@@ -155,8 +160,15 @@ spawn through with the reason on the ledger. Two things the docs got wrong
155
160
  and the wire settled: the tool arrives as `spawn_agent` (the docs say it
156
161
  "matches as Agent"; the matcher covers both), and project-local hooks only
157
162
  load when the project is trusted. The captured payloads are in
158
- `fixtures/codex-0.151.0-pretooluse.json` and drive a test. After
159
- `init codex --write`, trust the hook once with `/hooks` inside Codex.
163
+ `fixtures/codex-0.151.0-pretooluse.json` and drive a test.
164
+
165
+ One step Codex makes you do by hand: after `init codex --write`, open
166
+ `codex`, run `/hooks`, and trust the AgentGuard hook. Codex requires this
167
+ once per hook source, and there is no CLI for it. Until it is done,
168
+ `codex exec` stalls the first time a spawn would be gated (verified: it
169
+ hangs with no output; the same with hooks defined in `config.toml`).
170
+ `status` repeats this under the codex line because it cannot see whether
171
+ trust was granted.
160
172
 
161
173
  **Cursor** is verified against the documented schema (`permission`,
162
174
  `user_message`, `agent_message`; `~/.cursor/hooks.json` with `failClosed`),
@@ -178,20 +190,41 @@ Ten parallel `Agent` calls launch ten hook processes that all read the same
178
190
  transcript and all see the same count. A naive cap is cosmetic during exactly
179
191
  the burst it exists for. Spawns are admitted through an atomic, cross-process
180
192
  reservation under a machine-wide lock; the test suite launches 60 real OS
181
- processes against a cap of 40 and asserts exactly 40 are admitted, through
182
- the Claude hook and again through the Cursor hook.
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.
183
205
 
184
206
  0.2.0 fixed the lock itself. Under 240 concurrent hook processes the 0.1
185
207
  lock could tear down a live sibling's lock (a waiter judged "owner is dead"
186
208
  about an instance that had already been released and replaced) and admit
187
209
  41 to 46. Lock instances now carry a nonce; a reclaim only counts if it
188
210
  grabbed the instance it judged, and every write is fenced on the holder's
189
- own nonce still being on the path. 192 runs at 240 concurrent processes:
190
- exactly 40, every time.
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.
191
214
 
192
215
  Single-machine by design. Two laptops on one account do not share state, and
193
216
  that is stated rather than hidden.
194
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
+
195
228
  ## What it never does
196
229
 
197
230
  No prompts, responses, file contents, or tool inputs are persisted or rendered.
package/dist/src/cli.js CHANGED
@@ -163,7 +163,7 @@ async function main(argv) {
163
163
  process.stderr.write(`init: host must be claude, cursor or codex\n`);
164
164
  return 64;
165
165
  }
166
- const label = { claude: '', cursor: 'Cursor support is BETA: verified against the documented hook schema, not yet against every installed build. "failClosed": true means a crashed hook denies the subagent.', codex: 'Codex support is BETA: a live deny, allow and override canary passed on codex-cli 0.151.0; transcript usage is best-effort and marked estimated. Trust the hook once with /hooks inside Codex.' }[target];
166
+ const label = { claude: '', cursor: 'Cursor support is BETA: verified against the documented hook schema, not yet against every installed build. "failClosed": true means a crashed hook denies the subagent.', codex: 'Codex support is BETA: a live deny, allow and override canary passed on codex-cli 0.151.0; transcript usage is best-effort and marked estimated.\nONE MORE STEP: open codex, run /hooks, and trust the AgentGuard hook. Codex requires this once per hook source; until then `codex exec` stalls the first time a spawn would be gated.' }[target];
167
167
  const tail = `Installed in ${policy.mode} mode. Nothing is blocked until you run: agentguard-burn enforce\n`;
168
168
  if (has('--write')) {
169
169
  let result;
@@ -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
- const policy = { mode: 'enforce', thresholds: defaults_1.DEFAULT_THRESHOLDS };
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
  }
@@ -2,14 +2,13 @@ import type { Policy, Thresholds } from './types';
2
2
  /**
3
3
  * Shipped thresholds.
4
4
  *
5
- * These are not intuition. They were fitted against 412 real Claude Code
6
- * sessions on the author's machine on 2026-09-03:
7
- *
8
- * fan-out 24/40 : on the 172-spawn runaway, STOP at spawn 40 would have
9
- * prevented 91% of that session's tokens.
10
- * sustained 3.5B/5B : across all 412 sessions this produced exactly 1 STOP,
11
- * the 9.15B slow grind the fan-out cap missed, 2 WARNs, and
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
@@ -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
- * These are not intuition. They were fitted against 412 real Claude Code
8
- * sessions on the author's machine on 2026-09-03:
9
- *
10
- * fan-out 24/40 : on the 172-spawn runaway, STOP at spawn 40 would have
11
- * prevented 91% of that session's tokens.
12
- * sustained 3.5B/5B : across all 412 sessions this produced exactly 1 STOP,
13
- * the 9.15B slow grind the fan-out cap missed, 2 WARNs, and
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 172-spawn
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): BurnReport;
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 172-spawn
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
  }
@@ -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 refreshed = refreshSession(home, input.session_id, input.transcript_path);
136
- const { state, newSpawns } = refreshed;
137
- notified = refreshed.notified;
138
- if (newSpawns > 0)
139
- store.reconcile(input.session_id, state.spawnCount, state.spawnCount - newSpawns);
140
- // Depth of the proposed child: the issuing agent's depth plus one. A hook
141
- // fired inside a subagent carries agent_id; treat that as depth 1 issuer.
142
- const proposedDepth = input.agent_id ? 2 : 1;
143
- report = (0, evaluate_1.evaluate)(state, policy.thresholds, proposedDepth);
144
- reservation = store.reserve({
145
- sessionId: input.session_id,
146
- toolUseId: input.tool_use_id ?? `${input.session_id}:${now}`,
147
- observedSpawns: state.spawnCount,
148
- ceiling: policy.thresholds.fanout.stop,
149
- now,
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, all clean`)));
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,3 @@
1
+ import type { SessionState } from '../types';
2
+ export declare const ACTIVE_SESSION_WINDOW_MS: number;
3
+ export declare function readAccountSessions(home: string, current: SessionState, now: number): SessionState[];
@@ -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
- sleepSync(LOCK_SPIN_MS);
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
  }
@@ -68,6 +68,12 @@ function renderHostHealth(hosts) {
68
68
  else
69
69
  state = `installed, hook command ok (${h.file.replace(process.env.HOME ?? '', '~')})`;
70
70
  lines.push(` ${name.padEnd(13)} ${state}`);
71
+ if (h.host === 'codex' && h.installed && h.commandExists) {
72
+ // Codex requires a one-time trust of each hook source. We cannot see
73
+ // whether it has been granted, and an untrusted hook stalls codex exec
74
+ // the first time it would fire, so say it every time.
75
+ lines.push(` ${''.padEnd(13)} trust it once: open codex, run /hooks, trust the AgentGuard hook. Until then codex exec stalls when a spawn would be gated.`);
76
+ }
71
77
  }
72
78
  return lines.join('\n');
73
79
  }
@@ -1,9 +1,9 @@
1
1
  {
2
- "captured_from": "codex-cli 0.151.0, 2026-09-03, codex exec with -c hooks.PreToolUse inline; --dangerously-bypass-hook-trust",
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": "01a069d3-5fc8-7470-8c95-1b42e7206746",
6
- "turn_id": "01a069d3-6018-7523-a26e-eea10123676c",
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": "call_NmxrZob0I2KFC8ChNe2PduGX"
16
+ "tool_use_id": "call_SYNTHETIC000000000000001"
17
17
  },
18
18
  {
19
- "session_id": "01a069d4-d356-73b3-9440-1c4440319de8",
20
- "turn_id": "01a069d4-d3b2-7593-85ec-8a0281f51bf1",
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": "call_oktUPQ5jcosNPOcGnEaWqRcv"
31
+ "tool_use_id": "call_SYNTHETIC000000000000002"
32
32
  },
33
33
  {
34
- "session_id": "01a069d4-d356-73b3-9440-1c4440319de8",
35
- "turn_id": "01a069d4-d3b2-7593-85ec-8a0281f51bf1",
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": "call_RK95A6i4UUhTigIGXF24gMMO"
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.1",
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",