@agentguard-run/burn 0.1.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.
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Detectors and the verdict.
3
+ *
4
+ * Two independent safety planes, because the data showed two independent
5
+ * failure modes:
6
+ *
7
+ * structural - fan-out. Many agents, each re-sending context. The 172-spawn
8
+ * session. Caught by an absolute spawn cap.
9
+ * economic - sustained burn. Few agents, long session, enormous total. The
10
+ * 9.15B session with only 26 spawns, which a spawn cap cannot
11
+ * see. Caught by a cumulative token ceiling and burn debt.
12
+ *
13
+ * Verdict is the maximum severity across findings. Advisory detectors (spawn
14
+ * rate, duplicate work, cache ratio) can raise WARN but never STOP, because
15
+ * each has a plausible benign explanation and a false STOP is what gets a
16
+ * safety tool uninstalled.
17
+ */
18
+ import type { BurnReport, SessionState, Thresholds } from '../types';
19
+ /**
20
+ * Evaluate a session. `proposedSpawnDepth` is the depth the *candidate* child
21
+ * would have if the pending spawn were allowed; the decision must be about the
22
+ * proposal, not about a violation the transcript has already recorded.
23
+ */
24
+ export declare function evaluate(state: SessionState, thresholds: Thresholds, proposedSpawnDepth?: number | null): BurnReport;
25
+ export declare function fmt(n: number): string;
@@ -0,0 +1,217 @@
1
+ "use strict";
2
+ /**
3
+ * Detectors and the verdict.
4
+ *
5
+ * Two independent safety planes, because the data showed two independent
6
+ * failure modes:
7
+ *
8
+ * structural - fan-out. Many agents, each re-sending context. The 172-spawn
9
+ * session. Caught by an absolute spawn cap.
10
+ * economic - sustained burn. Few agents, long session, enormous total. The
11
+ * 9.15B session with only 26 spawns, which a spawn cap cannot
12
+ * see. Caught by a cumulative token ceiling and burn debt.
13
+ *
14
+ * Verdict is the maximum severity across findings. Advisory detectors (spawn
15
+ * rate, duplicate work, cache ratio) can raise WARN but never STOP, because
16
+ * each has a plausible benign explanation and a false STOP is what gets a
17
+ * safety tool uninstalled.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.evaluate = evaluate;
21
+ exports.fmt = fmt;
22
+ const session_1 = require("../state/session");
23
+ const RANK = { OK: 0, WARN: 1, STOP: 2 };
24
+ function worst(a, b) {
25
+ return RANK[a] >= RANK[b] ? a : b;
26
+ }
27
+ /**
28
+ * Evaluate a session. `proposedSpawnDepth` is the depth the *candidate* child
29
+ * would have if the pending spawn were allowed; the decision must be about the
30
+ * proposal, not about a violation the transcript has already recorded.
31
+ */
32
+ function evaluate(state, thresholds, proposedSpawnDepth = null) {
33
+ const findings = [];
34
+ let verdict = 'OK';
35
+ // ---- structural plane: fan-out --------------------------------------
36
+ // Count the proposal itself. "Allow through 40, deny candidate 41."
37
+ const effectiveSpawns = state.spawnCount + (proposedSpawnDepth !== null ? 1 : 0);
38
+ if (effectiveSpawns > thresholds.fanout.stop) {
39
+ findings.push({
40
+ detector: 'fanout',
41
+ verdict: 'STOP',
42
+ summary: `Spawn ${effectiveSpawns} would exceed the fan-out ceiling of ${thresholds.fanout.stop}.`,
43
+ observed: effectiveSpawns,
44
+ threshold: thresholds.fanout.stop,
45
+ });
46
+ verdict = worst(verdict, 'STOP');
47
+ }
48
+ else if (effectiveSpawns >= thresholds.fanout.warn) {
49
+ findings.push({
50
+ detector: 'fanout',
51
+ verdict: 'WARN',
52
+ summary: `${effectiveSpawns} agent spawns this session; ceiling is ${thresholds.fanout.stop}.`,
53
+ observed: effectiveSpawns,
54
+ threshold: thresholds.fanout.warn,
55
+ });
56
+ verdict = worst(verdict, 'WARN');
57
+ }
58
+ if (proposedSpawnDepth !== null && proposedSpawnDepth > thresholds.fanout.maxDepth) {
59
+ findings.push({
60
+ detector: 'fanout',
61
+ verdict: 'STOP',
62
+ summary: `Proposed agent would sit at depth ${proposedSpawnDepth}; agents spawning agents beyond depth ${thresholds.fanout.maxDepth} is the fan-out amplifier.`,
63
+ observed: proposedSpawnDepth,
64
+ threshold: thresholds.fanout.maxDepth,
65
+ });
66
+ verdict = worst(verdict, 'STOP');
67
+ }
68
+ // ---- economic plane: sustained burn --------------------------------
69
+ if (state.totalTokens >= thresholds.sustained.stopTokens) {
70
+ findings.push({
71
+ detector: 'sustained_burn',
72
+ verdict: 'STOP',
73
+ summary: `Session has consumed ${fmt(state.totalTokens)} tokens, above the ${fmt(thresholds.sustained.stopTokens)} ceiling.`,
74
+ observed: state.totalTokens,
75
+ threshold: thresholds.sustained.stopTokens,
76
+ });
77
+ verdict = worst(verdict, 'STOP');
78
+ }
79
+ else if (state.totalTokens >= thresholds.sustained.warnTokens) {
80
+ findings.push({
81
+ detector: 'sustained_burn',
82
+ verdict: 'WARN',
83
+ summary: `Session at ${fmt(state.totalTokens)} tokens; ceiling is ${fmt(thresholds.sustained.stopTokens)}.`,
84
+ observed: state.totalTokens,
85
+ threshold: thresholds.sustained.warnTokens,
86
+ });
87
+ verdict = worst(verdict, 'WARN');
88
+ }
89
+ // ---- economic plane: burn debt (only after calibration) ------------
90
+ if (thresholds.burnDebt.enabled) {
91
+ if (state.burnDebt >= thresholds.burnDebt.stopDebt) {
92
+ findings.push({
93
+ detector: 'burn_debt',
94
+ verdict: 'STOP',
95
+ summary: `Burn is ${fmt(state.burnDebt)} tokens above this user's expected rate, sustained.`,
96
+ observed: state.burnDebt,
97
+ threshold: thresholds.burnDebt.stopDebt,
98
+ });
99
+ verdict = worst(verdict, 'STOP');
100
+ }
101
+ else if (state.burnDebt >= thresholds.burnDebt.warnDebt) {
102
+ findings.push({
103
+ detector: 'burn_debt',
104
+ verdict: 'WARN',
105
+ summary: `Burn is running ${fmt(state.burnDebt)} tokens above the expected rate.`,
106
+ observed: state.burnDebt,
107
+ threshold: thresholds.burnDebt.warnDebt,
108
+ });
109
+ verdict = worst(verdict, 'WARN');
110
+ }
111
+ }
112
+ // ---- advisory: spawn rate over active time -------------------------
113
+ const recentSpawns = (0, session_1.windowSum)(state.spawnsByActiveMinute, state.activeMinutes, thresholds.spawnRate.windowActiveMinutes);
114
+ if (recentSpawns >= thresholds.spawnRate.stop) {
115
+ const severity = thresholds.spawnRate.enforce ? 'STOP' : 'WARN';
116
+ findings.push({
117
+ detector: 'spawn_rate',
118
+ verdict: severity,
119
+ summary: `${recentSpawns} spawns in the last ${thresholds.spawnRate.windowActiveMinutes} active minutes.`,
120
+ observed: recentSpawns,
121
+ threshold: thresholds.spawnRate.stop,
122
+ });
123
+ verdict = worst(verdict, severity);
124
+ }
125
+ else if (recentSpawns >= thresholds.spawnRate.warn) {
126
+ findings.push({
127
+ detector: 'spawn_rate',
128
+ verdict: 'WARN',
129
+ summary: `${recentSpawns} spawns in the last ${thresholds.spawnRate.windowActiveMinutes} active minutes.`,
130
+ observed: recentSpawns,
131
+ threshold: thresholds.spawnRate.warn,
132
+ });
133
+ verdict = worst(verdict, 'WARN');
134
+ }
135
+ // ---- advisory: duplicate work --------------------------------------
136
+ let duplicated = 0;
137
+ let worstSurfaceReaders = 0;
138
+ for (const readers of state.surfaceReaders.values()) {
139
+ if (readers.size >= thresholds.duplicate.warnReaders) {
140
+ duplicated += 1;
141
+ worstSurfaceReaders = Math.max(worstSurfaceReaders, readers.size);
142
+ }
143
+ }
144
+ if (duplicated > 0) {
145
+ findings.push({
146
+ detector: 'duplicate_work',
147
+ verdict: 'WARN',
148
+ summary: `${duplicated} file(s) re-read across ${worstSurfaceReaders}+ agent eras. Context is being re-sent instead of passed down.`,
149
+ observed: duplicated,
150
+ threshold: thresholds.duplicate.warnReaders,
151
+ });
152
+ verdict = worst(verdict, 'WARN');
153
+ }
154
+ return {
155
+ sessionId: state.sessionId,
156
+ verdict,
157
+ findings,
158
+ prescriptions: prescribe(state, findings, thresholds),
159
+ cacheReadRatio: (0, session_1.cacheReadRatio)(state),
160
+ totals: {
161
+ tokens: state.totalTokens,
162
+ spawns: state.spawnCount,
163
+ maxDepth: state.maxDepth,
164
+ activeMinutes: state.activeMinutes,
165
+ },
166
+ };
167
+ }
168
+ /**
169
+ * The layer that raw telemetry never ships: what to actually do, ranked.
170
+ * Every line is specific to this session's numbers.
171
+ */
172
+ function prescribe(state, findings, t) {
173
+ const out = [];
174
+ const has = (d, v) => findings.some((f) => f.detector === d && (!v || f.verdict === v));
175
+ if (has('fanout', 'STOP')) {
176
+ out.push('Do not start another subagent. The fan-out ceiling is the validated stop.');
177
+ out.push(`Let the ${Math.min(state.spawnCount, 4)} most useful running agents finish; do not replace them.`);
178
+ }
179
+ else if (has('fanout')) {
180
+ out.push(`You are at ${state.spawnCount} spawns; ${t.fanout.stop - state.spawnCount} remain before the ceiling. Plan for it.`);
181
+ }
182
+ if (state.maxDepth >= 2) {
183
+ out.push('Agents are spawning agents. Make the root orchestrator the only process allowed to spawn.');
184
+ }
185
+ if (has('sustained_burn', 'STOP')) {
186
+ out.push(`This session is at ${fmt(state.totalTokens)} tokens. Start a fresh session for the next task; do not keep extending this one.`);
187
+ out.push('Compact before any further heavy work so the next call does not re-send the full history.');
188
+ }
189
+ else if (has('sustained_burn')) {
190
+ out.push('Compact now. Every call from here re-sends the accumulated context.');
191
+ }
192
+ if (has('burn_debt')) {
193
+ out.push('Burn is sustained above your own baseline. Narrow the task or split it into a new session.');
194
+ }
195
+ if (has('duplicate_work')) {
196
+ out.push('Assign one reader per file and pass a short digest down, instead of letting every agent re-read it.');
197
+ }
198
+ if (has('spawn_rate')) {
199
+ out.push('Queue agents instead of launching them in parallel; the limit is shared across all of them.');
200
+ }
201
+ const median = (0, session_1.medianCompletedMinute)(state);
202
+ if (median > 0 && (0, session_1.cacheReadRatio)(state) > 0.9) {
203
+ out.push('Most of this burn is cached context re-sent per agent. Fewer, longer-lived agents beat many short ones.');
204
+ }
205
+ if (out.length === 0)
206
+ out.push('Nothing pathological. Carry on.');
207
+ return out;
208
+ }
209
+ function fmt(n) {
210
+ if (n >= 1e9)
211
+ return `${(n / 1e9).toFixed(2)}B`;
212
+ if (n >= 1e6)
213
+ return `${(n / 1e6).toFixed(1)}M`;
214
+ if (n >= 1e3)
215
+ return `${(n / 1e3).toFixed(0)}K`;
216
+ return String(Math.round(n));
217
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Claude Code transcript reader.
3
+ *
4
+ * Reads ~/.claude/projects/<slug>/<session>.jsonl incrementally. The hot path
5
+ * (the PreToolUse hook) must never re-read a 25,000-line file, so the reader
6
+ * remembers the byte offset after the last complete newline and only parses
7
+ * what has been appended since.
8
+ *
9
+ * Transcript lines are not a stable contract. Every line is parsed defensively:
10
+ * a malformed line is counted and skipped, a partial trailing line is left
11
+ * uncommitted so it can be re-read once the host finishes writing it, and a
12
+ * truncated or replaced file resets the cursor rather than reading garbage.
13
+ */
14
+ import type { BurnEvent } from '../types';
15
+ export interface ReaderCursor {
16
+ /** Byte just after the last complete newline we processed. */
17
+ offset: number;
18
+ /** Size at last read, to detect truncation or replacement. */
19
+ size: number;
20
+ malformedLines: number;
21
+ /** Resolved depth per line uuid, for spawn-depth attribution. */
22
+ depthByUuid: Map<string, number>;
23
+ }
24
+ export declare function newCursor(): ReaderCursor;
25
+ interface RawLine {
26
+ timestamp?: string;
27
+ uuid?: string;
28
+ parentUuid?: string;
29
+ isSidechain?: boolean;
30
+ message?: {
31
+ usage?: Record<string, number | undefined>;
32
+ content?: unknown;
33
+ };
34
+ }
35
+ /** Turn one parsed line into an event, or null if it carries nothing we track. */
36
+ export declare function normaliseLine(raw: RawLine, cursor: ReaderCursor): BurnEvent | null;
37
+ /**
38
+ * Read every complete line appended since the cursor. Returns the new events
39
+ * and advances the cursor. O(new bytes), never O(file size) after the first
40
+ * read.
41
+ */
42
+ export declare function readIncremental(path: string, cursor: ReaderCursor): BurnEvent[];
43
+ /** Convenience for replay and tests: read a whole transcript from the start. */
44
+ export declare function readAll(path: string): {
45
+ events: BurnEvent[];
46
+ cursor: ReaderCursor;
47
+ };
48
+ export {};
@@ -0,0 +1,175 @@
1
+ "use strict";
2
+ /**
3
+ * Claude Code transcript reader.
4
+ *
5
+ * Reads ~/.claude/projects/<slug>/<session>.jsonl incrementally. The hot path
6
+ * (the PreToolUse hook) must never re-read a 25,000-line file, so the reader
7
+ * remembers the byte offset after the last complete newline and only parses
8
+ * what has been appended since.
9
+ *
10
+ * Transcript lines are not a stable contract. Every line is parsed defensively:
11
+ * a malformed line is counted and skipped, a partial trailing line is left
12
+ * uncommitted so it can be re-read once the host finishes writing it, and a
13
+ * truncated or replaced file resets the cursor rather than reading garbage.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.newCursor = newCursor;
17
+ exports.normaliseLine = normaliseLine;
18
+ exports.readIncremental = readIncremental;
19
+ exports.readAll = readAll;
20
+ const node_fs_1 = require("node:fs");
21
+ const CHUNK = 64 * 1024;
22
+ const MAX_LINE = 4 * 1024 * 1024;
23
+ const SPAWN_TOOLS = new Set(['Agent', 'Task']);
24
+ const SURFACE_TOOLS = new Set(['Read', 'Grep', 'Glob']);
25
+ function newCursor() {
26
+ return { offset: 0, size: 0, malformedLines: 0, depthByUuid: new Map() };
27
+ }
28
+ function toMillis(value) {
29
+ if (typeof value !== 'string' || value.length === 0)
30
+ return null;
31
+ const ms = Date.parse(value);
32
+ return Number.isFinite(ms) ? ms : null;
33
+ }
34
+ function nonNegative(value) {
35
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
36
+ }
37
+ /** Turn one parsed line into an event, or null if it carries nothing we track. */
38
+ function normaliseLine(raw, cursor) {
39
+ const at = toMillis(raw.timestamp);
40
+ if (at === null)
41
+ return null;
42
+ const usage = raw.message?.usage ?? {};
43
+ const cacheRead = nonNegative(usage.cache_read_input_tokens);
44
+ const tokens = nonNegative(usage.input_tokens) +
45
+ nonNegative(usage.output_tokens) +
46
+ nonNegative(usage.cache_creation_input_tokens) +
47
+ cacheRead;
48
+ // Depth attribution: a line's depth is its parent's depth, plus one if the
49
+ // host flagged it as a sidechain (subagent) line. Unknown parents are root.
50
+ const parentDepth = raw.parentUuid ? cursor.depthByUuid.get(raw.parentUuid) ?? 0 : 0;
51
+ const depth = raw.isSidechain ? parentDepth + 1 : parentDepth;
52
+ if (raw.uuid)
53
+ cursor.depthByUuid.set(raw.uuid, depth);
54
+ const spawns = [];
55
+ const surfaces = [];
56
+ const content = raw.message?.content;
57
+ if (Array.isArray(content)) {
58
+ for (const item of content) {
59
+ if (!item || typeof item !== 'object')
60
+ continue;
61
+ const block = item;
62
+ if (block.type !== 'tool_use' || typeof block.name !== 'string')
63
+ continue;
64
+ const input = (block.input && typeof block.input === 'object' ? block.input : {});
65
+ if (SPAWN_TOOLS.has(block.name)) {
66
+ spawns.push({
67
+ // Bounded and never persisted beyond process memory: this is a label
68
+ // for the operator, not a record of the prompt.
69
+ description: String(input.description ?? '').slice(0, 80),
70
+ model: typeof input.model === 'string' ? input.model : undefined,
71
+ issuerDepth: depth,
72
+ });
73
+ }
74
+ else if (SURFACE_TOOLS.has(block.name)) {
75
+ const target = input.file_path ?? input.path ?? input.pattern;
76
+ if (typeof target === 'string' && target.length > 0)
77
+ surfaces.push(target);
78
+ }
79
+ }
80
+ }
81
+ if (tokens === 0 && spawns.length === 0 && surfaces.length === 0)
82
+ return null;
83
+ return {
84
+ at,
85
+ tokens,
86
+ cacheRead,
87
+ spawns,
88
+ surfaces,
89
+ sidechain: Boolean(raw.isSidechain),
90
+ uuid: raw.uuid,
91
+ parentUuid: raw.parentUuid,
92
+ };
93
+ }
94
+ /**
95
+ * Read every complete line appended since the cursor. Returns the new events
96
+ * and advances the cursor. O(new bytes), never O(file size) after the first
97
+ * read.
98
+ */
99
+ function readIncremental(path, cursor) {
100
+ let fd;
101
+ try {
102
+ fd = (0, node_fs_1.openSync)(path, 'r');
103
+ }
104
+ catch {
105
+ return [];
106
+ }
107
+ try {
108
+ const size = (0, node_fs_1.fstatSync)(fd).size;
109
+ // Truncated or replaced: the bytes we remember no longer exist. Restart.
110
+ if (size < cursor.offset) {
111
+ cursor.offset = 0;
112
+ cursor.depthByUuid.clear();
113
+ }
114
+ cursor.size = size;
115
+ if (size === cursor.offset)
116
+ return [];
117
+ const events = [];
118
+ let carry = Buffer.alloc(0);
119
+ let position = cursor.offset;
120
+ const buffer = Buffer.allocUnsafe(CHUNK);
121
+ while (position < size) {
122
+ const read = (0, node_fs_1.readSync)(fd, buffer, 0, CHUNK, position);
123
+ if (read <= 0)
124
+ break;
125
+ position += read;
126
+ let chunk = carry.length ? Buffer.concat([carry, buffer.subarray(0, read)]) : buffer.subarray(0, read);
127
+ let lineStart = 0;
128
+ for (;;) {
129
+ const newline = chunk.indexOf(0x0a, lineStart);
130
+ if (newline === -1)
131
+ break;
132
+ const lineBytes = chunk.subarray(lineStart, newline);
133
+ lineStart = newline + 1;
134
+ if (lineBytes.length > 0 && lineBytes.length <= MAX_LINE) {
135
+ try {
136
+ const parsed = JSON.parse(lineBytes.toString('utf8'));
137
+ const event = normaliseLine(parsed, cursor);
138
+ if (event)
139
+ events.push(event);
140
+ }
141
+ catch {
142
+ cursor.malformedLines += 1;
143
+ }
144
+ }
145
+ else if (lineBytes.length > MAX_LINE) {
146
+ cursor.malformedLines += 1;
147
+ }
148
+ }
149
+ // Everything before lineStart is committed. The remainder is a partial
150
+ // line the host may still be writing; keep it for the next chunk, and
151
+ // if the file ends mid-line, do not advance past it.
152
+ const consumed = position - read + lineStart;
153
+ cursor.offset = consumed - (carry.length ? carry.length : 0) + (carry.length ? carry.length : 0);
154
+ carry = Buffer.from(chunk.subarray(lineStart));
155
+ // Guard against a runaway partial line that never terminates.
156
+ if (carry.length > MAX_LINE) {
157
+ cursor.malformedLines += 1;
158
+ carry = Buffer.alloc(0);
159
+ cursor.offset = position;
160
+ }
161
+ }
162
+ // Recompute offset precisely: bytes read minus the uncommitted carry.
163
+ cursor.offset = position - carry.length;
164
+ return events;
165
+ }
166
+ finally {
167
+ (0, node_fs_1.closeSync)(fd);
168
+ }
169
+ }
170
+ /** Convenience for replay and tests: read a whole transcript from the start. */
171
+ function readAll(path) {
172
+ const cursor = newCursor();
173
+ const events = readIncremental(path, cursor);
174
+ return { events, cursor };
175
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Claude Code PreToolUse hook.
3
+ *
4
+ * Reads the hook payload from stdin, evaluates the proposed spawn under the
5
+ * reservation lock, and emits the nested permissionDecision Claude Code
6
+ * expects. Only Agent/Task spawns are ever denied; ordinary tools pass.
7
+ *
8
+ * Shadow mode evaluates and records the decision but always allows. That is
9
+ * the first-run default, because a blocker with untuned thresholds that fires
10
+ * on day one gets uninstalled on day one.
11
+ *
12
+ * Fail-closed on the reservation lock: if we cannot coordinate with sibling
13
+ * hook processes, we deny the spawn rather than let a burst through. We
14
+ * cannot fail closed if Claude Code times the hook out, so the hot path does
15
+ * as little as possible.
16
+ */
17
+ import { type ReaderCursor } from '../history/claude-transcript';
18
+ import type { Policy, SessionState } from '../types';
19
+ export interface HookInput {
20
+ session_id?: string;
21
+ transcript_path?: string;
22
+ tool_name?: string;
23
+ tool_use_id?: string;
24
+ tool_input?: Record<string, unknown>;
25
+ agent_id?: string;
26
+ agent_type?: string;
27
+ }
28
+ export interface HookOutput {
29
+ continue: true;
30
+ suppressOutput?: boolean;
31
+ systemMessage?: string;
32
+ hookSpecificOutput?: {
33
+ hookEventName: 'PreToolUse';
34
+ permissionDecision: 'allow' | 'deny';
35
+ permissionDecisionReason: string;
36
+ };
37
+ }
38
+ export declare function loadPolicy(home: string): Policy;
39
+ /** Refresh session state from the transcript. Cheap: only new bytes are read. */
40
+ export declare function refreshSession(home: string, sessionId: string, transcriptPath: string): {
41
+ cursor: ReaderCursor;
42
+ state: SessionState;
43
+ newSpawns: number;
44
+ };
45
+ export declare function handlePreToolUse(input: HookInput, home: string, now?: number): HookOutput;
46
+ /** The settings.json fragment users paste in. Only PreToolUse can block. */
47
+ export declare function settingsSnippet(command: string): Record<string, unknown>;
@@ -0,0 +1,166 @@
1
+ "use strict";
2
+ /**
3
+ * Claude Code PreToolUse hook.
4
+ *
5
+ * Reads the hook payload from stdin, evaluates the proposed spawn under the
6
+ * reservation lock, and emits the nested permissionDecision Claude Code
7
+ * expects. Only Agent/Task spawns are ever denied; ordinary tools pass.
8
+ *
9
+ * Shadow mode evaluates and records the decision but always allows. That is
10
+ * the first-run default, because a blocker with untuned thresholds that fires
11
+ * on day one gets uninstalled on day one.
12
+ *
13
+ * Fail-closed on the reservation lock: if we cannot coordinate with sibling
14
+ * hook processes, we deny the spawn rather than let a burst through. We
15
+ * cannot fail closed if Claude Code times the hook out, so the hot path does
16
+ * as little as possible.
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.loadPolicy = loadPolicy;
20
+ exports.refreshSession = refreshSession;
21
+ exports.handlePreToolUse = handlePreToolUse;
22
+ exports.settingsSnippet = settingsSnippet;
23
+ const node_fs_1 = require("node:fs");
24
+ const node_path_1 = require("node:path");
25
+ const evaluate_1 = require("../detectors/evaluate");
26
+ const claude_transcript_1 = require("../history/claude-transcript");
27
+ const reservations_1 = require("../state/reservations");
28
+ const session_1 = require("../state/session");
29
+ const defaults_1 = require("../defaults");
30
+ const SPAWN_TOOLS = new Set(['Agent', 'Task']);
31
+ function sessionFile(home, sessionId) {
32
+ return (0, node_path_1.join)(home, 'sessions', `${sessionId.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`);
33
+ }
34
+ function loadSession(home, sessionId, firstEventAt) {
35
+ try {
36
+ const raw = JSON.parse((0, node_fs_1.readFileSync)(sessionFile(home, sessionId), 'utf8'));
37
+ const cursor = { ...raw.cursor, depthByUuid: new Map(raw.cursor.depthByUuid) };
38
+ const state = {
39
+ ...raw.state,
40
+ tokensByActiveMinute: new Map(raw.state.tokensByActiveMinute),
41
+ spawnsByActiveMinute: new Map(raw.state.spawnsByActiveMinute),
42
+ surfaceReaders: new Map(raw.state.surfaceReaders.map(([k, v]) => [k, new Set(v)])),
43
+ };
44
+ return { cursor, state };
45
+ }
46
+ catch {
47
+ return { cursor: (0, claude_transcript_1.newCursor)(), state: (0, session_1.newSessionState)(sessionId, firstEventAt) };
48
+ }
49
+ }
50
+ function saveSession(home, cursor, state) {
51
+ (0, node_fs_1.mkdirSync)((0, node_path_1.join)(home, 'sessions'), { recursive: true, mode: 0o700 });
52
+ const persisted = {
53
+ cursor: { offset: cursor.offset, size: cursor.size, malformedLines: cursor.malformedLines, depthByUuid: [...cursor.depthByUuid] },
54
+ state: {
55
+ ...state,
56
+ tokensByActiveMinute: [...state.tokensByActiveMinute],
57
+ spawnsByActiveMinute: [...state.spawnsByActiveMinute],
58
+ surfaceReaders: [...state.surfaceReaders].map(([k, v]) => [k, [...v]]),
59
+ },
60
+ };
61
+ const file = sessionFile(home, state.sessionId);
62
+ (0, node_fs_1.writeFileSync)(`${file}.tmp`, JSON.stringify(persisted), { mode: 0o600 });
63
+ // rename is atomic; a concurrent reader never sees a half-written file.
64
+ require('node:fs').renameSync(`${file}.tmp`, file);
65
+ }
66
+ function loadPolicy(home) {
67
+ try {
68
+ const parsed = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(home, 'burn-policy.json'), 'utf8'));
69
+ if (parsed && (parsed.mode === 'shadow' || parsed.mode === 'enforce') && parsed.thresholds)
70
+ return parsed;
71
+ }
72
+ catch {
73
+ /* no policy yet: shadow defaults */
74
+ }
75
+ return defaults_1.DEFAULT_POLICY;
76
+ }
77
+ function recordDecision(home, entry) {
78
+ (0, node_fs_1.mkdirSync)(home, { recursive: true, mode: 0o700 });
79
+ (0, node_fs_1.appendFileSync)((0, node_path_1.join)(home, 'decisions.ndjson'), `${JSON.stringify(entry)}\n`, { mode: 0o600 });
80
+ }
81
+ /** Refresh session state from the transcript. Cheap: only new bytes are read. */
82
+ function refreshSession(home, sessionId, transcriptPath) {
83
+ const { cursor, state } = loadSession(home, sessionId, Date.now());
84
+ const before = state.spawnCount;
85
+ const events = (0, claude_transcript_1.readIncremental)(transcriptPath, cursor);
86
+ for (const event of events)
87
+ (0, session_1.applyEvent)(state, event);
88
+ if (events.length > 0 && state.startedAt > events[0].at)
89
+ state.startedAt = events[0].at;
90
+ saveSession(home, cursor, state);
91
+ return { cursor, state, newSpawns: state.spawnCount - before };
92
+ }
93
+ function handlePreToolUse(input, home, now = Date.now()) {
94
+ const toolName = input.tool_name ?? '';
95
+ if (!SPAWN_TOOLS.has(toolName) || !input.session_id || !input.transcript_path) {
96
+ return { continue: true, suppressOutput: true };
97
+ }
98
+ const policy = loadPolicy(home);
99
+ const store = new reservations_1.ReservationStore(home);
100
+ let report;
101
+ let reservation;
102
+ try {
103
+ const { state, newSpawns } = refreshSession(home, input.session_id, input.transcript_path);
104
+ if (newSpawns > 0)
105
+ store.reconcile(input.session_id, state.spawnCount, state.spawnCount - newSpawns);
106
+ // Depth of the proposed child: the issuing agent's depth plus one. A hook
107
+ // fired inside a subagent carries agent_id; treat that as depth 1 issuer.
108
+ const proposedDepth = input.agent_id ? 2 : 1;
109
+ report = (0, evaluate_1.evaluate)(state, policy.thresholds, proposedDepth);
110
+ reservation = store.reserve({
111
+ sessionId: input.session_id,
112
+ toolUseId: input.tool_use_id ?? `${input.session_id}:${now}`,
113
+ observedSpawns: state.spawnCount,
114
+ ceiling: policy.thresholds.fanout.stop,
115
+ now,
116
+ });
117
+ }
118
+ catch (error) {
119
+ // Could not coordinate. Deny the spawn; never let a burst through blind.
120
+ const reason = `AgentGuard failed closed: ${error instanceof Error ? error.message : 'unknown error'}`;
121
+ recordDecision(home, { at: now, sessionId: input.session_id, verdict: 'STOP', enforced: policy.mode === 'enforce', reason, failClosed: true });
122
+ return policy.mode === 'enforce' ? deny(reason) : { continue: true, suppressOutput: true };
123
+ }
124
+ const shouldDeny = report.verdict === 'STOP' || !reservation.allowed;
125
+ const reason = shouldDeny ? buildDenyReason(report, reservation) : '';
126
+ recordDecision(home, {
127
+ at: now,
128
+ sessionId: input.session_id,
129
+ toolUseId: input.tool_use_id ?? null,
130
+ verdict: report.verdict,
131
+ wouldDeny: shouldDeny,
132
+ enforced: policy.mode === 'enforce' && shouldDeny,
133
+ mode: policy.mode,
134
+ findings: report.findings.map((f) => ({ detector: f.detector, verdict: f.verdict, observed: f.observed, threshold: f.threshold })),
135
+ effectiveSpawns: reservation.effectiveSpawns,
136
+ totals: report.totals,
137
+ });
138
+ if (shouldDeny && policy.mode === 'enforce')
139
+ return deny(reason);
140
+ if (report.verdict !== 'OK') {
141
+ return {
142
+ continue: true,
143
+ systemMessage: `AgentGuard ${report.verdict}${policy.mode === 'shadow' && shouldDeny ? ' (shadow: would have blocked)' : ''}: ${report.findings[0]?.summary ?? ''}`,
144
+ };
145
+ }
146
+ return { continue: true, suppressOutput: true };
147
+ }
148
+ function buildDenyReason(report, reservation) {
149
+ const lead = report.findings.find((f) => f.verdict === 'STOP')?.summary ?? `Fan-out ceiling reached (${reservation.effectiveSpawns} effective spawns).`;
150
+ const rx = report.prescriptions.slice(0, 3).map((p, i) => `${i + 1}. ${p}`).join(' ');
151
+ return `AgentGuard STOP: blocked this agent spawn. ${lead} ${rx} Override once with: agentguard-burn resume --once --reason "..."`;
152
+ }
153
+ function deny(reason) {
154
+ return {
155
+ continue: true,
156
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason },
157
+ };
158
+ }
159
+ /** The settings.json fragment users paste in. Only PreToolUse can block. */
160
+ function settingsSnippet(command) {
161
+ return {
162
+ hooks: {
163
+ PreToolUse: [{ matcher: '^(Agent|Task)$', hooks: [{ type: 'command', command, timeout: 5 }] }],
164
+ },
165
+ };
166
+ }