@agentguard-run/burn 0.1.1 → 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.
Files changed (45) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +145 -6
  3. package/dist/src/adapters/codex.d.ts +48 -0
  4. package/dist/src/adapters/codex.js +197 -0
  5. package/dist/src/adapters/cursor.d.ts +35 -0
  6. package/dist/src/adapters/cursor.js +135 -0
  7. package/dist/src/adapters/raw-api.d.ts +76 -0
  8. package/dist/src/adapters/raw-api.js +130 -0
  9. package/dist/src/cli.d.ts +7 -3
  10. package/dist/src/cli.js +141 -17
  11. package/dist/src/conformance.d.ts +26 -0
  12. package/dist/src/conformance.js +261 -0
  13. package/dist/src/defaults.d.ts +11 -0
  14. package/dist/src/defaults.js +16 -1
  15. package/dist/src/detectors/local-compute.d.ts +19 -0
  16. package/dist/src/detectors/local-compute.js +66 -0
  17. package/dist/src/events.d.ts +94 -0
  18. package/dist/src/events.js +47 -0
  19. package/dist/src/gateway.d.ts +141 -0
  20. package/dist/src/gateway.js +536 -0
  21. package/dist/src/hook/pre-tool-use.d.ts +25 -1
  22. package/dist/src/hook/pre-tool-use.js +64 -16
  23. package/dist/src/index.d.ts +19 -4
  24. package/dist/src/index.js +57 -1
  25. package/dist/src/install.d.ts +29 -0
  26. package/dist/src/install.js +145 -0
  27. package/dist/src/override.d.ts +32 -0
  28. package/dist/src/override.js +72 -0
  29. package/dist/src/proxy/server.d.ts +45 -0
  30. package/dist/src/proxy/server.js +169 -0
  31. package/dist/src/proxy/usage-observer.d.ts +40 -0
  32. package/dist/src/proxy/usage-observer.js +128 -0
  33. package/dist/src/receipt.d.ts +61 -0
  34. package/dist/src/receipt.js +98 -0
  35. package/dist/src/replay/render.d.ts +1 -0
  36. package/dist/src/replay/render.js +2 -1
  37. package/dist/src/state/reservations.d.ts +115 -11
  38. package/dist/src/state/reservations.js +293 -59
  39. package/dist/src/state/session.d.ts +6 -0
  40. package/dist/src/state/session.js +17 -0
  41. package/dist/src/status.d.ts +19 -0
  42. package/dist/src/status.js +112 -0
  43. package/dist/src/types.d.ts +14 -1
  44. package/fixtures/codex-0.151.0-pretooluse.json +49 -0
  45. package/package.json +34 -6
