@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.
- package/CHANGELOG.md +93 -0
- package/README.md +145 -6
- package/dist/src/adapters/codex.d.ts +48 -0
- package/dist/src/adapters/codex.js +197 -0
- package/dist/src/adapters/cursor.d.ts +35 -0
- package/dist/src/adapters/cursor.js +135 -0
- package/dist/src/adapters/raw-api.d.ts +76 -0
- package/dist/src/adapters/raw-api.js +130 -0
- package/dist/src/cli.d.ts +7 -3
- package/dist/src/cli.js +141 -17
- package/dist/src/conformance.d.ts +26 -0
- package/dist/src/conformance.js +261 -0
- package/dist/src/defaults.d.ts +11 -0
- package/dist/src/defaults.js +16 -1
- package/dist/src/detectors/local-compute.d.ts +19 -0
- package/dist/src/detectors/local-compute.js +66 -0
- package/dist/src/events.d.ts +94 -0
- package/dist/src/events.js +47 -0
- package/dist/src/gateway.d.ts +141 -0
- package/dist/src/gateway.js +536 -0
- package/dist/src/hook/pre-tool-use.d.ts +25 -1
- package/dist/src/hook/pre-tool-use.js +64 -16
- package/dist/src/index.d.ts +19 -4
- package/dist/src/index.js +57 -1
- package/dist/src/install.d.ts +29 -0
- package/dist/src/install.js +145 -0
- package/dist/src/override.d.ts +32 -0
- package/dist/src/override.js +72 -0
- package/dist/src/proxy/server.d.ts +45 -0
- package/dist/src/proxy/server.js +169 -0
- package/dist/src/proxy/usage-observer.d.ts +40 -0
- package/dist/src/proxy/usage-observer.js +128 -0
- package/dist/src/receipt.d.ts +61 -0
- package/dist/src/receipt.js +98 -0
- package/dist/src/replay/render.d.ts +1 -0
- package/dist/src/replay/render.js +2 -1
- package/dist/src/state/reservations.d.ts +115 -11
- package/dist/src/state/reservations.js +293 -59
- package/dist/src/state/session.d.ts +6 -0
- package/dist/src/state/session.js +17 -0
- package/dist/src/status.d.ts +19 -0
- package/dist/src/status.js +112 -0
- package/dist/src/types.d.ts +14 -1
- package/fixtures/codex-0.151.0-pretooluse.json +49 -0
- 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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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>;
|