@agentguard-run/burn 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +145 -6
  3. package/dist/src/adapters/codex.d.ts +48 -0
  4. package/dist/src/adapters/codex.js +197 -0
  5. package/dist/src/adapters/cursor.d.ts +35 -0
  6. package/dist/src/adapters/cursor.js +135 -0
  7. package/dist/src/adapters/raw-api.d.ts +76 -0
  8. package/dist/src/adapters/raw-api.js +130 -0
  9. package/dist/src/cli.d.ts +7 -3
  10. package/dist/src/cli.js +141 -17
  11. package/dist/src/conformance.d.ts +26 -0
  12. package/dist/src/conformance.js +261 -0
  13. package/dist/src/defaults.d.ts +11 -0
  14. package/dist/src/defaults.js +16 -1
  15. package/dist/src/detectors/local-compute.d.ts +19 -0
  16. package/dist/src/detectors/local-compute.js +66 -0
  17. package/dist/src/events.d.ts +94 -0
  18. package/dist/src/events.js +47 -0
  19. package/dist/src/gateway.d.ts +141 -0
  20. package/dist/src/gateway.js +536 -0
  21. package/dist/src/hook/pre-tool-use.d.ts +25 -1
  22. package/dist/src/hook/pre-tool-use.js +64 -16
  23. package/dist/src/index.d.ts +19 -4
  24. package/dist/src/index.js +57 -1
  25. package/dist/src/install.d.ts +29 -0
  26. package/dist/src/install.js +145 -0
  27. package/dist/src/override.d.ts +32 -0
  28. package/dist/src/override.js +72 -0
  29. package/dist/src/proxy/server.d.ts +45 -0
  30. package/dist/src/proxy/server.js +169 -0
  31. package/dist/src/proxy/usage-observer.d.ts +40 -0
  32. package/dist/src/proxy/usage-observer.js +128 -0
  33. package/dist/src/receipt.d.ts +61 -0
  34. package/dist/src/receipt.js +98 -0
  35. package/dist/src/replay/render.d.ts +1 -0
  36. package/dist/src/replay/render.js +2 -1
  37. package/dist/src/state/reservations.d.ts +115 -11
  38. package/dist/src/state/reservations.js +293 -59
  39. package/dist/src/state/session.d.ts +6 -0
  40. package/dist/src/state/session.js +17 -0
  41. package/dist/src/status.d.ts +19 -0
  42. package/dist/src/status.js +112 -0
  43. package/dist/src/types.d.ts +14 -1
  44. package/fixtures/codex-0.151.0-pretooluse.json +49 -0
  45. package/package.json +34 -6
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The gateway: one transaction boundary for every host.
3
+ *
4
+ * Adapters do not evaluate policy. They translate what they can see into
5
+ * AgentEvents and hand them here. The gateway folds events into per-session
6
+ * state, evaluates the same detectors the Claude hook uses, and returns a
7
+ * decision. Cursor, Codex, the Ollama proxy and raw middleware therefore
8
+ * cannot drift from each other, because there is nothing host-specific left
9
+ * to drift.
10
+ *
11
+ * Three properties are load-bearing:
12
+ *
13
+ * 1. A spawn is admitted inside the same machine-wide lock the Claude hook
14
+ * uses, as one transaction: fold, evaluate, reserve, sign. Ten parallel
15
+ * Cursor hooks racing a cap of 40 admit exactly 40, for exactly the
16
+ * reason ten Claude hooks do.
17
+ *
18
+ * 2. Usage is committed by call ID and *replaces* what was reserved under
19
+ * it. When middleware estimated 120K and the proxy later saw 87K for the
20
+ * same call, the session moves by 87K, not 207K. Double counting is the
21
+ * easiest way to make a cross-tool product lie, and it is ruled out here
22
+ * rather than in every adapter.
23
+ *
24
+ * 3. A STOP blocks the next expansion. It never truncates a request that is
25
+ * already streaming and never kills a running agent. Blocking is not
26
+ * killing, and the product does not pretend otherwise.
27
+ *
28
+ * Persisted state is content-free: counts, digests, verdicts.
29
+ */
30
+ import { type AgentEvent, type Attribution, type CallRequested, type Coverage, type HostCapabilities, type HostId, type SpawnRequested } from './events';
31
+ import { type SignedReceipt } from './receipt';
32
+ import { type ComputeSnapshot } from './state/reservations';
33
+ import type { BurnReport, Mode, SessionState, Verdict } from './types';
34
+ export interface Decision {
35
+ decisionId: string;
36
+ action: 'spawn' | 'model_call';
37
+ verdict: Verdict;
38
+ /** The policy would block this if enforcing. */
39
+ wouldBlock: boolean;
40
+ /** Enforcing, and blocking. */
41
+ blocked: boolean;
42
+ /** A block was due and the user's recorded override let it through. */
43
+ overridden: {
44
+ once: boolean;
45
+ reason: string;
46
+ } | null;
47
+ /** The finding set changed since the user was last told. Adapters speak only when true. */
48
+ notify: boolean;
49
+ mode: Mode;
50
+ report: BurnReport;
51
+ capabilities: HostCapabilities;
52
+ effectiveSpawns: number;
53
+ /** Depth the child would have. Null for model calls. */
54
+ proposedDepth: number | null;
55
+ compute: ComputeSnapshot | null;
56
+ receipt: SignedReceipt | null;
57
+ /** Set when the decision was forced by an infrastructure failure. */
58
+ failedClosed?: string;
59
+ }
60
+ export interface UsageCoverage {
61
+ authoritative: number;
62
+ estimated: number;
63
+ missing: number;
64
+ }
65
+ export interface GatewaySessionView {
66
+ sessionId: string;
67
+ hosts: HostId[];
68
+ capabilities: HostCapabilities;
69
+ state: SessionState;
70
+ liveSpawns: number;
71
+ usage: UsageCoverage;
72
+ decisions: number;
73
+ wouldBlock: number;
74
+ closedAt: number | null;
75
+ }
76
+ export interface GatewayOptions {
77
+ /** Sign receipts. On by default; off keeps tests and hot paths key-free. */
78
+ sign?: boolean;
79
+ now?: () => number;
80
+ }
81
+ export declare class Gateway {
82
+ private readonly home;
83
+ private readonly store;
84
+ private readonly signer;
85
+ private readonly now;
86
+ private readonly sessionsDir;
87
+ constructor(home: string, opts?: GatewayOptions);
88
+ private file;
89
+ private load;
90
+ private save;
91
+ /**
92
+ * Fold observations. Never decides, never blocks. Idempotent per eventId,
93
+ * so a retried hook or a replayed transcript line is a no-op.
94
+ */
95
+ observe(events: AgentEvent[]): void;
96
+ private fold;
97
+ /**
98
+ * Replace-not-add by call ID. An estimate reserved by middleware is
99
+ * superseded by the proxy's authoritative count for the same call, up or
100
+ * down. Without a call ID the usage is simply added.
101
+ */
102
+ private applyUsage;
103
+ private commitUsage;
104
+ /**
105
+ * Admit or deny a spawn. One transaction under the machine lock: fold,
106
+ * evaluate the proposal, reserve, sign.
107
+ */
108
+ beforeSpawn(event: SpawnRequested): Decision;
109
+ /**
110
+ * Admit or deny a model call. Reserves the caller's estimate under callId;
111
+ * completion replaces it. The local-compute plane is evaluated here and
112
+ * only here, because only calls occupy the machine.
113
+ */
114
+ beforeCall(event: CallRequested): Decision;
115
+ /** Real usage for a reserved call. Replaces the estimate; releases the slot. */
116
+ completeCall(args: {
117
+ host: HostId;
118
+ sessionId: string;
119
+ callId: string;
120
+ tokens: number;
121
+ cacheRead?: number;
122
+ usageCoverage: Coverage;
123
+ at?: number;
124
+ }): void;
125
+ /** The call never produced usage. Releases the estimate and the slot. */
126
+ failCall(args: {
127
+ host: HostId;
128
+ sessionId: string;
129
+ callId: string;
130
+ at?: number;
131
+ }): void;
132
+ private finish;
133
+ private failClosed;
134
+ peek(sessionId: string): GatewaySessionView | null;
135
+ /** Every gateway session on this machine, newest activity first. */
136
+ sessions(): GatewaySessionView[];
137
+ compute(sessionId?: string): ComputeSnapshot;
138
+ }
139
+ /** A session fed by several hosts sees the best coverage any of them provides. */
140
+ export declare function mergeCapabilities(hosts: HostId[]): HostCapabilities;
141
+ export type { Attribution };
@@ -0,0 +1,536 @@
1
+ "use strict";
2
+ /**
3
+ * The gateway: one transaction boundary for every host.
4
+ *
5
+ * Adapters do not evaluate policy. They translate what they can see into
6
+ * AgentEvents and hand them here. The gateway folds events into per-session
7
+ * state, evaluates the same detectors the Claude hook uses, and returns a
8
+ * decision. Cursor, Codex, the Ollama proxy and raw middleware therefore
9
+ * cannot drift from each other, because there is nothing host-specific left
10
+ * to drift.
11
+ *
12
+ * Three properties are load-bearing:
13
+ *
14
+ * 1. A spawn is admitted inside the same machine-wide lock the Claude hook
15
+ * uses, as one transaction: fold, evaluate, reserve, sign. Ten parallel
16
+ * Cursor hooks racing a cap of 40 admit exactly 40, for exactly the
17
+ * reason ten Claude hooks do.
18
+ *
19
+ * 2. Usage is committed by call ID and *replaces* what was reserved under
20
+ * it. When middleware estimated 120K and the proxy later saw 87K for the
21
+ * same call, the session moves by 87K, not 207K. Double counting is the
22
+ * easiest way to make a cross-tool product lie, and it is ruled out here
23
+ * rather than in every adapter.
24
+ *
25
+ * 3. A STOP blocks the next expansion. It never truncates a request that is
26
+ * already streaming and never kills a running agent. Blocking is not
27
+ * killing, and the product does not pretend otherwise.
28
+ *
29
+ * Persisted state is content-free: counts, digests, verdicts.
30
+ */
31
+ Object.defineProperty(exports, "__esModule", { value: true });
32
+ exports.Gateway = void 0;
33
+ exports.mergeCapabilities = mergeCapabilities;
34
+ const node_fs_1 = require("node:fs");
35
+ const node_path_1 = require("node:path");
36
+ const defaults_1 = require("./defaults");
37
+ const evaluate_1 = require("./detectors/evaluate");
38
+ const local_compute_1 = require("./detectors/local-compute");
39
+ const events_1 = require("./events");
40
+ const pre_tool_use_1 = require("./hook/pre-tool-use");
41
+ const override_1 = require("./override");
42
+ const receipt_1 = require("./receipt");
43
+ const reservations_1 = require("./state/reservations");
44
+ const session_1 = require("./state/session");
45
+ const MAX_SEEN_EVENTS = 4000;
46
+ const FILE_PREFIX = 'gw-';
47
+ function safeName(sessionId) {
48
+ return sessionId.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 120) + '-' + (0, receipt_1.sha256)(sessionId).slice(0, 8);
49
+ }
50
+ class Gateway {
51
+ home;
52
+ store;
53
+ signer;
54
+ now;
55
+ sessionsDir;
56
+ constructor(home, opts = {}) {
57
+ this.home = home;
58
+ this.sessionsDir = (0, node_path_1.join)(home, 'sessions');
59
+ (0, node_fs_1.mkdirSync)(this.sessionsDir, { recursive: true, mode: 0o700 });
60
+ this.store = new reservations_1.ReservationStore(home);
61
+ this.signer = opts.sign === false ? null : receipt_1.ReceiptSigner.loadOrCreate(home);
62
+ this.now = opts.now ?? (() => Date.now());
63
+ }
64
+ // ---- persistence ----------------------------------------------------
65
+ file(sessionId) {
66
+ return (0, node_path_1.join)(this.sessionsDir, `${FILE_PREFIX}${safeName(sessionId)}.json`);
67
+ }
68
+ load(sessionId, host, at) {
69
+ try {
70
+ const raw = JSON.parse((0, node_fs_1.readFileSync)(this.file(sessionId), 'utf8'));
71
+ const state = inflate(raw.state);
72
+ if (!raw.hosts.includes(host)) {
73
+ raw.hosts.push(host);
74
+ raw.capabilities = mergeCapabilities(raw.hosts);
75
+ }
76
+ return { state, meta: raw };
77
+ }
78
+ catch {
79
+ const state = (0, session_1.newSessionState)(sessionId, at);
80
+ return {
81
+ state,
82
+ meta: {
83
+ hosts: [host],
84
+ capabilities: events_1.CAPABILITIES[host],
85
+ state: serialise(state),
86
+ calls: [],
87
+ liveSpawns: [],
88
+ seenEvents: [],
89
+ usage: { authoritative: 0, estimated: 0, missing: 0 },
90
+ decisions: 0,
91
+ wouldBlock: 0,
92
+ lastReceipt: null,
93
+ closedAt: null,
94
+ },
95
+ };
96
+ }
97
+ }
98
+ save(meta, state) {
99
+ // Only ever called inside a store transaction. If our lock instance was
100
+ // reclaimed while we worked, this throws and nothing is written.
101
+ this.store.assertHeld();
102
+ meta.state = serialise(state);
103
+ if (meta.seenEvents.length > MAX_SEEN_EVENTS)
104
+ meta.seenEvents = meta.seenEvents.slice(-MAX_SEEN_EVENTS);
105
+ const file = this.file(state.sessionId);
106
+ const tmp = `${file}.${process.pid}.tmp`;
107
+ (0, node_fs_1.writeFileSync)(tmp, JSON.stringify(meta), { mode: 0o600 });
108
+ (0, node_fs_1.renameSync)(tmp, file);
109
+ }
110
+ // ---- observations ---------------------------------------------------
111
+ /**
112
+ * Fold observations. Never decides, never blocks. Idempotent per eventId,
113
+ * so a retried hook or a replayed transcript line is a no-op.
114
+ */
115
+ observe(events) {
116
+ if (events.length === 0)
117
+ return;
118
+ this.store.withLock((tx) => {
119
+ // Group by session so each session file is written once.
120
+ const bySession = new Map();
121
+ for (const e of events) {
122
+ const list = bySession.get(e.sessionId) ?? [];
123
+ list.push(e);
124
+ bySession.set(e.sessionId, list);
125
+ }
126
+ for (const [sessionId, list] of bySession) {
127
+ const first = list[0];
128
+ const { state, meta } = this.load(sessionId, first.host, first.at);
129
+ for (const event of list)
130
+ this.fold(event, state, meta, tx);
131
+ this.save(meta, state);
132
+ }
133
+ });
134
+ }
135
+ fold(event, state, meta, tx) {
136
+ if (meta.seenEvents.includes(event.eventId))
137
+ return;
138
+ meta.seenEvents.push(event.eventId);
139
+ const calls = new Map(meta.calls);
140
+ const live = new Map(meta.liveSpawns);
141
+ switch (event.kind) {
142
+ case 'session_opened':
143
+ meta.closedAt = null;
144
+ break;
145
+ case 'session_closed':
146
+ meta.closedAt = event.at;
147
+ break;
148
+ case 'model_usage': {
149
+ this.commitUsage(event, state, meta, calls);
150
+ if (event.callId)
151
+ tx.finishCall(event.callId, windowMs(), event.at);
152
+ break;
153
+ }
154
+ case 'surface_read':
155
+ (0, session_1.applyEvent)(state, burnEvent(event.at, { surfaces: [event.surfaceDigest] }));
156
+ break;
157
+ case 'spawn_started': {
158
+ live.set(event.spawnId, event.depth);
159
+ const before = state.spawnCount;
160
+ (0, session_1.applyEvent)(state, burnEvent(event.at, { spawns: [{ description: '', issuerDepth: Math.max(0, event.depth - 1) }] }));
161
+ // The spawn is now observed; its reservation has done its job.
162
+ tx.reconcile(event.sessionId, state.spawnCount, before);
163
+ break;
164
+ }
165
+ case 'spawn_finished':
166
+ live.delete(event.spawnId);
167
+ break;
168
+ case 'spawn_requested':
169
+ case 'call_requested':
170
+ // Decisions go through beforeSpawn / beforeCall. Folding one here
171
+ // would count it without admitting it.
172
+ break;
173
+ }
174
+ meta.calls = [...calls];
175
+ meta.liveSpawns = [...live];
176
+ }
177
+ /**
178
+ * Replace-not-add by call ID. An estimate reserved by middleware is
179
+ * superseded by the proxy's authoritative count for the same call, up or
180
+ * down. Without a call ID the usage is simply added.
181
+ */
182
+ applyUsage(callId, tokens, cacheRead, at, state, calls) {
183
+ const previous = callId ? calls.get(callId) : undefined;
184
+ if (callId)
185
+ calls.set(callId, { tokens, cacheRead });
186
+ if (!previous) {
187
+ (0, session_1.applyEvent)(state, burnEvent(at, { tokens, cacheRead }));
188
+ return;
189
+ }
190
+ // Keep active-time accounting honest: the correction is not new activity.
191
+ (0, session_1.applyEvent)(state, burnEvent(at, {}));
192
+ (0, session_1.applyCorrection)(state, tokens - previous.tokens, cacheRead - previous.cacheRead);
193
+ }
194
+ commitUsage(event, state, meta, calls) {
195
+ this.applyUsage(event.callId, event.tokens, event.cacheRead, event.at, state, calls);
196
+ meta.usage[event.usageCoverage] += 1;
197
+ }
198
+ // ---- decisions ------------------------------------------------------
199
+ /**
200
+ * Admit or deny a spawn. One transaction under the machine lock: fold,
201
+ * evaluate the proposal, reserve, sign.
202
+ */
203
+ beforeSpawn(event) {
204
+ const policy = (0, pre_tool_use_1.loadPolicy)(this.home);
205
+ try {
206
+ return this.store.withLock((tx) => {
207
+ const { state, meta } = this.load(event.sessionId, event.host, event.at);
208
+ if (meta.seenEvents.includes(event.eventId)) {
209
+ // Same request evaluated twice (host retry). Re-evaluate without
210
+ // reserving again; the store is idempotent on spawnId anyway.
211
+ }
212
+ else {
213
+ meta.seenEvents.push(event.eventId);
214
+ }
215
+ const live = new Map(meta.liveSpawns);
216
+ const proposedDepth = event.proposedDepth ?? (event.issuerId !== undefined && live.has(event.issuerId) ? live.get(event.issuerId) + 1 : 1);
217
+ const report = (0, evaluate_1.evaluate)(state, policy.thresholds, proposedDepth);
218
+ const reservation = tx.reserve({
219
+ sessionId: event.sessionId,
220
+ toolUseId: event.spawnId,
221
+ observedSpawns: state.spawnCount,
222
+ ceiling: policy.thresholds.fanout.stop,
223
+ now: event.at,
224
+ });
225
+ const wouldBlock = report.verdict === 'STOP' || !reservation.allowed;
226
+ const verdict = wouldBlock ? 'STOP' : report.verdict;
227
+ // Session-scope STOPs need a session we trust. Fan-out is session scope.
228
+ const due = wouldBlock && policy.mode === 'enforce' && event.attribution === 'high';
229
+ const override = due ? (0, override_1.consumeOverride)(this.home, event.at) : null;
230
+ const decision = this.finish({ action: 'spawn', host: event.host, sessionId: event.sessionId, at: event.at, proposedDepth, verdict, wouldBlock, blocked: due && !override, override, report, effectiveSpawns: reservation.effectiveSpawns, compute: null }, policy, meta, state);
231
+ this.save(meta, state);
232
+ return decision;
233
+ });
234
+ }
235
+ catch (error) {
236
+ return this.failClosed('spawn', event, policy, error);
237
+ }
238
+ }
239
+ /**
240
+ * Admit or deny a model call. Reserves the caller's estimate under callId;
241
+ * completion replaces it. The local-compute plane is evaluated here and
242
+ * only here, because only calls occupy the machine.
243
+ */
244
+ beforeCall(event) {
245
+ const policy = (0, pre_tool_use_1.loadPolicy)(this.home);
246
+ try {
247
+ return this.store.withLock((tx) => {
248
+ const { state, meta } = this.load(event.sessionId, event.host, event.at);
249
+ if (!meta.seenEvents.includes(event.eventId))
250
+ meta.seenEvents.push(event.eventId);
251
+ const calls = new Map(meta.calls);
252
+ const compute = tx.reserveCall({
253
+ sessionId: event.sessionId,
254
+ callId: event.callId,
255
+ host: event.host,
256
+ estimatedTokens: event.estimatedTokens,
257
+ ttlMs: defaults_1.CALL_RESERVATION_TTL_MS,
258
+ windowMs: windowMs(policy),
259
+ now: event.at,
260
+ });
261
+ // The estimate counts toward the session until the real number lands.
262
+ // It is a reservation, not an observation, so coverage is untouched.
263
+ if (event.estimatedTokens > 0 && !calls.has(event.callId)) {
264
+ this.applyUsage(event.callId, event.estimatedTokens, 0, event.at, state, calls);
265
+ }
266
+ meta.calls = [...calls];
267
+ const session = (0, evaluate_1.evaluate)(state, policy.thresholds, null);
268
+ const local = (0, local_compute_1.evaluateLocalCompute)(compute, policy.thresholds);
269
+ const report = merge(session, local.findings);
270
+ const wouldBlock = report.verdict === 'STOP';
271
+ // A low-confidence session (proxy without a session header) can be
272
+ // stopped only on machine-scope grounds. Its session count is a guess.
273
+ const machineStop = local.verdict === 'STOP';
274
+ const due = wouldBlock && policy.mode === 'enforce' && (event.attribution === 'high' || machineStop);
275
+ const override = due ? (0, override_1.consumeOverride)(this.home, event.at) : null;
276
+ const blocked = due && !override;
277
+ const decision = this.finish({ action: 'model_call', host: event.host, sessionId: event.sessionId, at: event.at, proposedDepth: null, verdict: report.verdict, wouldBlock, blocked, override, report, effectiveSpawns: state.spawnCount, compute }, policy, meta, state);
278
+ if (blocked) {
279
+ // A denied call neither occupies the machine nor spends tokens.
280
+ tx.finishCall(event.callId, windowMs(policy), event.at);
281
+ const released = new Map(meta.calls);
282
+ this.applyUsage(event.callId, 0, 0, event.at, state, released);
283
+ meta.calls = [...released];
284
+ }
285
+ this.save(meta, state);
286
+ return decision;
287
+ });
288
+ }
289
+ catch (error) {
290
+ return this.failClosed('model_call', event, policy, error);
291
+ }
292
+ }
293
+ /** Real usage for a reserved call. Replaces the estimate; releases the slot. */
294
+ completeCall(args) {
295
+ this.observe([
296
+ {
297
+ schemaVersion: 1,
298
+ kind: 'model_usage',
299
+ // Per host: middleware and the proxy may both complete one call, and
300
+ // the later authoritative figure supersedes by callId.
301
+ eventId: `complete-${args.host}-${args.callId}`,
302
+ host: args.host,
303
+ sessionId: args.sessionId,
304
+ at: args.at ?? this.now(),
305
+ tokens: Math.max(0, args.tokens),
306
+ cacheRead: Math.max(0, args.cacheRead ?? 0),
307
+ usageCoverage: args.usageCoverage,
308
+ callId: args.callId,
309
+ },
310
+ ]);
311
+ }
312
+ /** The call never produced usage. Releases the estimate and the slot. */
313
+ failCall(args) {
314
+ this.observe([
315
+ {
316
+ schemaVersion: 1,
317
+ kind: 'model_usage',
318
+ eventId: `fail-${args.host}-${args.callId}`,
319
+ host: args.host,
320
+ sessionId: args.sessionId,
321
+ at: args.at ?? this.now(),
322
+ tokens: 0,
323
+ cacheRead: 0,
324
+ usageCoverage: 'missing',
325
+ callId: args.callId,
326
+ },
327
+ ]);
328
+ }
329
+ finish(d, policy, meta, state) {
330
+ meta.decisions += 1;
331
+ if (d.wouldBlock)
332
+ meta.wouldBlock += 1;
333
+ const decisionId = (0, events_1.eventId)();
334
+ // Speak once per change, not once per spawn. A block or an override is
335
+ // always spoken; it is the event the user needs to see.
336
+ const signature = d.verdict === 'OK' ? '' : (0, pre_tool_use_1.findingSignature)(d.report);
337
+ const notify = d.blocked || d.override !== null || (signature !== '' && signature !== (meta.notified ?? ''));
338
+ meta.notified = signature;
339
+ // Sign what matters: every spawn admission, and any call that is not OK.
340
+ // Signing every OK call through a busy proxy would be thousands of
341
+ // receipts an hour saying nothing.
342
+ let receipt = null;
343
+ if (this.signer && (d.action === 'spawn' || d.verdict !== 'OK')) {
344
+ const payload = {
345
+ schema: 'agentguard.burn.decision.v1',
346
+ decisionId,
347
+ at: d.at,
348
+ host: d.host,
349
+ sessionDigest: (0, receipt_1.sha256)(d.sessionId),
350
+ action: d.action,
351
+ policy: { mode: policy.mode, digest: (0, receipt_1.sha256)((0, receipt_1.canonical)(policy)) },
352
+ measured: {
353
+ sessionTokens: state.totalTokens,
354
+ sessionSpawns: d.effectiveSpawns,
355
+ proposedDepth: d.proposedDepth,
356
+ inFlight: d.compute?.inFlight ?? 0,
357
+ occupiedMs: d.compute?.occupiedMs ?? 0,
358
+ },
359
+ coverage: meta.capabilities,
360
+ verdict: d.verdict,
361
+ blocked: d.blocked,
362
+ reasons: d.report.findings.map((f) => `${f.detector}:${f.verdict}`),
363
+ previous: meta.lastReceipt,
364
+ };
365
+ receipt = this.signer.sign(payload);
366
+ meta.lastReceipt = (0, receipt_1.receiptDigest)(receipt);
367
+ (0, node_fs_1.appendFileSync)((0, node_path_1.join)(this.home, 'receipts.ndjson'), `${JSON.stringify(receipt)}\n`, { mode: 0o600 });
368
+ }
369
+ // Same ledger the Claude hook writes, so `status` and shadow eligibility
370
+ // count every host.
371
+ (0, node_fs_1.appendFileSync)((0, node_path_1.join)(this.home, 'decisions.ndjson'), `${JSON.stringify({
372
+ at: d.at,
373
+ host: d.host,
374
+ action: d.action,
375
+ sessionId: d.sessionId,
376
+ verdict: d.verdict,
377
+ wouldDeny: d.wouldBlock,
378
+ enforced: d.blocked,
379
+ overridden: d.override ?? undefined,
380
+ mode: policy.mode,
381
+ findings: d.report.findings.map((f) => ({ detector: f.detector, verdict: f.verdict, observed: f.observed, threshold: f.threshold })),
382
+ effectiveSpawns: d.effectiveSpawns,
383
+ totals: d.report.totals,
384
+ compute: d.compute,
385
+ coverage: meta.capabilities,
386
+ })}\n`, { mode: 0o600 });
387
+ return {
388
+ decisionId,
389
+ action: d.action,
390
+ verdict: d.verdict,
391
+ wouldBlock: d.wouldBlock,
392
+ blocked: d.blocked,
393
+ overridden: d.override,
394
+ notify,
395
+ mode: policy.mode,
396
+ report: d.report,
397
+ capabilities: meta.capabilities,
398
+ effectiveSpawns: d.effectiveSpawns,
399
+ proposedDepth: d.proposedDepth,
400
+ compute: d.compute,
401
+ receipt,
402
+ };
403
+ }
404
+ failClosed(action, event, policy, error) {
405
+ const reason = `AgentGuard failed closed: ${error instanceof Error ? error.message : 'unknown error'}`;
406
+ (0, node_fs_1.mkdirSync)(this.home, { recursive: true, mode: 0o700 });
407
+ (0, node_fs_1.appendFileSync)((0, node_path_1.join)(this.home, 'decisions.ndjson'), `${JSON.stringify({ at: event.at, host: event.host, action, sessionId: event.sessionId, verdict: 'STOP', wouldDeny: true, enforced: policy.mode === 'enforce', mode: policy.mode, reason, failClosed: true })}\n`, { mode: 0o600 });
408
+ const report = {
409
+ sessionId: event.sessionId,
410
+ verdict: 'STOP',
411
+ findings: [{ detector: action === 'spawn' ? 'fanout' : 'local_compute', verdict: 'STOP', summary: reason, observed: 0, threshold: 0 }],
412
+ prescriptions: ['Another AgentGuard process holds the lock and is not releasing it. Check for a stuck hook, then retry.'],
413
+ cacheReadRatio: 0,
414
+ totals: { tokens: 0, spawns: 0, maxDepth: 0, activeMinutes: 0 },
415
+ };
416
+ return {
417
+ decisionId: (0, events_1.eventId)(),
418
+ action,
419
+ verdict: 'STOP',
420
+ wouldBlock: true,
421
+ blocked: policy.mode === 'enforce',
422
+ overridden: null,
423
+ notify: true,
424
+ mode: policy.mode,
425
+ report,
426
+ capabilities: events_1.CAPABILITIES[event.host],
427
+ effectiveSpawns: 0,
428
+ proposedDepth: event.kind === 'spawn_requested' ? event.proposedDepth ?? 1 : null,
429
+ compute: null,
430
+ receipt: null,
431
+ failedClosed: reason,
432
+ };
433
+ }
434
+ // ---- read side ------------------------------------------------------
435
+ peek(sessionId) {
436
+ try {
437
+ const raw = JSON.parse((0, node_fs_1.readFileSync)(this.file(sessionId), 'utf8'));
438
+ return view(sessionId, raw);
439
+ }
440
+ catch {
441
+ return null;
442
+ }
443
+ }
444
+ /** Every gateway session on this machine, newest activity first. */
445
+ sessions() {
446
+ const out = [];
447
+ let names = [];
448
+ try {
449
+ names = (0, node_fs_1.readdirSync)(this.sessionsDir).filter((n) => n.startsWith(FILE_PREFIX) && n.endsWith('.json'));
450
+ }
451
+ catch {
452
+ return out;
453
+ }
454
+ for (const name of names) {
455
+ try {
456
+ const raw = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(this.sessionsDir, name), 'utf8'));
457
+ out.push(view(raw.state.sessionId, raw));
458
+ }
459
+ catch {
460
+ /* half-written or foreign file: skip */
461
+ }
462
+ }
463
+ return out.sort((a, b) => b.state.lastEventAt - a.state.lastEventAt);
464
+ }
465
+ compute(sessionId = '') {
466
+ return this.store.computeSnapshot(sessionId, windowMs((0, pre_tool_use_1.loadPolicy)(this.home)), this.now());
467
+ }
468
+ }
469
+ exports.Gateway = Gateway;
470
+ // ---- helpers ------------------------------------------------------------
471
+ function windowMs(policy) {
472
+ return policy?.thresholds.localCompute?.windowMs ?? defaults_1.DEFAULT_LOCAL_COMPUTE.windowMs;
473
+ }
474
+ function burnEvent(at, partial) {
475
+ return { at, tokens: 0, cacheRead: 0, spawns: [], surfaces: [], sidechain: false, ...partial };
476
+ }
477
+ const RANK = { OK: 0, WARN: 1, STOP: 2 };
478
+ function merge(report, extra) {
479
+ if (extra.length === 0)
480
+ return report;
481
+ const findings = [...report.findings, ...extra];
482
+ let verdict = report.verdict;
483
+ for (const f of extra)
484
+ if (RANK[f.verdict] > RANK[verdict])
485
+ verdict = f.verdict;
486
+ const prescriptions = [...report.prescriptions.filter((p) => p !== 'Nothing pathological. Carry on.')];
487
+ if (extra.some((f) => f.verdict === 'STOP'))
488
+ prescriptions.unshift('Let the model calls already in flight finish before admitting more work.');
489
+ else if (extra.length)
490
+ prescriptions.push('Queue model calls instead of firing them in parallel; the machine is shared by every agent on it.');
491
+ return { ...report, verdict, findings, prescriptions };
492
+ }
493
+ const COVERAGE_RANK = { missing: 0, estimated: 1, authoritative: 2 };
494
+ /** A session fed by several hosts sees the best coverage any of them provides. */
495
+ function mergeCapabilities(hosts) {
496
+ const best = (k) => hosts.map((h) => events_1.CAPABILITIES[h][k]).reduce((a, b) => (COVERAGE_RANK[b] > COVERAGE_RANK[a] ? b : a), 'missing');
497
+ return { spawns: best('spawns'), depth: best('depth'), usage: best('usage') };
498
+ }
499
+ function serialise(state) {
500
+ return {
501
+ sessionId: state.sessionId,
502
+ startedAt: state.startedAt,
503
+ lastEventAt: state.lastEventAt,
504
+ totalTokens: state.totalTokens,
505
+ totalCacheRead: state.totalCacheRead,
506
+ spawnCount: state.spawnCount,
507
+ maxDepth: state.maxDepth,
508
+ activeMinutes: state.activeMinutes,
509
+ tokensByActiveMinute: [...state.tokensByActiveMinute],
510
+ spawnsByActiveMinute: [...state.spawnsByActiveMinute],
511
+ surfaceReaders: [...state.surfaceReaders].map(([k, v]) => [k, [...v]]),
512
+ burnDebt: state.burnDebt,
513
+ lastDebtEventAt: state.lastDebtEventAt,
514
+ };
515
+ }
516
+ function inflate(s) {
517
+ return {
518
+ ...s,
519
+ tokensByActiveMinute: new Map(s.tokensByActiveMinute),
520
+ spawnsByActiveMinute: new Map(s.spawnsByActiveMinute),
521
+ surfaceReaders: new Map(s.surfaceReaders.map(([k, v]) => [k, new Set(v)])),
522
+ };
523
+ }
524
+ function view(sessionId, raw) {
525
+ return {
526
+ sessionId,
527
+ hosts: raw.hosts,
528
+ capabilities: raw.capabilities,
529
+ state: inflate(raw.state),
530
+ liveSpawns: raw.liveSpawns.length,
531
+ usage: raw.usage,
532
+ decisions: raw.decisions,
533
+ wouldBlock: raw.wouldBlock,
534
+ closedAt: raw.closedAt,
535
+ };
536
+ }