@agentguard-run/burn 0.1.0 → 0.2.0

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