@agentguard-run/burn 0.2.0 → 0.2.1

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,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1 (2026-09-03)
4
+
5
+ Finishing the product so it can be used, and a live Codex canary.
6
+
7
+ ### Added
8
+ - `agentguard-burn resume --once` is now real. The STOP box has advertised it
9
+ since 0.1.0; nothing read the file. Every host consults the override only
10
+ when a block is due, consumes a `--once` atomically (rename), records the
11
+ reason in the decisions ledger, and `resume --reason` without `--once`
12
+ opens a 15-minute window; `--clear` ends it.
13
+ - `init [claude|cursor|codex] --write`: merges the hook into the host's
14
+ config with a timestamped backup, finds an existing AgentGuard entry by
15
+ its command and upgrades it in place, refuses to touch a file it cannot
16
+ parse.
17
+ - `status` shows hook health per host (installed, command exists on disk;
18
+ a missing script is called out in capitals because that hook fails open),
19
+ Claude Code sessions alongside gateway sessions, and any active override.
20
+ - Warnings are announced once per change in the finding set, on every host.
21
+ - Captured codex-cli 0.151.0 payloads as a fixture; a test runs on them.
22
+
23
+ ### Verified
24
+ - Codex: live canary on codex-cli 0.151.0. `spawn_agent` denied with the
25
+ alarm box as the reason; `Bash` passed untouched; `resume --once` let the
26
+ next spawn through with the reason on the ledger. Codex moves from
27
+ experimental to beta. The wire sends `tool_name: "spawn_agent"` (the docs
28
+ say it matches as `Agent`; the matcher covers both). Project-local hooks
29
+ load only when the project is trusted; the user-layer file needs a
30
+ one-time `/hooks` trust inside Codex.
31
+
3
32
  ## 0.2.0 (2026-09-03)
4
33
 
5
34
  The cross-tool layer. One policy, one lock, one receipt across hosts.
package/README.md CHANGED
@@ -33,14 +33,32 @@ have intercepted, when, and the observed tail after each stop. It is an upper
33
33
  bound, labelled as such. Nobody installs a blocker cold.
34
34
 
35
35
  ```
36
- npx @agentguard-run/burn calibrate # fit thresholds to your own usage
37
- npx @agentguard-run/burn init # print the settings.json hook snippet
38
- npx @agentguard-run/burn status # shadow observations, eligibility
39
- npx @agentguard-run/burn enforce # after 7 days and 50 decisions
36
+ npm i -g @agentguard-run/burn
37
+ agentguard-burn init --write # merge the hook into ~/.claude/settings.json (backup taken)
38
+ agentguard-burn init codex --write # same for ~/.codex/hooks.json
39
+ agentguard-burn status # hook health, shadow observations, every live session
40
+ agentguard-burn enforce # after 7 days and 50 decisions
40
41
  ```
41
42
 
42
43
  The hook installs in **shadow mode**: every decision is recorded, nothing is
43
- blocked, until you have seen it be right.
44
+ blocked, until you have seen it be right. `status` shows what it would have
45
+ done so far, which sessions are live on every host, and whether each hook's
46
+ command still exists on disk (a hook whose script is gone fails open, and
47
+ `status` says so in capitals).
48
+
49
+ ### When it stops you and you disagree
50
+
51
+ ```
52
+ agentguard-burn resume --once --reason "these 60 agents are the plan" # the next STOP passes, once, on any host
53
+ agentguard-burn resume --reason "load test" # every STOP passes for 15 minutes
54
+ agentguard-burn resume --clear
55
+ ```
56
+
57
+ Every override is written to the decisions ledger with its reason. A
58
+ `--once` override is consumed atomically: two hooks racing for it cannot
59
+ both pass. Warnings are spoken once per change in the finding set, not once
60
+ per spawn; eighteen identical banners train you to stop reading the
61
+ nineteenth.
44
62
 
45
63
  ## One policy across hosts (0.2.0)
46
64
 
@@ -62,7 +80,7 @@ What each host can actually see is stated, not implied:
62
80
  | Ollama proxy | none | none | authoritative | `prompt_eval_count` + `eval_count` on the final chunk |
63
81
  | vLLM / LM Studio / OpenAI-compatible proxy | none | none | authoritative when the server sends `usage`, else reported missing | non-streaming `usage`, or the final SSE usage event |
64
82
  | Cursor (beta) | authoritative | estimated | none | native `subagentStart` deny; hosted-model usage is never exposed |
65
- | Codex (experimental) | authoritative | estimated | estimated | `PreToolUse` on `spawn_agent`; transcript parsed best-effort |
83
+ | Codex (beta) | authoritative | estimated | estimated | `PreToolUse` on `spawn_agent`; live deny, allow and override canary passed on codex-cli 0.151.0; transcript parsed best-effort |
66
84
 
67
85
  An `OK` from a host that cannot see usage is an OK about spawns, and `status`
68
86
  says `usage:n/a` next to it. Missing usage never becomes a guessed zero.
@@ -128,10 +146,22 @@ agentguard-burn init codex # ~/.codex/hooks.json snippet
128
146
  Both renderers are one page each and emit only their host's documented output
129
147
  object. Codex fails the whole hook on Claude's common fields (`continue`,
130
148
  `stopReason`, `suppressOutput`), and a failed hook is a fail-open hook, so the
131
- Codex renderer never emits them and a test forbids them by name. Cursor is
132
- labelled beta and Codex experimental until an installed-version deny canary
133
- has passed on each; the schemas were verified against the vendors' documents
134
- on 2026-09-03, not against every installed build.
149
+ Codex renderer never emits them and a test forbids them by name.
150
+
151
+ **Codex** passed a live canary on the installed codex-cli 0.151.0 on
152
+ 2026-09-03: a `spawn_agent` call was denied with the alarm box as the reason,
153
+ a shell call passed untouched, and a `resume --once` override let the next
154
+ spawn through with the reason on the ledger. Two things the docs got wrong
155
+ and the wire settled: the tool arrives as `spawn_agent` (the docs say it
156
+ "matches as Agent"; the matcher covers both), and project-local hooks only
157
+ 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.
160
+
161
+ **Cursor** is verified against the documented schema (`permission`,
162
+ `user_message`, `agent_message`; `~/.cursor/hooks.json` with `failClosed`),
163
+ not yet against an installed build. It stays beta until a live deny canary
164
+ passes.
135
165
 
136
166
  ### Receipts
137
167
 