package/CHANGELOG.md ADDED
@@ -0,0 +1,93 @@
1
+ # Changelog
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
+
32
+ ## 0.2.0 (2026-09-03)
33
+
34
+ The cross-tool layer. One policy, one lock, one receipt across hosts.
35
+
36
+ ### Added
37
+ - Host-neutral `AgentEvent` vocabulary and a `Gateway` that folds events,
38
+ evaluates the existing detectors and decides, so adapters cannot drift.
39
+ - Raw middleware (`@agentguard-run/burn/middleware`): `beforeSpawn` /
40
+ `beforeCall` leases with stable IDs. The strongest position: it sees the
41
+ tree and the request.
42
+ - Loopback reverse proxy (`agentguard-burn proxy`) for Ollama, vLLM, LM
43
+ Studio and OpenAI-compatible servers. Streams before it inspects, honours
44
+ backpressure, reads final usage as a side channel, answers the next
45
+ request with 429 on STOP. Never truncates a stream in flight.
46
+ - Local-compute plane: concurrent in-flight calls and occupied request time,
47
+ WARN-only by default (4 concurrent). Operators set the STOP for their own
48
+ hardware.
49
+ - Cursor adapter (beta): `subagentStart` admission with the documented
50
+ `{ permission, user_message, agent_message }` output; depth derived from
51
+ the issuing subagent; `failClosed` in the generated snippet.
52
+ - Codex adapter (experimental): `PreToolUse` on `spawn_agent`, host-specific
53
+ output only. A test forbids `continue`, `stopReason`, `suppressOutput`,
54
+ `decision` and `reason` by name. Transcript usage is best-effort and
55
+ marked estimated.
56
+ - Content-free Ed25519 receipts on `node:crypto`, chained per session.
57
+ - Capability-aware `status` across every host, with coverage per plane.
58
+ - `agentguard-burn conformance`: the same storm and the same grind through
59
+ every adapter, asserting identical boundaries, plus the composite case.
60
+ - Usage is committed by call ID and replaces the reservation under it, in
61
+ either direction. Middleware and proxy never double count one call.
62
+
63
+ ### Fixed
64
+ - The reservation lock could be torn down by a waiter that judged a
65
+ previous, already-released instance as dead (TOCTOU under heavy
66
+ contention). Under 240 concurrent hook processes this admitted 41 to 46
67
+ spawns against a cap of 40. Lock instances now carry a nonce; reclaims
68
+ are verified against the instance judged; writes are fenced on the
69
+ holder's nonce. 192 runs at 240 concurrent processes admit exactly 40.
70
+ - A lock owner record could be read half-written and parsed as "held since
71
+ 1970". The record is now written atomically and malformed records are
72
+ never trusted.
73
+ - Lock removal was readdir + unlink + rmdir on the live path and could hit a
74
+ sibling's fresh directory (ENOTEMPTY crash mid-lock). Removal is now
75
+ rename-then-delete.
76
+ - The signing key is created atomically; concurrent first-run hooks no
77
+ longer race on `wx`.
78
+
79
+ ### Changed
80
+ - Dropped `@noble/ed25519` (unused, ESM-only). Zero runtime dependencies.
81
+ - `Thresholds.localCompute` added as an optional field; 0.1 policy files
82
+ load unchanged.
83
+
84
+ ### Unchanged
85
+ - The Claude Code hook, transcript reader, detectors and thresholds. The
86
+ existing 24 tests pass as they were.
87
+
88
+ ## 0.1.1 (2026-09-03)
89
+ - The artifact: block-digit hero, per-session sparklines with the STOP marked,
90
+ boxed STOP alarm.
91
+
92
+ ## 0.1.0 (2026-09-03)
93
+ - First publish. Two safety planes fitted on 412 real sessions.
package/README.md CHANGED
@@ -33,14 +33,144 @@ 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.
62
+
63
+ ## One policy across hosts (0.2.0)
64
+
65
+ The detectors never learn which host produced an event. Claude Code, Cursor,
66
+ Codex, a local model runtime behind the proxy, and an orchestrator calling
67
+ the middleware all normalise into the same event stream, share one
68
+ machine-wide reservation lock, and sign the same receipt. The same failure,
69
+ through every door, stops at the same step: `agentguard-burn conformance`
70
+ replays a 42-spawn storm and a 250M-token-per-call grind through each adapter
71
+ and asserts fan-out WARN at 24, STOP at 41, sustained WARN at 3.5B, STOP at
72
+ 5B.
73
+
74
+ What each host can actually see is stated, not implied:
75
+
76
+ | Host | Spawns | Depth | Usage | How |
77
+ |---|---|---|---|---|
78
+ | Claude Code | authoritative | authoritative | authoritative | PreToolUse hook + transcript (unchanged from 0.1) |
79
+ | Raw middleware | authoritative | authoritative | authoritative | `beforeSpawn` / `beforeCall` leases in your orchestrator |
80
+ | Ollama proxy | none | none | authoritative | `prompt_eval_count` + `eval_count` on the final chunk |
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 |
82
+ | Cursor (beta) | authoritative | estimated | none | native `subagentStart` deny; hosted-model usage is never exposed |
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 |
84
+
85
+ An `OK` from a host that cannot see usage is an OK about spawns, and `status`
86
+ says `usage:n/a` next to it. Missing usage never becomes a guessed zero.
87
+
88
+ The full claim, "40 spawns, depth 2, 5B tokens, enforced identically", is true
89
+ for a deployment that feeds both a topology source and a usage source into one
90
+ session ID: raw middleware plus the proxy, for instance. A proxy alone sees
91
+ tokens and no tree. A Cursor hook alone sees the tree and no tokens. The
92
+ composite conformance check proves the combined case: candidate spawn 41 sees
93
+ both planes in its findings.
94
+
95
+ ### Local models: the compute plane
96
+
97
+ Token dollars are close to meaningless when the GPU is yours. What runs away
98
+ is the machine: concurrency and occupied request time. The proxy tracks both
99
+ and warns at 4 concurrent calls by default. No universal STOP ships for
100
+ hardware we cannot see; set `localCompute.stopConcurrent` or
101
+ `stopOccupiedMs` in `burn-policy.json` for your server. Elapsed request time
102
+ includes queueing and transport, so it is called occupied time, never GPU
103
+ utilisation.
104
+
105
+ ```
106
+ agentguard-burn proxy --upstream http://127.0.0.1:11434 --host ollama
107
+ # point the agent at http://127.0.0.1:18080 and send x-agentguard-session: <id>
108
+ ```
109
+
110
+ Loopback only, both sides, by default. Every upstream chunk is written to the
111
+ client before it is inspected; the observer is a side channel, never a data
112
+ path. A STOP answers the *next* request with 429 and the alarm box. It never
113
+ cuts a stream that is already flowing, and it never kills a running agent.
114
+ Blocking is not killing.
115
+
116
+ ### Raw middleware
117
+
118
+ ```ts
119
+ import { createRawApiGuard } from '@agentguard-run/burn/middleware';
120
+ const burn = createRawApiGuard({ sessionId: 'nightly-refactor-17' });
121
+
122
+ const spawn = burn.beforeSpawn({ parentDepth: 0 });
123
+ spawn.throwIfBlocked();
124
+ spawn.started();
125
+ try { await worker() } finally { spawn.finished() }
126
+
127
+ const call = burn.beforeCall({ estimatedTokens: 120_000 });
128
+ call.throwIfBlocked();
129
+ try {
130
+ const res = await client.chat({ ..., headers: call.headers }); // proxy correlates by call id
131
+ call.complete({ tokens: res.usage.total_tokens });
132
+ } catch (e) { call.fail(); throw e }
133
+ ```
134
+
135
+ Usage is committed by call ID and *replaces* what was reserved under it.
136
+ When middleware estimated 120K and the proxy later saw 87K for the same call,
137
+ the session moves by 87K, not 207K.
138
+
139
+ ### Cursor and Codex
140
+
141
+ ```
142
+ agentguard-burn init cursor # ~/.cursor/hooks.json snippet, failClosed on
143
+ agentguard-burn init codex # ~/.codex/hooks.json snippet
144
+ ```
145
+
146
+ Both renderers are one page each and emit only their host's documented output
147
+ object. Codex fails the whole hook on Claude's common fields (`continue`,
148
+ `stopReason`, `suppressOutput`), and a failed hook is a fail-open hook, so the
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.
165
+
166
+ ### Receipts
167
+
168
+ Every spawn decision, and every model call that is not OK, is signed with a
169
+ local Ed25519 key (Node built-ins, key generated on first use, 0600) and
170
+ chained to the previous receipt for the session. A receipt carries the host,
171
+ the coverage, the counts, the verdict, the policy digest and a hash of the
172
+ session ID. It carries no prompt, completion, path, or tool input. It can
173
+ leave the machine when a transcript never can.
44
174
 
