@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.
- package/CHANGELOG.md +64 -0
- package/README.md +110 -1
- package/dist/src/adapters/codex.d.ts +48 -0
- package/dist/src/adapters/codex.js +194 -0
- package/dist/src/adapters/cursor.d.ts +35 -0
- package/dist/src/adapters/cursor.js +132 -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 +99 -10
- 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 +134 -0
- package/dist/src/gateway.js +522 -0
- package/dist/src/hook/pre-tool-use.js +5 -4
- package/dist/src/index.d.ts +15 -4
- package/dist/src/index.js +41 -1
- 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 +10 -3
- package/dist/src/replay/render.js +175 -44
- package/dist/src/replay/simulate.d.ts +4 -0
- package/dist/src/replay/simulate.js +24 -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 +11 -0
- package/dist/src/status.js +48 -0
- package/dist/src/types.d.ts +14 -1
- 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>;
|