@@ -75,7 +75,10 @@ function handleCodexHook(raw, gateway, now = Date.now()) {
75
75
  // An unrecorded start leaves its reservation pending until TTL, which is
76
76
  // the conservative direction.
77
77
  quietly(() => gateway.observe([{ schemaVersion: 1, kind: 'spawn_started', eventId: `codex:start:${spawnId}`, host: HOST, sessionId: input.sessionId, at: now, spawnId, depth: 1 }]));
78
- if (decision.verdict !== 'OK') {
78
+ if (decision.overridden) {
79
+ return { systemMessage: `AgentGuard STOP overridden${decision.overridden.once ? ' once' : ''} ("${decision.overridden.reason}"): ${decision.report.findings[0]?.summary ?? ''}` };
80
+ }
81
+ if (decision.notify && decision.verdict !== 'OK') {
79
82
  return {
80
83
  systemMessage: `AgentGuard ${decision.verdict}${decision.mode === 'shadow' && decision.wouldBlock ? ' (shadow: would have blocked)' : ''}: ${decision.report.findings[0]?.summary ?? ''}`,
81
84
  };
@@ -64,7 +64,10 @@ function handleCursorHook(raw, gateway, now = Date.now()) {
64
64
  // hook after it has decided; a crash is a fail-open on most hosts.
65
65
  const depth = decision.proposedDepth ?? 1;
66
66
  quietly(() => gateway.observe([{ schemaVersion: 1, kind: 'spawn_started', eventId: `cursor:start:${spawnId}`, host: HOST, sessionId: input.sessionId, at, spawnId, depth }]));
67
- if (decision.verdict === 'WARN' || (decision.mode === 'shadow' && decision.wouldBlock)) {
67
+ if (decision.overridden) {
68
+ return { permission: 'allow', agent_message: `AgentGuard STOP overridden${decision.overridden.once ? ' once' : ''} ("${decision.overridden.reason}"): ${decision.report.findings[0]?.summary ?? ''}` };
69
+ }
70
+ if (decision.notify && decision.verdict !== 'OK') {
68
71
  return {
69
72
  permission: 'allow',
70
73
  agent_message: `AgentGuard ${decision.verdict}${decision.mode === 'shadow' && decision.wouldBlock ? ' (shadow: would have blocked)' : ''}: ${decision.report.findings[0]?.summary ?? ''}`,
package/dist/src/cli.js CHANGED
@@ -27,6 +27,8 @@ const conformance_1 = require("./conformance");
27
27
  const defaults_1 = require("./defaults");
28
28
  const gateway_1 = require("./gateway");
29
29
  const pre_tool_use_1 = require("./hook/pre-tool-use");
30
+ const install_1 = require("./install");
31
+ const override_1 = require("./override");
30
32
  const server_1 = require("./proxy/server");
31
33
  const render_1 = require("./replay/render");
32
34
  const simulate_1 = require("./replay/simulate");
@@ -131,40 +133,59 @@ async function main(argv) {
131
133
  const e = shadowEligibility();
132
134
  const gateway = new gateway_1.Gateway(HOME, { sign: false });
133
135
  const lc = policy.thresholds.localCompute;
136
+ const override = (0, override_1.readOverride)(HOME);
134
137
  process.stdout.write([
135
- `mode: ${policy.mode}`,
138
+ `mode: ${policy.mode}${policy.mode === 'enforce' ? ' (the next STOP blocks)' : ' (recording only; nothing is blocked)'}`,
136
139
  `thresholds: fan-out ${policy.thresholds.fanout.warn}/${policy.thresholds.fanout.stop} sustained ${(policy.thresholds.sustained.warnTokens / 1e9).toFixed(1)}B/${(policy.thresholds.sustained.stopTokens / 1e9).toFixed(1)}B local-compute warn at ${lc?.warnConcurrent ?? 4} concurrent${lc?.stopConcurrent ? `, stop at ${lc.stopConcurrent}` : ' (no stop set)'}`,
137
140
  policy.calibration ? `calibrated from ${policy.calibration.sessionsSampled} sessions` : 'using shipped defaults (run: agentguard-burn calibrate)',
138
141
  `shadow observation: ${e.decisions} decisions over ${e.days.toFixed(1)} days`,
139
142
  ` would have warned: ${e.warns} would have blocked: ${e.wouldBlock}`,
140
- `eligible for enforcement: ${e.eligible ? 'yes' : `no (need ${defaults_1.SHADOW_MIN_DECISIONS} decisions and ${defaults_1.SHADOW_MIN_DAYS} days)`}`,
143
+ `eligible for enforcement: ${e.eligible ? 'yes (agentguard-burn enforce)' : `no (need ${defaults_1.SHADOW_MIN_DECISIONS} decisions and ${defaults_1.SHADOW_MIN_DAYS} days)`}`,
144
+ override ? `override ACTIVE: ${override.once ? 'the next STOP' : `every STOP until ${new Date(override.until).toLocaleTimeString()}`} passes ("${override.reason}")` : '',
141
145
  '',
142
- (0, status_1.renderMachineStatus)(gateway.sessions(), gateway.compute()),
146
+ (0, status_1.renderHostHealth)(['claude', 'cursor', 'codex'].map((h) => (0, install_1.health)(h))),
147
+ '',
148
+ (0, status_1.renderMachineStatus)([...(0, status_1.readHookSessions)(HOME), ...gateway.sessions()], gateway.compute()),
143
149
  '',
144
150
  'content logging: off · telemetry: none · state: this machine only',
145
- ].join('\n') + '\n');
151
+ ]
152
+ .filter((l) => l !== '' || true)
153
+ .join('\n')
154
+ .replace(/\n\n\n/g, '\n\n') + '\n');
146
155
  return 0;
147
156
  }
148
157
  case 'init': {
149
158
  const policy = (0, pre_tool_use_1.loadPolicy)(HOME);
150
159
  savePolicy(policy);
151
160
  const cli = (0, node_path_1.join)(__dirname, 'cli.js');
152
- const target = rest.find((a) => !a.startsWith('--')) ?? 'claude';
153
- if (target === 'cursor') {
154
- process.stdout.write(`Add this to ~/.cursor/hooks.json (merge into existing "hooks"):\n\n${JSON.stringify((0, cursor_1.cursorHooksSnippet)(`node ${cli} cursor-hook`), null, 2)}\n\n` +
155
- `Cursor support is BETA: verified against the documented hook schema, not yet against every installed version. ` +
156
- `"failClosed": true means a crashed hook denies the subagent.\n` +
157
- `Installed in ${policy.mode} mode. Nothing is blocked until you run: agentguard-burn enforce\n`);
158
- return 0;
161
+ const target = (rest.find((a) => !a.startsWith('--')) ?? 'claude');
162
+ if (!['claude', 'cursor', 'codex'].includes(target)) {
163
+ process.stderr.write(`init: host must be claude, cursor or codex\n`);
164
+ return 64;
159
165
  }
160
- if (target === 'codex') {
161
- process.stdout.write(`Add this to ~/.codex/hooks.json (merge into existing "hooks"):\n\n${JSON.stringify((0, codex_1.codexHooksSnippet)(`node ${cli} codex-hook`), null, 2)}\n\n` +
162
- `Codex support is EXPERIMENTAL: the deny shape is verified against the documented schema; transcript usage is best-effort and marked estimated.\n` +
163
- `Installed in ${policy.mode} mode. Nothing is blocked until you run: agentguard-burn enforce\n`);
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];
167
+ const tail = `Installed in ${policy.mode} mode. Nothing is blocked until you run: agentguard-burn enforce\n`;
168
+ if (has('--write')) {
169
+ let result;
170
+ try {
171
+ result = (0, install_1.install)(target, cli);
172
+ }
173
+ catch (error) {
174
+ process.stderr.write(`init: refusing to touch ${(0, install_1.configPath)(target)}: ${error instanceof Error ? error.message : String(error)}\n`);
175
+ return 1;
176
+ }
177
+ process.stdout.write((result.changed ? `Wrote ${result.file}${result.backup ? ` (backup: ${result.backup})` : ''}\n` : `${result.file} already has the current hook; nothing changed.\n`) +
178
+ ` ${result.command}\n` +
179
+ (label ? `${label}\n` : '') +
180
+ tail +
181
+ `Check it: agentguard-burn status\n`);
164
182
  return 0;
165
183
  }
166
- process.stdout.write(`Add this to ~/.claude/settings.json (merge into existing "hooks"):\n\n${JSON.stringify((0, pre_tool_use_1.settingsSnippet)(`node ${cli} hook`), null, 2)}\n\nInstalled in ${policy.mode} mode. Nothing is blocked until you run: agentguard-burn enforce\n` +
167
- `Other hosts: agentguard-burn init cursor | init codex | proxy --upstream http://127.0.0.1:11434 --host ollama\n`);
184
+ const snippet = target === 'cursor' ? (0, cursor_1.cursorHooksSnippet)(`node ${cli} cursor-hook`) : target === 'codex' ? (0, codex_1.codexHooksSnippet)(`node ${cli} codex-hook`) : (0, pre_tool_use_1.settingsSnippet)(`node ${cli} hook`);
185
+ process.stdout.write(`Add this to ${(0, install_1.configPath)(target).replace(process.env.HOME ?? '', '~')} (merge into existing "hooks"), or run: agentguard-burn init ${target} --write\n\n${JSON.stringify(snippet, null, 2)}\n\n` +
186
+ (label ? `${label}\n` : '') +
187
+ tail +
188
+ (target === 'claude' ? `Other hosts: agentguard-burn init cursor | init codex | proxy --upstream http://127.0.0.1:11434 --host ollama\n` : ''));
168
189
  return 0;
169
190
  }
170
191
  case 'cursor-hook': {
@@ -230,20 +251,34 @@ async function main(argv) {
230
251
  return 0;
231
252
  }
232
253
  case 'resume': {
233
- const reason = flag('--reason') ?? 'no reason given';
234
- (0, node_fs_1.mkdirSync)(HOME, { recursive: true, mode: 0o700 });
235
- (0, node_fs_1.writeFileSync)((0, node_path_1.join)(HOME, 'override.json'), JSON.stringify({ at: Date.now(), once: has('--once'), reason }), { mode: 0o600 });
236
- process.stdout.write(`Override recorded${has('--once') ? ' for the next STOP only' : ''}: ${reason}\n`);
254
+ if (has('--clear')) {
255
+ (0, override_1.clearOverride)(HOME);
256
+ process.stdout.write('Override cleared. The next STOP blocks.\n');
257
+ return 0;
258
+ }
259
+ const reason = flag('--reason');
260
+ if (!reason) {
261
+ process.stderr.write('resume: --reason "..." is required; every override is written to the decisions ledger with it.\n');
262
+ return 64;
263
+ }
264
+ const once = has('--once');
265
+ const now = Date.now();
266
+ (0, override_1.writeOverride)(HOME, once ? { at: now, once: true, reason } : { at: now, once: false, reason, until: now + override_1.OVERRIDE_WINDOW_MS });
267
+ process.stdout.write(once
268
+ ? `Override recorded for the next STOP only, on any host: "${reason}"\n`
269
+ : `Override recorded for the next ${override_1.OVERRIDE_WINDOW_MS / 60000} minutes, on any host: "${reason}"\n (agentguard-burn resume --clear to end it early)\n`);
237
270
  return 0;
238
271
  }
239
272
  default:
240
273
  process.stdout.write('agentguard-burn <replay|calibrate|status|init|enforce|shadow|resume|proxy|conformance>\n' +
241
274
  ' replay [files...] [--json] [--top N] [--min-tokens N]\n' +
242
275
  ' calibrate fit thresholds to your history (writes shadow policy)\n' +
243
- ' status mode, shadow observations, eligibility, every host\n' +
244
- ' init [claude|cursor|codex] print the hook snippet for that host\n' +
276
+ ' status mode, shadow observations, eligibility, hook health, every host\n' +
277
+ ' init [claude|cursor|codex] [--write] print the hook snippet, or merge it into the config (with a backup)\n' +
245
278
  ' enforce [--force] shadow -> enforce\n' +
246
- ' resume --once --reason "..."\n' +
279
+ ' shadow enforce -> shadow\n' +
280
+ ' resume --once --reason "..." let the next STOP through, once, on any host\n' +
281
+ ' resume --reason "..." let STOPs through for 15 minutes; --clear ends it\n' +
247
282
  ' proxy --upstream http://127.0.0.1:11434 --host ollama|vllm|lm-studio|openai-compatible [--listen 127.0.0.1:18080] [--session ID]\n' +
248
283
  ' conformance replay the storm and the grind through every adapter shape\n');
249
284
  return command ? 64 : 0;
@@ -39,6 +39,13 @@ export interface Decision {
39
39
  wouldBlock: boolean;
40
40
  /** Enforcing, and blocking. */
41
41
  blocked: boolean;
42
+ /** A block was due and the user's recorded override let it through. */
43
+ overridden: {
44
+ once: boolean;
45
+ reason: string;
46
+ } | null;
47
+ /** The finding set changed since the user was last told. Adapters speak only when true. */
48
+ notify: boolean;
42
49
  mode: Mode;
43
50
  report: BurnReport;
44
51
  capabilities: HostCapabilities;
@@ -38,6 +38,7 @@ const evaluate_1 = require("./detectors/evaluate");
38
38
  const local_compute_1 = require("./detectors/local-compute");
39
39
  const events_1 = require("./events");
40
40
  const pre_tool_use_1 = require("./hook/pre-tool-use");
41
+ const override_1 = require("./override");
41
42
  const receipt_1 = require("./receipt");
42
43
  const reservations_1 = require("./state/reservations");
43
44
  const session_1 = require("./state/session");
@@ -224,8 +225,9 @@ class Gateway {
224
225
  const wouldBlock = report.verdict === 'STOP' || !reservation.allowed;
225
226
  const verdict = wouldBlock ? 'STOP' : report.verdict;
226
227
  // Session-scope STOPs need a session we trust. Fan-out is session scope.
227
- const blocked = wouldBlock && policy.mode === 'enforce' && event.attribution === 'high';
228
- const decision = this.finish({ action: 'spawn', host: event.host, sessionId: event.sessionId, at: event.at, proposedDepth, verdict, wouldBlock, blocked, report, effectiveSpawns: reservation.effectiveSpawns, compute: null }, policy, meta, state);
228
+ const due = wouldBlock && policy.mode === 'enforce' && event.attribution === 'high';
229
+ const override = due ? (0, override_1.consumeOverride)(this.home, event.at) : null;
230
+ const decision = this.finish({ action: 'spawn', host: event.host, sessionId: event.sessionId, at: event.at, proposedDepth, verdict, wouldBlock, blocked: due && !override, override, report, effectiveSpawns: reservation.effectiveSpawns, compute: null }, policy, meta, state);
229
231
  this.save(meta, state);
230
232
  return decision;
231
233
  });
@@ -269,8 +271,10 @@ class Gateway {
269
271
  // A low-confidence session (proxy without a session header) can be
270
272
  // stopped only on machine-scope grounds. Its session count is a guess.
271
273
  const machineStop = local.verdict === 'STOP';
272
- const blocked = wouldBlock && policy.mode === 'enforce' && (event.attribution === 'high' || machineStop);
273
- const decision = this.finish({ action: 'model_call', host: event.host, sessionId: event.sessionId, at: event.at, proposedDepth: null, verdict: report.verdict, wouldBlock, blocked, report, effectiveSpawns: state.spawnCount, compute }, policy, meta, state);
274
+ const due = wouldBlock && policy.mode === 'enforce' && (event.attribution === 'high' || machineStop);
275
+ const override = due ? (0, override_1.consumeOverride)(this.home, event.at) : null;
276
+ const blocked = due && !override;
277
+ const decision = this.finish({ action: 'model_call', host: event.host, sessionId: event.sessionId, at: event.at, proposedDepth: null, verdict: report.verdict, wouldBlock, blocked, override, report, effectiveSpawns: state.spawnCount, compute }, policy, meta, state);
274
278
  if (blocked) {
275
279
  // A denied call neither occupies the machine nor spends tokens.
276
280
  tx.finishCall(event.callId, windowMs(policy), event.at);
@@ -327,6 +331,11 @@ class Gateway {
327
331
  if (d.wouldBlock)
328
332
  meta.wouldBlock += 1;
329
333
  const decisionId = (0, events_1.eventId)();
334
+ // Speak once per change, not once per spawn. A block or an override is
335
+ // always spoken; it is the event the user needs to see.
336
+ const signature = d.verdict === 'OK' ? '' : (0, pre_tool_use_1.findingSignature)(d.report);
337
+ const notify = d.blocked || d.override !== null || (signature !== '' && signature !== (meta.notified ?? ''));
338
+ meta.notified = signature;
330
339
  // Sign what matters: every spawn admission, and any call that is not OK.
331
340
  // Signing every OK call through a busy proxy would be thousands of
332
341
  // receipts an hour saying nothing.
@@ -367,6 +376,7 @@ class Gateway {
367
376
  verdict: d.verdict,
368
377
  wouldDeny: d.wouldBlock,
369
378
  enforced: d.blocked,
379
+ overridden: d.override ?? undefined,
370
380
  mode: policy.mode,
371
381
  findings: d.report.findings.map((f) => ({ detector: f.detector, verdict: f.verdict, observed: f.observed, threshold: f.threshold })),
372
382
  effectiveSpawns: d.effectiveSpawns,
@@ -380,6 +390,8 @@ class Gateway {
380
390
  verdict: d.verdict,
381
391
  wouldBlock: d.wouldBlock,
382
392
  blocked: d.blocked,
393
+ overridden: d.override,
394
+ notify,
383
395
  mode: policy.mode,
384
396
  report: d.report,
385
397
  capabilities: meta.capabilities,
@@ -407,6 +419,8 @@ class Gateway {
407
419
  verdict: 'STOP',
408
420
  wouldBlock: true,
409
421
  blocked: policy.mode === 'enforce',
422
+ overridden: null,
423
+ notify: true,
410
424
  mode: policy.mode,
411
425
  report,
412
426
  capabilities: events_1.CAPABILITIES[event.host],
@@ -15,7 +15,7 @@
15
15
  * as little as possible.
16
16
  */
17
17
  import { type ReaderCursor } from '../history/claude-transcript';
18
- import type { Policy, SessionState } from '../types';
18
+ import type { BurnReport, Policy, SessionState } from '../types';
19
19
  export interface HookInput {
20
20
  session_id?: string;
21
21
  transcript_path?: string;
@@ -35,12 +35,36 @@ export interface HookOutput {
35
35
  permissionDecisionReason: string;
36
36
  };
37
37
  }
38
+ export interface PersistedSession {
39
+ cursor: {
40
+ offset: number;
41
+ size: number;
42
+ malformedLines: number;
43
+ depthByUuid: [string, number][];
44
+ };
45
+ state: Omit<SessionState, 'tokensByActiveMinute' | 'spawnsByActiveMinute' | 'surfaceReaders'> & {
46
+ tokensByActiveMinute: [number, number][];
47
+ spawnsByActiveMinute: [number, number][];
48
+ surfaceReaders: [string, number[]][];
49
+ };
50
+ /** Signature of the last finding set the user was told about. */
51
+ notified?: string;
52
+ }
53
+ /** Inflate a persisted Claude Code session. Shared with status. */
54
+ export declare function inflateHookSession(raw: PersistedSession): SessionState;
55
+ /**
56
+ * Tell the user once per change, not once per spawn. Eighteen identical
57
+ * "WARN: 30 spawns" banners in a row train the user to stop reading them,
58
+ * and the one that matters is the nineteenth.
59
+ */
60
+ export declare function findingSignature(report: BurnReport): string;
38
61
  export declare function loadPolicy(home: string): Policy;
39
62
  /** Refresh session state from the transcript. Cheap: only new bytes are read. */
40
63
  export declare function refreshSession(home: string, sessionId: string, transcriptPath: string): {
41
64
  cursor: ReaderCursor;
42
65
  state: SessionState;
43
66
  newSpawns: number;
67
+ notified: string;
44
68
  };
45
69
  export declare function handlePreToolUse(input: HookInput, home: string, now?: number): HookOutput;
46
70
  /** The settings.json fragment users paste in. Only PreToolUse can block. */
@@ -16,6 +16,8 @@
16
16
  * as little as possible.
17
17
  */
18
18
  Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.inflateHookSession = inflateHookSession;
20
+ exports.findingSignature = findingSignature;
19
21
  exports.loadPolicy = loadPolicy;
20
22
  exports.refreshSession = refreshSession;
21
23
  exports.handlePreToolUse = handlePreToolUse;
@@ -27,28 +29,32 @@ const claude_transcript_1 = require("../history/claude-transcript");
27
29
  const reservations_1 = require("../state/reservations");
28
30
  const session_1 = require("../state/session");
29
31
  const defaults_1 = require("../defaults");
32
+ const override_1 = require("../override");
30
33
  const render_1 = require("../replay/render");
31
34
  const SPAWN_TOOLS = new Set(['Agent', 'Task']);
32
35
  function sessionFile(home, sessionId) {
33
36
  return (0, node_path_1.join)(home, 'sessions', `${sessionId.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`);
34
37
  }
38
+ /** Inflate a persisted Claude Code session. Shared with status. */
39
+ function inflateHookSession(raw) {
40
+ return {
41
+ ...raw.state,
42
+ tokensByActiveMinute: new Map(raw.state.tokensByActiveMinute),
43
+ spawnsByActiveMinute: new Map(raw.state.spawnsByActiveMinute),
44
+ surfaceReaders: new Map(raw.state.surfaceReaders.map(([k, v]) => [k, new Set(v)])),
45
+ };
46
+ }
35
47
  function loadSession(home, sessionId, firstEventAt) {
36
48
  try {
37
49
  const raw = JSON.parse((0, node_fs_1.readFileSync)(sessionFile(home, sessionId), 'utf8'));
38
50
  const cursor = { ...raw.cursor, depthByUuid: new Map(raw.cursor.depthByUuid) };
39
- const state = {
40
- ...raw.state,
41
- tokensByActiveMinute: new Map(raw.state.tokensByActiveMinute),
42
- spawnsByActiveMinute: new Map(raw.state.spawnsByActiveMinute),
43
- surfaceReaders: new Map(raw.state.surfaceReaders.map(([k, v]) => [k, new Set(v)])),
44
- };
45
- return { cursor, state };
51
+ return { cursor, state: inflateHookSession(raw), notified: raw.notified ?? '' };
46
52
  }
47
53
  catch {
48
- return { cursor: (0, claude_transcript_1.newCursor)(), state: (0, session_1.newSessionState)(sessionId, firstEventAt) };
54
+ return { cursor: (0, claude_transcript_1.newCursor)(), state: (0, session_1.newSessionState)(sessionId, firstEventAt), notified: '' };
49
55
  }
50
56
  }
51
- function saveSession(home, cursor, state) {
57
+ function saveSession(home, cursor, state, notified) {
52
58
  (0, node_fs_1.mkdirSync)((0, node_path_1.join)(home, 'sessions'), { recursive: true, mode: 0o700 });
53
59
  const persisted = {
54
60
  cursor: { offset: cursor.offset, size: cursor.size, malformedLines: cursor.malformedLines, depthByUuid: [...cursor.depthByUuid] },
@@ -58,12 +64,24 @@ function saveSession(home, cursor, state) {
58
64
  spawnsByActiveMinute: [...state.spawnsByActiveMinute],
59
65
  surfaceReaders: [...state.surfaceReaders].map(([k, v]) => [k, [...v]]),
60
66
  },
67
+ notified,
61
68
  };
62
69
  const file = sessionFile(home, state.sessionId);
63
70
  (0, node_fs_1.writeFileSync)(`${file}.tmp`, JSON.stringify(persisted), { mode: 0o600 });
64
71
  // rename is atomic; a concurrent reader never sees a half-written file.
65
72
  require('node:fs').renameSync(`${file}.tmp`, file);
66
73
  }
74
+ /**
75
+ * Tell the user once per change, not once per spawn. Eighteen identical
76
+ * "WARN: 30 spawns" banners in a row train the user to stop reading them,
77
+ * and the one that matters is the nineteenth.
78
+ */
79
+ function findingSignature(report) {
80
+ return report.findings
81
+ .map((f) => `${f.detector}:${f.verdict}`)
82
+ .sort()
83
+ .join('|');
84
+ }
67
85
  function loadPolicy(home) {
68
86
  try {
69
87
  const parsed = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(home, 'burn-policy.json'), 'utf8'));
@@ -81,15 +99,27 @@ function recordDecision(home, entry) {
81
99
  }
82
100
  /** Refresh session state from the transcript. Cheap: only new bytes are read. */
83
101
  function refreshSession(home, sessionId, transcriptPath) {
84
- const { cursor, state } = loadSession(home, sessionId, Date.now());
102
+ const { cursor, state, notified } = loadSession(home, sessionId, Date.now());
85
103
  const before = state.spawnCount;
86
104
  const events = (0, claude_transcript_1.readIncremental)(transcriptPath, cursor);
87
105
  for (const event of events)
88
106
  (0, session_1.applyEvent)(state, event);
89
107
  if (events.length > 0 && state.startedAt > events[0].at)
90
108
  state.startedAt = events[0].at;
91
- saveSession(home, cursor, state);
92
- return { cursor, state, newSpawns: state.spawnCount - before };
109
+ saveSession(home, cursor, state, notified);
110
+ return { cursor, state, newSpawns: state.spawnCount - before, notified };
111
+ }
112
+ function rememberNotified(home, sessionId, signature) {
113
+ try {
114
+ const raw = JSON.parse((0, node_fs_1.readFileSync)(sessionFile(home, sessionId), 'utf8'));
115
+ raw.notified = signature;
116
+ const file = sessionFile(home, sessionId);
117
+ (0, node_fs_1.writeFileSync)(`${file}.tmp`, JSON.stringify(raw), { mode: 0o600 });
118
+ require('node:fs').renameSync(`${file}.tmp`, file);
119
+ }
120
+ catch {
121
+ /* best effort; the worst case is one repeated banner */
122
+ }
93
123
  }
94
124
  function handlePreToolUse(input, home, now = Date.now()) {
95
125
  const toolName = input.tool_name ?? '';
@@ -100,8 +130,11 @@ function handlePreToolUse(input, home, now = Date.now()) {
100
130
  const store = new reservations_1.ReservationStore(home);
101
131
  let report;
102
132
  let reservation;
133
+ let notified = '';
103
134
  try {
104
- const { state, newSpawns } = refreshSession(home, input.session_id, input.transcript_path);
135
+ const refreshed = refreshSession(home, input.session_id, input.transcript_path);
136
+ const { state, newSpawns } = refreshed;
137
+ notified = refreshed.notified;
105
138
  if (newSpawns > 0)
106
139
  store.reconcile(input.session_id, state.spawnCount, state.spawnCount - newSpawns);
107
140
  // Depth of the proposed child: the issuing agent's depth plus one. A hook
@@ -124,26 +157,41 @@ function handlePreToolUse(input, home, now = Date.now()) {
124
157
  }
125
158
  const shouldDeny = report.verdict === 'STOP' || !reservation.allowed;
126
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;
127
162
  recordDecision(home, {
128
163
  at: now,
129
164
  sessionId: input.session_id,
130
165
  toolUseId: input.tool_use_id ?? null,
131
166
  verdict: report.verdict,
132
167
  wouldDeny: shouldDeny,
133
- enforced: policy.mode === 'enforce' && shouldDeny,
168
+ enforced: policy.mode === 'enforce' && shouldDeny && !override,
169
+ overridden: override ? { once: override.once, reason: override.reason } : undefined,
134
170
  mode: policy.mode,
135
171
  findings: report.findings.map((f) => ({ detector: f.detector, verdict: f.verdict, observed: f.observed, threshold: f.threshold })),
136
172
  effectiveSpawns: reservation.effectiveSpawns,
137
173
  totals: report.totals,
138
174
  });
139
- if (shouldDeny && policy.mode === 'enforce')
140
- return deny(reason);
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
+ }
141
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);
142
188
  return {
143
189
  continue: true,
144
190
  systemMessage: `AgentGuard ${report.verdict}${policy.mode === 'shadow' && shouldDeny ? ' (shadow: would have blocked)' : ''}: ${report.findings[0]?.summary ?? ''}`,
145
191
  };
146
192
  }
193
+ if (notified)
194
+ rememberNotified(home, input.session_id, '');
147
195
  return { continue: true, suppressOutput: true };
148
196
  }
149
197
  function buildDenyReason(report, _reservation) {
@@ -19,3 +19,7 @@ export { startProxy, profileFor, type ProxyOptions, type ProxyHost, type Running
19
19
  export { UsageObserver, type ProxyProfile, type ObservedUsage } from './proxy/usage-observer';
20
20
  export { renderMachineStatus } from './status';
21
21
  export { runConformance, type ConformanceResult } from './conformance';
22
+ export { writeOverride, readOverride, consumeOverride, clearOverride, OVERRIDE_WINDOW_MS, type Override } from './override';
23
+ export { install, health, configPath, type InstallHost, type InstallResult, type HostHealth } from './install';
24
+ export { readHookSessions, renderHostHealth } from './status';
25
+ export { findingSignature, inflateHookSession, type PersistedSession } from './hook/pre-tool-use';
package/dist/src/index.js CHANGED
@@ -15,7 +15,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  exports.UsageObserver = exports.profileFor = exports.startProxy = exports.CODEX_FORBIDDEN_FIELDS = exports.readCodexTranscriptUsage = exports.parseCodexTranscript = exports.codexHooksSnippet = exports.handleCodexHook = exports.cursorHooksSnippet = exports.handleCursorHook = exports.BurnStopError = exports.createRawApiGuard = exports.sha256 = exports.canonical = exports.receiptDigest = exports.verifyReceipt = exports.ReceiptSigner = exports.mergeCapabilities = exports.Gateway = exports.settingsSnippet = exports.loadPolicy = exports.refreshSession = exports.handlePreToolUse = exports.calibrate = exports.renderStop = exports.renderReplay = exports.discoverTranscripts = exports.replayEvents = exports.replaySession = exports.replayAll = exports.RESERVATION_TTL_MS = exports.Transaction = exports.ReservationStore = exports.cacheReadRatio = exports.medianCompletedMinute = exports.windowSum = exports.newSessionState = exports.applyCorrection = exports.applyEvent = exports.normaliseLine = exports.newCursor = exports.readAll = exports.readIncremental = exports.evaluateLocalCompute = exports.fmt = exports.evaluate = exports.CALL_RESERVATION_TTL_MS = exports.DEFAULT_LOCAL_COMPUTE = exports.DEFAULT_THRESHOLDS = exports.DEFAULT_POLICY = void 0;
18
- exports.runConformance = exports.renderMachineStatus = void 0;
18
+ exports.inflateHookSession = exports.findingSignature = exports.renderHostHealth = exports.readHookSessions = exports.configPath = exports.health = exports.install = exports.OVERRIDE_WINDOW_MS = exports.clearOverride = exports.consumeOverride = exports.readOverride = exports.writeOverride = exports.runConformance = exports.renderMachineStatus = void 0;
19
19
  __exportStar(require("./types"), exports);
20
20
  var defaults_1 = require("./defaults");
21
21
  Object.defineProperty(exports, "DEFAULT_POLICY", { enumerable: true, get: function () { return defaults_1.DEFAULT_POLICY; } });
@@ -90,3 +90,19 @@ var status_1 = require("./status");
90
90
  Object.defineProperty(exports, "renderMachineStatus", { enumerable: true, get: function () { return status_1.renderMachineStatus; } });
91
91
  var conformance_1 = require("./conformance");
92
92
  Object.defineProperty(exports, "runConformance", { enumerable: true, get: function () { return conformance_1.runConformance; } });
93
+ var override_1 = require("./override");
94
+ Object.defineProperty(exports, "writeOverride", { enumerable: true, get: function () { return override_1.writeOverride; } });
95
+ Object.defineProperty(exports, "readOverride", { enumerable: true, get: function () { return override_1.readOverride; } });
96
+ Object.defineProperty(exports, "consumeOverride", { enumerable: true, get: function () { return override_1.consumeOverride; } });
97
+ Object.defineProperty(exports, "clearOverride", { enumerable: true, get: function () { return override_1.clearOverride; } });
98
+ Object.defineProperty(exports, "OVERRIDE_WINDOW_MS", { enumerable: true, get: function () { return override_1.OVERRIDE_WINDOW_MS; } });
99
+ var install_1 = require("./install");
100
+ Object.defineProperty(exports, "install", { enumerable: true, get: function () { return install_1.install; } });
101
+ Object.defineProperty(exports, "health", { enumerable: true, get: function () { return install_1.health; } });
102
+ Object.defineProperty(exports, "configPath", { enumerable: true, get: function () { return install_1.configPath; } });
103
+ var status_2 = require("./status");
104
+ Object.defineProperty(exports, "readHookSessions", { enumerable: true, get: function () { return status_2.readHookSessions; } });
105
+ Object.defineProperty(exports, "renderHostHealth", { enumerable: true, get: function () { return status_2.renderHostHealth; } });
106
+ var pre_tool_use_2 = require("./hook/pre-tool-use");
107
+ Object.defineProperty(exports, "findingSignature", { enumerable: true, get: function () { return pre_tool_use_2.findingSignature; } });
108
+ Object.defineProperty(exports, "inflateHookSession", { enumerable: true, get: function () { return pre_tool_use_2.inflateHookSession; } });
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Installing the hook into each host's config, and checking it is there.
3
+ *
4
+ * Merging into a config file the user also edits by hand is the one place
5
+ * this tool can do damage, so: always a timestamped backup first, always a
6
+ * parse-before-write, always an atomic rename, and the AgentGuard entry is
7
+ * found by its command (any command mentioning agentguard-burn), so a
8
+ * re-run upgrades the path in place instead of stacking duplicates.
9
+ */
10
+ export type InstallHost = 'claude' | 'cursor' | 'codex';
11
+ export interface InstallResult {
12
+ file: string;
13
+ backup: string | null;
14
+ changed: boolean;
15
+ command: string;
16
+ }
17
+ export interface HostHealth {
18
+ host: InstallHost;
19
+ file: string;
20
+ installed: boolean;
21
+ command: string | null;
22
+ /** The script the command points at exists on disk. */
23
+ commandExists: boolean;
24
+ /** The host itself is present (its config dir or binary). */
25
+ hostPresent: boolean;
26
+ }
27
+ export declare function configPath(host: InstallHost, home?: string): string;
28
+ export declare function install(host: InstallHost, cliPath: string, home?: string): InstallResult;
29
+ export declare function health(host: InstallHost, home?: string): HostHealth;
@@ -0,0 +1,145 @@
1
+ "use strict";
2
+ /**
3
+ * Installing the hook into each host's config, and checking it is there.
4
+ *
5
+ * Merging into a config file the user also edits by hand is the one place
6
+ * this tool can do damage, so: always a timestamped backup first, always a
7
+ * parse-before-write, always an atomic rename, and the AgentGuard entry is
8
+ * found by its command (any command mentioning agentguard-burn), so a
9
+ * re-run upgrades the path in place instead of stacking duplicates.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.configPath = configPath;
13
+ exports.install = install;
14
+ exports.health = health;
15
+ const node_fs_1 = require("node:fs");
16
+ const node_os_1 = require("node:os");
17
+ const node_path_1 = require("node:path");
18
+ const MARK = /agentguard-burn|agentguard-run\/burn/;
19
+ function configPath(host, home = (0, node_os_1.homedir)()) {
20
+ return host === 'claude' ? (0, node_path_1.join)(home, '.claude', 'settings.json') : host === 'cursor' ? (0, node_path_1.join)(home, '.cursor', 'hooks.json') : (0, node_path_1.join)(home, '.codex', 'hooks.json');
21
+ }
22
+ function readJson(file) {
23
+ if (!(0, node_fs_1.existsSync)(file))
24
+ return {};
25
+ const text = (0, node_fs_1.readFileSync)(file, 'utf8');
26
+ if (!text.trim())
27
+ return {};
28
+ const parsed = JSON.parse(text); // throws on a corrupt file: we refuse to overwrite what we cannot read
29
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
30
+ throw new Error(`${file} is not a JSON object`);
31
+ return parsed;
32
+ }
33
+ function writeJson(file, value) {
34
+ (0, node_fs_1.mkdirSync)((0, node_path_1.join)(file, '..'), { recursive: true });
35
+ let backup = null;
36
+ if ((0, node_fs_1.existsSync)(file)) {
37
+ backup = `${file}.bak-${new Date().toISOString().replace(/[:.]/g, '').slice(0, 15)}`;
38
+ (0, node_fs_1.copyFileSync)(file, backup);
39
+ }
40
+ const tmp = `${file}.agentguard.tmp`;
41
+ (0, node_fs_1.writeFileSync)(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
42
+ (0, node_fs_1.renameSync)(tmp, file);
43
+ return backup;
44
+ }
45
+ /** Claude Code and Codex share the { hooks: { Event: [ { matcher, hooks: [ {command} ] } ] } } shape. */
46
+ function mergeMatcherStyle(cfg, event, matcher, command, timeout) {
47
+ const hooks = (cfg.hooks ??= {});
48
+ const list = (hooks[event] ??= []);
49
+ let changed = false;
50
+ let found = false;
51
+ for (const m of list) {
52
+ for (const h of m.hooks ?? []) {
53
+ if (typeof h.command === 'string' && MARK.test(h.command)) {
54
+ found = true;
55
+ if (h.command !== command || m.matcher !== matcher || h.timeout !== timeout) {
56
+ h.command = command;
57
+ h.timeout = timeout;
58
+ m.matcher = matcher;
59
+ changed = true;
60
+ }
61
+ }
62
+ }
63
+ }
64
+ if (!found) {
65
+ list.push({ matcher, hooks: [{ type: 'command', command, timeout }] });
66
+ changed = true;
67
+ }
68
+ return changed;
69
+ }
70
+ /** Cursor: { version: 1, hooks: { subagentStart: [ {command, timeout, failClosed} ] } } */
71
+ function mergeCursor(cfg, command) {
72
+ let changed = false;
73
+ if (cfg.version !== 1) {
74
+ cfg.version = 1;
75
+ changed = true;
76
+ }
77
+ const hooks = (cfg.hooks ??= {});
78
+ const want = {
79
+ subagentStart: { command, timeout: 5, failClosed: true },
80
+ subagentStop: { command, timeout: 5 },
81
+ sessionEnd: { command, timeout: 3 },
82
+ };
83
+ for (const [event, entry] of Object.entries(want)) {
84
+ const list = (hooks[event] ??= []);
85
+ const mine = list.find((h) => typeof h.command === 'string' && MARK.test(h.command));
86
+ if (!mine) {
87
+ list.push(entry);
88
+ changed = true;
89
+ }
90
+ else if (JSON.stringify(mine) !== JSON.stringify({ ...mine, ...entry })) {
91
+ Object.assign(mine, entry);
92
+ changed = true;
93
+ }
94
+ }
95
+ return changed;
96
+ }
97
+ function install(host, cliPath, home = (0, node_os_1.homedir)()) {
98
+ const file = configPath(host, home);
99
+ const cfg = readJson(file);
100
+ let command;
101
+ let changed;
102
+ if (host === 'claude') {
103
+ command = `node ${cliPath} hook`;
104
+ changed = mergeMatcherStyle(cfg, 'PreToolUse', '^(Agent|Task)$', command, 5);
105
+ }
106
+ else if (host === 'codex') {
107
+ command = `node ${cliPath} codex-hook`;
108
+ changed = mergeMatcherStyle(cfg, 'PreToolUse', '^(Agent|spawn_agent)$', command, 5);
109
+ }
110
+ else {
111
+ command = `node ${cliPath} cursor-hook`;
112
+ changed = mergeCursor(cfg, command);
113
+ }
114
+ const backup = changed ? writeJson(file, cfg) : null;
115
+ return { file, backup, changed, command };
116
+ }
117
+ function findCommand(cfg) {
118
+ const hooks = cfg.hooks;
119
+ if (!hooks || typeof hooks !== 'object')
120
+ return null;
121
+ for (const list of Object.values(hooks)) {
122
+ if (!Array.isArray(list))
123
+ continue;
124
+ for (const item of list) {
125
+ const entries = Array.isArray(item.hooks) ? item.hooks : [item];
126
+ for (const h of entries)
127
+ if (typeof h.command === 'string' && MARK.test(h.command))
128
+ return h.command;
129
+ }
130
+ }
131
+ return null;
132
+ }
133
+ function health(host, home = (0, node_os_1.homedir)()) {
134
+ const file = configPath(host, home);
135
+ let command = null;
136
+ try {
137
+ command = findCommand(readJson(file));
138
+ }
139
+ catch {
140
+ command = null;
141
+ }
142
+ const script = command?.match(/^node (\S+)/)?.[1] ?? null;
143
+ const hostPresent = host === 'claude' ? (0, node_fs_1.existsSync)((0, node_path_1.join)(home, '.claude')) : host === 'cursor' ? (0, node_fs_1.existsSync)((0, node_path_1.join)(home, '.cursor')) : (0, node_fs_1.existsSync)((0, node_path_1.join)(home, '.codex'));
144
+ return { host, file, installed: command !== null, command, commandExists: script !== null && (0, node_fs_1.existsSync)(script), hostPresent };
145
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The audited override.
3
+ *
4
+ * The STOP box says "override once: agentguard-burn resume --once". That
5
+ * has to be real, and it has to be single-use across every host on the
6
+ * machine: two hooks that both see the file must not both let a spawn
7
+ * through. Consumption is a rename, which is atomic; the loser of the race
8
+ * finds nothing to rename and blocks.
9
+ *
10
+ * Without --once the override is a window, 15 minutes by default, for the
11
+ * case where the user has decided the session is fine and does not want to
12
+ * type the command forty times. Every overridden STOP is written to the
13
+ * decisions ledger with the reason, so "why did this get through" always
14
+ * has an answer.
15
+ */
16
+ export interface Override {
17
+ at: number;
18
+ once: boolean;
19
+ reason: string;
20
+ /** Absent for --once. */
21
+ until?: number;
22
+ }
23
+ export declare const OVERRIDE_WINDOW_MS: number;
24
+ export declare function writeOverride(home: string, o: Override): void;
25
+ export declare function readOverride(home: string, now?: number): Override | null;
26
+ /**
27
+ * Called only when a STOP is about to be enforced. Returns the override to
28
+ * apply, or null. A --once override is consumed here and cannot be used
29
+ * twice, however many processes ask at the same moment.
30
+ */
31
+ export declare function consumeOverride(home: string, now?: number): Override | null;
32
+ export declare function clearOverride(home: string): void;
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ /**
3
+ * The audited override.
4
+ *
5
+ * The STOP box says "override once: agentguard-burn resume --once". That
6
+ * has to be real, and it has to be single-use across every host on the
7
+ * machine: two hooks that both see the file must not both let a spawn
8
+ * through. Consumption is a rename, which is atomic; the loser of the race
9
+ * finds nothing to rename and blocks.
10
+ *
11
+ * Without --once the override is a window, 15 minutes by default, for the
12
+ * case where the user has decided the session is fine and does not want to
13
+ * type the command forty times. Every overridden STOP is written to the
14
+ * decisions ledger with the reason, so "why did this get through" always
15
+ * has an answer.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.OVERRIDE_WINDOW_MS = void 0;
19
+ exports.writeOverride = writeOverride;
20
+ exports.readOverride = readOverride;
21
+ exports.consumeOverride = consumeOverride;
22
+ exports.clearOverride = clearOverride;
23
+ const node_fs_1 = require("node:fs");
24
+ const node_path_1 = require("node:path");
25
+ exports.OVERRIDE_WINDOW_MS = 15 * 60 * 1000;
26
+ function file(home) {
27
+ return (0, node_path_1.join)(home, 'override.json');
28
+ }
29
+ function writeOverride(home, o) {
30
+ (0, node_fs_1.mkdirSync)(home, { recursive: true, mode: 0o700 });
31
+ (0, node_fs_1.writeFileSync)(file(home), JSON.stringify(o), { mode: 0o600 });
32
+ }
33
+ function readOverride(home, now = Date.now()) {
34
+ try {
35
+ const o = JSON.parse((0, node_fs_1.readFileSync)(file(home), 'utf8'));
36
+ if (typeof o.reason !== 'string' || typeof o.at !== 'number')
37
+ return null;
38
+ if (!o.once && (o.until === undefined || o.until < now))
39
+ return null;
40
+ return o;
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ /**
47
+ * Called only when a STOP is about to be enforced. Returns the override to
48
+ * apply, or null. A --once override is consumed here and cannot be used
49
+ * twice, however many processes ask at the same moment.
50
+ */
51
+ function consumeOverride(home, now = Date.now()) {
52
+ const o = readOverride(home, now);
53
+ if (!o) {
54
+ if ((0, node_fs_1.existsSync)(file(home)))
55
+ (0, node_fs_1.rmSync)(file(home), { force: true }); // expired or malformed
56
+ return null;
57
+ }
58
+ if (!o.once)
59
+ return o;
60
+ const consumed = `${file(home)}.used.${process.pid}.${now.toString(36)}`;
61
+ try {
62
+ (0, node_fs_1.renameSync)(file(home), consumed);
63
+ }
64
+ catch {
65
+ return null; // a sibling consumed it first
66
+ }
67
+ (0, node_fs_1.rmSync)(consumed, { force: true });
68
+ return o;
69
+ }
70
+ function clearOverride(home) {
71
+ (0, node_fs_1.rmSync)(file(home), { force: true });
72
+ }
@@ -7,5 +7,13 @@
7
7
  * middleware and the proxy shows full coverage, because it has it.
8
8
  */
9
9
  import type { GatewaySessionView } from './gateway';
10
+ import type { HostHealth } from './install';
10
11
  import type { ComputeSnapshot } from './state/reservations';
12
+ /**
13
+ * Claude Code sessions live in the hook's own files (they carry a transcript
14
+ * cursor the gateway sessions do not). Read them into the same view so the
15
+ * table shows every host, not just the new ones.
16
+ */
17
+ export declare function readHookSessions(home: string): GatewaySessionView[];
18
+ export declare function renderHostHealth(hosts: HostHealth[]): string;
11
19
  export declare function renderMachineStatus(sessions: GatewaySessionView[], compute: ComputeSnapshot, now?: number): string;
@@ -8,19 +8,79 @@
8
8
  * middleware and the proxy shows full coverage, because it has it.
9
9
  */
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.readHookSessions = readHookSessions;
12
+ exports.renderHostHealth = renderHostHealth;
11
13
  exports.renderMachineStatus = renderMachineStatus;
14
+ const node_fs_1 = require("node:fs");
15
+ const node_path_1 = require("node:path");
12
16
  const evaluate_1 = require("./detectors/evaluate");
17
+ const events_1 = require("./events");
18
+ const pre_tool_use_1 = require("./hook/pre-tool-use");
13
19
  const LIVE_WINDOW_MS = 30 * 60 * 1000;
20
+ /**
21
+ * Claude Code sessions live in the hook's own files (they carry a transcript
22
+ * cursor the gateway sessions do not). Read them into the same view so the
23
+ * table shows every host, not just the new ones.
24
+ */
25
+ function readHookSessions(home) {
26
+ const dir = (0, node_path_1.join)(home, 'sessions');
27
+ const out = [];
28
+ let names = [];
29
+ try {
30
+ names = (0, node_fs_1.readdirSync)(dir).filter((n) => n.endsWith('.json') && !n.startsWith('gw-'));
31
+ }
32
+ catch {
33
+ return out;
34
+ }
35
+ for (const name of names) {
36
+ try {
37
+ const raw = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(dir, name), 'utf8'));
38
+ if (!raw.cursor || !raw.state)
39
+ continue;
40
+ const state = (0, pre_tool_use_1.inflateHookSession)(raw);
41
+ out.push({
42
+ sessionId: state.sessionId,
43
+ hosts: ['claude-code'],
44
+ capabilities: events_1.CAPABILITIES['claude-code'],
45
+ state,
46
+ liveSpawns: 0,
47
+ usage: { authoritative: state.totalTokens > 0 ? 1 : 0, estimated: 0, missing: 0 },
48
+ decisions: 0,
49
+ wouldBlock: 0,
50
+ closedAt: null,
51
+ });
52
+ }
53
+ catch {
54
+ /* half-written or foreign file: skip */
55
+ }
56
+ }
57
+ return out;
58
+ }
59
+ function renderHostHealth(hosts) {
60
+ const lines = ['hosts:'];
61
+ for (const h of hosts) {
62
+ const name = h.host === 'claude' ? 'claude-code' : h.host;
63
+ let state;
64
+ if (!h.installed)
65
+ state = h.hostPresent ? `not installed (agentguard-burn init ${h.host} --write)` : 'not present on this machine';
66
+ else if (!h.commandExists)
67
+ state = `INSTALLED BUT BROKEN: hook command not found on disk, host will fail open (re-run: agentguard-burn init ${h.host} --write)`;
68
+ else
69
+ state = `installed, hook command ok (${h.file.replace(process.env.HOME ?? '', '~')})`;
70
+ lines.push(` ${name.padEnd(13)} ${state}`);
71
+ }
72
+ return lines.join('\n');
73
+ }
14
74
  function renderMachineStatus(sessions, compute, now = Date.now()) {
15
- const live = sessions.filter((s) => s.closedAt === null && now - s.state.lastEventAt <= LIVE_WINDOW_MS);
75
+ const live = sessions.filter((s) => s.closedAt === null && now - s.state.lastEventAt <= LIVE_WINDOW_MS).sort((a, b) => b.state.lastEventAt - a.state.lastEventAt);
16
76
  const totalTokens = live.reduce((n, s) => n + s.state.totalTokens, 0);
17
77
  const lines = [];
18
- lines.push(`cross-tool sessions: ${live.length} live · ${(0, evaluate_1.fmt)(totalTokens)} tokens · ${compute.inFlight} model call(s) in flight · ${Math.round(compute.occupiedMs / 1000)}s occupied in last ${Math.round(compute.windowMs / 60000)} min`);
78
+ lines.push(`sessions: ${live.length} active in the last 30 min · ${(0, evaluate_1.fmt)(totalTokens)} tokens · ${compute.inFlight} model call(s) in flight · ${Math.round(compute.occupiedMs / 1000)}s occupied in last ${Math.round(compute.windowMs / 60000)} min`);
19
79
  if (live.length === 0) {
20
- lines.push(' (none in the last 30 minutes; Claude Code sessions are tracked by the hook and listed by `replay`)');
80
+ lines.push(' (none)');
21
81
  return lines.join('\n');
22
82
  }
23
- lines.push(' ' + pad('HOST(S)', 22) + pad('SESSION', 20) + pad('TOKENS', 9) + pad('SPAWNS', 8) + pad('DEPTH', 7) + pad('LIVE', 6) + 'COVERAGE');
83
+ lines.push(' ' + pad('HOST(S)', 22) + pad('SESSION', 20) + pad('TOKENS', 9) + pad('SPAWNS', 8) + pad('DEPTH', 7) + pad('LAST', 8) + 'COVERAGE');
24
84
  for (const s of live) {
25
85
  lines.push(' ' +
26
86
  pad(s.hosts.join('+'), 22) +
@@ -28,11 +88,15 @@ function renderMachineStatus(sessions, compute, now = Date.now()) {
28
88
  pad((0, evaluate_1.fmt)(s.state.totalTokens), 9) +
29
89
  pad(String(s.state.spawnCount), 8) +
30
90
  pad(String(s.state.maxDepth), 7) +
31
- pad(String(s.liveSpawns), 6) +
91
+ pad(ago(now - s.state.lastEventAt), 8) +
32
92
  coverageLine(s));
33
93
  }
34
94
  return lines.join('\n');
35
95
  }
96
+ function ago(ms) {
97
+ const m = Math.floor(ms / 60000);
98
+ return m < 1 ? 'now' : `${m}m ago`;
99
+ }
36
100
  function coverageLine(s) {
37
101
  const usage = s.usage.missing > 0 ? `usage:partial(${s.usage.missing} missing)` : s.usage.authoritative > 0 ? 'usage:auth' : s.usage.estimated > 0 ? 'usage:est' : `usage:${short(s.capabilities.usage)}`;
38
102
  return `spawns:${short(s.capabilities.spawns)} depth:${short(s.capabilities.depth)} ${usage}`;
@@ -0,0 +1,49 @@
1
+ {
2
+ "captured_from": "codex-cli 0.151.0, 2026-09-03, codex exec with -c hooks.PreToolUse inline; --dangerously-bypass-hook-trust",
3
+ "payloads": [
4
+ {
5
+ "session_id": "01a069d3-5fc8-7470-8c95-1b42e7206746",
6
+ "turn_id": "01a069d3-6018-7523-a26e-eea10123676c",
7
+ "transcript_path": "/Users/example/.codex/sessions/2026/09/03/rollout-example.jsonl",
8
+ "cwd": "/Users/example/project",
9
+ "hook_event_name": "PreToolUse",
10
+ "model": "gpt-5.4-mini",
11
+ "permission_mode": "bypassPermissions",
12
+ "tool_name": "Bash",
13
+ "tool_input": {
14
+ "command": "echo CANARY_OK"
15
+ },
16
+ "tool_use_id": "call_NmxrZob0I2KFC8ChNe2PduGX"
17
+ },
18
+ {
19
+ "session_id": "01a069d4-d356-73b3-9440-1c4440319de8",
20
+ "turn_id": "01a069d4-d3b2-7593-85ec-8a0281f51bf1",
21
+ "transcript_path": "/Users/example/.codex/sessions/2026/09/03/rollout-example.jsonl",
22
+ "cwd": "/Users/example/project",
23
+ "hook_event_name": "PreToolUse",
24
+ "model": "gpt-5.4-mini",
25
+ "permission_mode": "bypassPermissions",
26
+ "tool_name": "spawn_agent",
27
+ "tool_input": {
28
+ "message": "<redacted>",
29
+ "fork_context": "<redacted>"
30
+ },
31
+ "tool_use_id": "call_oktUPQ5jcosNPOcGnEaWqRcv"
32
+ },
33
+ {
34
+ "session_id": "01a069d4-d356-73b3-9440-1c4440319de8",
35
+ "turn_id": "01a069d4-d3b2-7593-85ec-8a0281f51bf1",
36
+ "transcript_path": "/Users/example/.codex/sessions/2026/09/03/rollout-example.jsonl",
37
+ "cwd": "/Users/example/project",
38
+ "hook_event_name": "PreToolUse",
39
+ "model": "gpt-5.4-mini",
40
+ "permission_mode": "bypassPermissions",
41
+ "tool_name": "multi_agent_v1wait_agent",
42
+ "tool_input": {
43
+ "targets": "<redacted>",
44
+ "timeout_ms": "<redacted>"
45
+ },
46
+ "tool_use_id": "call_RK95A6i4UUhTigIGXF24gMMO"
47
+ }
48
+ ]
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentguard-run/burn",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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",
@@ -34,6 +34,7 @@
34
34
  },
35
35
  "files": [
36
36
  "dist/src",
37
+ "fixtures",
37
38
  "README.md",
38
39
  "CHANGELOG.md",
39
40
  "LICENSE"