45
175
  ## The concurrency guarantee
46
176
 
@@ -48,7 +178,16 @@ Ten parallel `Agent` calls launch ten hook processes that all read the same
48
178
  transcript and all see the same count. A naive cap is cosmetic during exactly
49
179
  the burst it exists for. Spawns are admitted through an atomic, cross-process
50
180
  reservation under a machine-wide lock; the test suite launches 60 real OS
51
- processes against a cap of 40 and asserts exactly 40 are admitted.
181
+ processes against a cap of 40 and asserts exactly 40 are admitted, through
182
+ the Claude hook and again through the Cursor hook.
183
+
184
+ 0.2.0 fixed the lock itself. Under 240 concurrent hook processes the 0.1
185
+ lock could tear down a live sibling's lock (a waiter judged "owner is dead"
186
+ about an instance that had already been released and replaced) and admit
187
+ 41 to 46. Lock instances now carry a nonce; a reclaim only counts if it
188
+ 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.
52
191
 
53
192
  Single-machine by design. Two laptops on one account do not share state, and
54
193
  that is stated rather than hidden.
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Codex CLI hooks adapter.
3
+ *
4
+ * Codex gates tool calls through PreToolUse, and `spawn_agent` matches as
5
+ * `Agent` in the hook matcher, so the admission point is structurally the
6
+ * same as Claude Code's. The output schema is not: Codex accepts the nested
7
+ * hookSpecificOutput object and `systemMessage`, and FAILS on the Claude
8
+ * common fields `continue`, `stopReason` and `suppressOutput`. A failed hook
9
+ * is a fail-open hook, so this renderer never emits them. There is a test
10
+ * that forbids them by name.
11
+ *
12
+ * Verified against the Codex hooks docs (learn.chatgpt.com/docs/hooks,
13
+ * 2026-09-03):
14
+ * input session_id, transcript_path, cwd, hook_event_name, model,
15
+ * permission_mode, turn_id, tool_name, tool_use_id, tool_input
16
+ * output { hookSpecificOutput: { hookEventName: "PreToolUse",
17
+ * permissionDecision: "deny", permissionDecisionReason } }
18
+ * config ~/.codex/hooks.json, matcher regex on tool name; exit 2 = deny
19
+ *
20
+ * `transcript_path` is a locator, not a schema. Usage read from it is
21
+ * best-effort and marked `estimated`; unknown records lower coverage instead
22
+ * of becoming a silent zero. That is the honest version, and it is why Codex
23
+ * ships as experimental until an installed-version deny canary has passed.
24
+ */
25
+ import { type ModelUsageObserved } from '../events';
26
+ import type { Gateway } from '../gateway';
27
+ export interface CodexHookOutput {
28
+ systemMessage?: string;
29
+ hookSpecificOutput?: {
30
+ hookEventName: 'PreToolUse';
31
+ permissionDecision: 'allow' | 'deny';
32
+ permissionDecisionReason?: string;
33
+ };
34
+ }
35
+ /** Fields this renderer must never emit. Codex rejects the whole object if they appear. */
36
+ export declare const CODEX_FORBIDDEN_FIELDS: readonly ["continue", "stopReason", "suppressOutput", "decision", "reason"];
37
+ export declare function handleCodexHook(raw: unknown, gateway: Gateway, now?: number): CodexHookOutput;
38
+ /**
39
+ * Pull usage records out of whatever the Codex transcript turns out to be.
40
+ * Accepts the common shapes (`usage.input_tokens`, `usage.prompt_tokens`,
41
+ * nested under `response` or `message`). Event IDs are content digests, so
42
+ * re-reading the file on every hook call is idempotent at the gateway.
43
+ */
44
+ export declare function readCodexTranscriptUsage(path: string, sessionId: string, now: number): ModelUsageObserved[];
45
+ export declare function parseCodexTranscript(text: string, sessionId: string, now: number): ModelUsageObserved[];
46
+ /** The ~/.codex/hooks.json fragment. spawn_agent matches as Agent. */
47
+ export declare function codexHooksSnippet(command: string): Record<string, unknown>;
48
+ export declare const CODEX_SPAWN_TOOLS: Set<string>;
@@ -0,0 +1,197 @@
1
+ "use strict";
2
+ /**
3
+ * Codex CLI hooks adapter.
4
+ *
5
+ * Codex gates tool calls through PreToolUse, and `spawn_agent` matches as
6
+ * `Agent` in the hook matcher, so the admission point is structurally the
7
+ * same as Claude Code's. The output schema is not: Codex accepts the nested
8
+ * hookSpecificOutput object and `systemMessage`, and FAILS on the Claude
9
+ * common fields `continue`, `stopReason` and `suppressOutput`. A failed hook
10
+ * is a fail-open hook, so this renderer never emits them. There is a test
11
+ * that forbids them by name.
12
+ *
13
+ * Verified against the Codex hooks docs (learn.chatgpt.com/docs/hooks,
14
+ * 2026-09-03):
15
+ * input session_id, transcript_path, cwd, hook_event_name, model,
16
+ * permission_mode, turn_id, tool_name, tool_use_id, tool_input
17
+ * output { hookSpecificOutput: { hookEventName: "PreToolUse",
18
+ * permissionDecision: "deny", permissionDecisionReason } }
19
+ * config ~/.codex/hooks.json, matcher regex on tool name; exit 2 = deny
20
+ *
21
+ * `transcript_path` is a locator, not a schema. Usage read from it is
22
+ * best-effort and marked `estimated`; unknown records lower coverage instead
23
+ * of becoming a silent zero. That is the honest version, and it is why Codex
24
+ * ships as experimental until an installed-version deny canary has passed.
25
+ */
26
+ Object.defineProperty(exports, "__esModule", { value: true });
27
+ exports.CODEX_SPAWN_TOOLS = exports.CODEX_FORBIDDEN_FIELDS = void 0;
28
+ exports.handleCodexHook = handleCodexHook;
29
+ exports.readCodexTranscriptUsage = readCodexTranscriptUsage;
30
+ exports.parseCodexTranscript = parseCodexTranscript;
31
+ exports.codexHooksSnippet = codexHooksSnippet;
32
+ const node_crypto_1 = require("node:crypto");
33
+ const node_fs_1 = require("node:fs");
34
+ const events_1 = require("../events");
35
+ const render_1 = require("../replay/render");
36
+ /** Fields this renderer must never emit. Codex rejects the whole object if they appear. */
37
+ exports.CODEX_FORBIDDEN_FIELDS = ['continue', 'stopReason', 'suppressOutput', 'decision', 'reason'];
38
+ const HOST = 'codex';
39
+ const SPAWN_TOOLS = new Set(['Agent', 'spawn_agent', 'Task']);
40
+ function handleCodexHook(raw, gateway, now = Date.now()) {
41
+ const input = parse(raw);
42
+ if (!input)
43
+ return {};
44
+ // Usage from the transcript, if it is readable. Best effort, estimated.
45
+ if (input.transcriptPath) {
46
+ const events = readCodexTranscriptUsage(input.transcriptPath, input.sessionId, now);
47
+ if (events.length)
48
+ quietly(() => gateway.observe(events));
49
+ }
50
+ if (input.event !== 'PreToolUse' || !SPAWN_TOOLS.has(input.toolName))
51
+ return {};
52
+ const spawnId = input.toolUseId ?? `${input.sessionId}:${now}`;
53
+ const decision = gateway.beforeSpawn({
54
+ schemaVersion: 1,
55
+ kind: 'spawn_requested',
56
+ eventId: `codex:${spawnId}`,
57
+ host: HOST,
58
+ sessionId: input.sessionId,
59
+ at: now,
60
+ spawnId,
61
+ // Codex PreToolUse does not say which agent is calling. Depth is estimated.
62
+ proposedDepth: 1,
63
+ attribution: 'high',
64
+ });
65
+ if (decision.blocked) {
66
+ return {
67
+ hookSpecificOutput: {
68
+ hookEventName: 'PreToolUse',
69
+ permissionDecision: 'deny',
70
+ permissionDecisionReason: (0, render_1.renderStop)(decision.report, { colour: false }),
71
+ },
72
+ };
73
+ }
74
+ // Never crash after deciding: a crashed hook is a fail-open on most hosts.
75
+ // An unrecorded start leaves its reservation pending until TTL, which is
76
+ // the conservative direction.
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.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') {
82
+ return {
83
+ systemMessage: `AgentGuard ${decision.verdict}${decision.mode === 'shadow' && decision.wouldBlock ? ' (shadow: would have blocked)' : ''}: ${decision.report.findings[0]?.summary ?? ''}`,
84
+ };
85
+ }
86
+ return {};
87
+ }
88
+ function quietly(fn) {
89
+ try {
90
+ fn();
91
+ }
92
+ catch (error) {
93
+ process.stderr.write(`agentguard-burn: could not record observation: ${error instanceof Error ? error.message : String(error)}\n`);
94
+ }
95
+ }
96
+ function parse(value) {
97
+ if (!value || typeof value !== 'object' || Array.isArray(value))
98
+ return null;
99
+ const v = value;
100
+ const str = (k) => (typeof v[k] === 'string' && v[k].length > 0 ? v[k] : undefined);
101
+ const session = str('session_id');
102
+ return {
103
+ event: str('hook_event_name') ?? '',
104
+ sessionId: session && (0, events_1.isValidSessionId)(session) ? session : 'codex:unknown',
105
+ toolName: str('tool_name') ?? '',
106
+ toolUseId: str('tool_use_id'),
107
+ transcriptPath: str('transcript_path'),
108
+ };
109
+ }
110
+ // ---- transcript usage (best effort) ---------------------------------------
111
+ /**
112
+ * Pull usage records out of whatever the Codex transcript turns out to be.
113
+ * Accepts the common shapes (`usage.input_tokens`, `usage.prompt_tokens`,
114
+ * nested under `response` or `message`). Event IDs are content digests, so
115
+ * re-reading the file on every hook call is idempotent at the gateway.
116
+ */
117
+ function readCodexTranscriptUsage(path, sessionId, now) {
118
+ let text;
119
+ try {
120
+ text = (0, node_fs_1.readFileSync)(path, 'utf8');
121
+ }
122
+ catch {
123
+ return [];
124
+ }
125
+ return parseCodexTranscript(text, sessionId, now);
126
+ }
127
+ function parseCodexTranscript(text, sessionId, now) {
128
+ const out = [];
129
+ const lines = text.split('\n');
130
+ for (let i = 0; i < lines.length; i++) {
131
+ const line = lines[i].trim();
132
+ if (!line)
133
+ continue;
134
+ let rec;
135
+ try {
136
+ rec = JSON.parse(line);
137
+ }
138
+ catch {
139
+ continue;
140
+ }
141
+ const usage = extractUsage(rec);
142
+ if (!usage)
143
+ continue;
144
+ // Digest of the line's position and counts, never its content.
145
+ const digest = (0, node_crypto_1.createHash)('sha256').update(`${sessionId}:${i}:${usage.tokens}:${usage.cacheRead}`).digest('hex').slice(0, 24);
146
+ out.push({
147
+ schemaVersion: 1,
148
+ kind: 'model_usage',
149
+ eventId: `codex:usage:${digest}`,
150
+ host: HOST,
151
+ sessionId,
152
+ at: usage.at ?? now,
153
+ tokens: usage.tokens,
154
+ cacheRead: usage.cacheRead,
155
+ usageCoverage: 'estimated',
156
+ });
157
+ }
158
+ return out;
159
+ }
160
+ function extractUsage(rec) {
161
+ const num = (...paths) => {
162
+ for (const p of paths) {
163
+ const v = walk(rec, p);
164
+ if (typeof v === 'number' && Number.isFinite(v) && v >= 0)
165
+ return Math.trunc(v);
166
+ }
167
+ return undefined;
168
+ };
169
+ const input = num(['usage', 'input_tokens'], ['usage', 'prompt_tokens'], ['response', 'usage', 'input_tokens'], ['response', 'usage', 'prompt_tokens'], ['message', 'usage', 'input_tokens'], ['message', 'usage', 'prompt_tokens'], ['token_usage', 'input_tokens']);
170
+ const output = num(['usage', 'output_tokens'], ['usage', 'completion_tokens'], ['response', 'usage', 'output_tokens'], ['response', 'usage', 'completion_tokens'], ['message', 'usage', 'output_tokens'], ['message', 'usage', 'completion_tokens'], ['token_usage', 'output_tokens']);
171
+ const total = num(['usage', 'total_tokens'], ['response', 'usage', 'total_tokens'], ['message', 'usage', 'total_tokens'], ['token_usage', 'total_tokens']);
172
+ const cached = num(['usage', 'cache_read_input_tokens'], ['usage', 'input_tokens_details', 'cached_tokens'], ['response', 'usage', 'input_tokens_details', 'cached_tokens'], ['token_usage', 'cached_input_tokens']);
173
+ if (input === undefined && output === undefined && total === undefined)
174
+ return null;
175
+ const tokens = Math.max(total ?? 0, (input ?? 0) + (output ?? 0) + (cached ?? 0));
176
+ const tsRaw = walk(rec, ['timestamp']) ?? walk(rec, ['created_at']);
177
+ const at = typeof tsRaw === 'string' ? Date.parse(tsRaw) : typeof tsRaw === 'number' ? tsRaw : NaN;
178
+ return { tokens, cacheRead: cached ?? 0, at: Number.isFinite(at) ? at : undefined };
179
+ }
180
+ function walk(value, path) {
181
+ let cur = value;
182
+ for (const k of path) {
183
+ if (!cur || typeof cur !== 'object' || Array.isArray(cur))
184
+ return undefined;
185
+ cur = cur[k];
186
+ }
187
+ return cur;
188
+ }
189
+ /** The ~/.codex/hooks.json fragment. spawn_agent matches as Agent. */
190
+ function codexHooksSnippet(command) {
191
+ return {
192
+ hooks: {
193
+ PreToolUse: [{ matcher: '^(Agent|spawn_agent)$', hooks: [{ type: 'command', command, timeout: 5 }] }],
194
+ },
195
+ };
196
+ }
197
+ exports.CODEX_SPAWN_TOOLS = SPAWN_TOOLS;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Cursor hooks adapter.
3
+ *
4
+ * Cursor exposes `subagentStart` as a native, prospective admission point and
5
+ * `subagentStop` for reconciliation. That is a stronger spawn signal than a
6
+ * transcript: the host names the subagent and its parent conversation. What
7
+ * Cursor never exposes is hosted-model token usage, so usage coverage for a
8
+ * pure Cursor session is `missing` and status says so.
9
+ *
10
+ * Verified against cursor.com/docs/agent/hooks (2026-09-03):
11
+ * input conversation_id, hook_event_name, subagent_id, subagent_type,
12
+ * parent_conversation_id, tool_call_id, is_parallel_worker
13
+ * output { "permission": "allow" | "deny", "user_message", "agent_message" }
14
+ * config ~/.cursor/hooks.json { version: 1, hooks: { subagentStart: [...] } }
15
+ * "failClosed": true makes a crashed or timed-out hook deny.
16
+ *
17
+ * Cursor's own hooks can deny a subagent. The value here is not the deny; it
18
+ * is that the deny follows the same policy, writes the same receipt and
19
+ * shares the same machine-wide reservation as every other host.
20
+ */
21
+ import type { Gateway } from '../gateway';
22
+ export interface CursorHookOutput {
23
+ permission?: 'allow' | 'deny';
24
+ user_message?: string;
25
+ agent_message?: string;
26
+ }
27
+ /**
28
+ * Handle one hook invocation. Unknown events allow. Returns the object to
29
+ * print on stdout; the CLI wraps it. Never throws: Cursor treats invalid
30
+ * output as fail-open unless failClosed is set, so a crash here would be a
31
+ * silent allow with no receipt.
32
+ */
33
+ export declare function handleCursorHook(raw: unknown, gateway: Gateway, now?: number): CursorHookOutput;
34
+ /** The ~/.cursor/hooks.json fragment. failClosed is on: a dead hook denies. */
35
+ export declare function cursorHooksSnippet(command: string): Record<string, unknown>;