@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,261 @@
1
+ "use strict";
2
+ /**
3
+ * Conformance: the same failure, through every door, stops at the same step.
4
+ *
5
+ * Two fixtures, fitted on real sessions:
6
+ *
7
+ * the storm 42 candidate spawns. First WARN must be spawn 24, first STOP
8
+ * must be spawn 41. Every spawn-capable adapter replays it.
9
+ * the grind model calls of 250M tokens each. First WARN must be the call
10
+ * after 3.5B, first STOP the call after 5B. Every usage-capable
11
+ * adapter replays it.
12
+ * composite raw middleware supplies the spawns, the proxy supplies the
13
+ * usage, one session ID. Candidate spawn 41 must see BOTH
14
+ * planes in its findings. That is the claim on the box.
15
+ *
16
+ * Runs in a throwaway home in enforce mode. Nothing here touches ~/.agentguard.
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.runConformance = runConformance;
20
+ const node_http_1 = require("node:http");
21
+ const node_fs_1 = require("node:fs");
22
+ const node_os_1 = require("node:os");
23
+ const node_path_1 = require("node:path");
24
+ const codex_1 = require("./adapters/codex");
25
+ const cursor_1 = require("./adapters/cursor");
26
+ const raw_api_1 = require("./adapters/raw-api");
27
+ const defaults_1 = require("./defaults");
28
+ const events_1 = require("./events");
29
+ const gateway_1 = require("./gateway");
30
+ const server_1 = require("./proxy/server");
31
+ const receipt_1 = require("./receipt");
32
+ const STORM = 42;
33
+ const WARN_AT = defaults_1.DEFAULT_THRESHOLDS.fanout.warn; // 24
34
+ const STOP_AT = defaults_1.DEFAULT_THRESHOLDS.fanout.stop + 1; // 41
35
+ const CALL_TOKENS = 250_000_000;
36
+ const GRIND_WARN_CALL = Math.floor(defaults_1.DEFAULT_THRESHOLDS.sustained.warnTokens / CALL_TOKENS) + 1; // 15
37
+ const GRIND_STOP_CALL = Math.floor(defaults_1.DEFAULT_THRESHOLDS.sustained.stopTokens / CALL_TOKENS) + 1; // 21
38
+ function freshHome() {
39
+ const home = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'agb-conf-'));
40
+ const policy = { mode: 'enforce', thresholds: defaults_1.DEFAULT_THRESHOLDS };
41
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(home, 'burn-policy.json'), JSON.stringify(policy));
42
+ return home;
43
+ }
44
+ function boundaries(steps) {
45
+ const w = steps.findIndex((v) => v === 'WARN');
46
+ const a = steps.findIndex((v) => v === 'WARN' || v === 'WARN:other');
47
+ const s = steps.findIndex((v) => v === 'STOP');
48
+ return { firstWarn: w < 0 ? null : w + 1, firstStop: s < 0 ? null : s + 1, firstAnyWarn: a < 0 ? null : a + 1 };
49
+ }
50
+ // Hook adapters only expose the first finding's summary. Fan-out is evaluated
51
+ // first in the core, so when it fires it is the one in the message.
52
+ const FANOUT_WARN = /agent spawns this session/;
53
+ function stepFromMessage(denied, message, warnPattern) {
54
+ if (denied)
55
+ return 'STOP';
56
+ if (!message || !message.includes('WARN'))
57
+ return 'OK';
58
+ return warnPattern.test(message) ? 'WARN' : 'WARN:other';
59
+ }
60
+ // ---- storms --------------------------------------------------------------
61
+ function stormRaw(home) {
62
+ const burn = (0, raw_api_1.createRawApiGuard)({ sessionId: 'storm-raw', home });
63
+ const steps = [];
64
+ for (let i = 1; i <= STORM; i++) {
65
+ const lease = burn.beforeSpawn({ parentDepth: 0 });
66
+ const d = lease.decision;
67
+ const fanout = d.report.findings.find((f) => f.detector === 'fanout');
68
+ steps.push(d.blocked ? 'STOP' : fanout?.verdict === 'WARN' ? 'WARN' : d.verdict === 'WARN' ? 'WARN:other' : 'OK');
69
+ if (!d.blocked)
70
+ lease.started();
71
+ }
72
+ return boundaries(steps);
73
+ }
74
+ function stormCursor(home) {
75
+ const gateway = new gateway_1.Gateway(home);
76
+ const steps = [];
77
+ for (let i = 1; i <= STORM; i++) {
78
+ const out = (0, cursor_1.handleCursorHook)({ hook_event_name: 'subagentStart', conversation_id: 'storm-cursor', subagent_id: `sub-${i}`, parent_conversation_id: 'storm-cursor' }, gateway);
79
+ steps.push(stepFromMessage(out.permission === 'deny', out.agent_message, FANOUT_WARN));
80
+ }
81
+ return boundaries(steps);
82
+ }
83
+ function stormCodex(home) {
84
+ const gateway = new gateway_1.Gateway(home);
85
+ const steps = [];
86
+ for (let i = 1; i <= STORM; i++) {
87
+ const out = (0, codex_1.handleCodexHook)({ hook_event_name: 'PreToolUse', session_id: 'storm-codex', tool_name: 'Agent', tool_use_id: `call-${i}` }, gateway);
88
+ steps.push(stepFromMessage(out.hookSpecificOutput?.permissionDecision === 'deny', out.systemMessage, FANOUT_WARN));
89
+ }
90
+ return boundaries(steps);
91
+ }
92
+ function depthRaw(home) {
93
+ const burn = (0, raw_api_1.createRawApiGuard)({ sessionId: 'depth-raw', home });
94
+ const ok1 = !burn.beforeSpawn({ parentDepth: 0 }).blocked;
95
+ const ok2 = !burn.beforeSpawn({ parentDepth: 1 }).blocked;
96
+ const denied3 = burn.beforeSpawn({ parentDepth: 2 }).blocked;
97
+ return ok1 && ok2 && denied3;
98
+ }
99
+ // ---- grinds --------------------------------------------------------------
100
+ function grindRaw(home) {
101
+ const burn = (0, raw_api_1.createRawApiGuard)({ sessionId: 'grind-raw', home });
102
+ const steps = [];
103
+ for (let i = 1; i <= GRIND_STOP_CALL + 1; i++) {
104
+ const call = burn.beforeCall({ estimatedTokens: 1_000 });
105
+ const d = call.decision;
106
+ const sustained = d.report.findings.find((f) => f.detector === 'sustained_burn');
107
+ steps.push(d.blocked ? 'STOP' : sustained?.verdict === 'WARN' ? 'WARN' : d.verdict === 'WARN' ? 'WARN:other' : 'OK');
108
+ if (d.blocked)
109
+ break;
110
+ call.complete({ tokens: CALL_TOKENS });
111
+ }
112
+ return boundaries(steps);
113
+ }
114
+ /** A stand-in Ollama: streams a few NDJSON chunks then the final counts. */
115
+ function fakeOllama(tokensPerCall) {
116
+ const bodies = [];
117
+ const server = (0, node_http_1.createServer)((req, res) => {
118
+ req.resume();
119
+ req.on('end', () => {
120
+ res.writeHead(200, { 'content-type': 'application/x-ndjson' });
121
+ const parts = [
122
+ JSON.stringify({ model: 'test', message: { role: 'assistant', content: 'hel' }, done: false }),
123
+ JSON.stringify({ model: 'test', message: { role: 'assistant', content: 'lo' }, done: false }),
124
+ JSON.stringify({ model: 'test', done: true, prompt_eval_count: tokensPerCall - 7, eval_count: 7 }),
125
+ ];
126
+ const body = parts.join('\n') + '\n';
127
+ bodies.push(body);
128
+ let i = 0;
129
+ const tick = () => {
130
+ if (i < parts.length) {
131
+ res.write(parts[i++] + '\n');
132
+ setTimeout(tick, 2);
133
+ }
134
+ else
135
+ res.end();
136
+ };
137
+ tick();
138
+ });
139
+ });
140
+ return new Promise((resolve) => {
141
+ server.listen(0, '127.0.0.1', () => {
142
+ const a = server.address();
143
+ resolve({ server, url: new URL(`http://127.0.0.1:${a.port}`), bodies });
144
+ });
145
+ });
146
+ }
147
+ function post(url, path, headers) {
148
+ return new Promise((resolve, reject) => {
149
+ const req = (0, node_http_1.request)({ hostname: url.hostname, port: url.port, path, method: 'POST', headers: { 'content-type': 'application/json', ...headers } }, (res) => {
150
+ let body = '';
151
+ res.on('data', (d) => (body += d.toString()));
152
+ res.on('end', () => resolve({ status: res.statusCode ?? 0, body, headers: res.headers }));
153
+ });
154
+ req.on('error', reject);
155
+ req.end(JSON.stringify({ model: 'test', messages: [{ role: 'user', content: 'x' }] }));
156
+ });
157
+ }
158
+ async function grindProxy(home, sessionId = 'grind-proxy') {
159
+ const upstream = await fakeOllama(CALL_TOKENS);
160
+ const gateway = new gateway_1.Gateway(home);
161
+ const proxy = await (0, server_1.startProxy)({ upstream: upstream.url, gateway, host: 'ollama' });
162
+ const steps = [];
163
+ let byteIdentical = true;
164
+ let stopBody = '';
165
+ try {
166
+ for (let i = 1; i <= GRIND_STOP_CALL + 1; i++) {
167
+ const r = await post(proxy.address, '/api/chat', { [events_1.SESSION_HEADER]: sessionId, [events_1.CALL_HEADER]: `${sessionId}-${i}` });
168
+ if (r.status === 429) {
169
+ steps.push('STOP');
170
+ stopBody = r.body;
171
+ break;
172
+ }
173
+ // The proxy only exposes the verdict header on the wire, so the grind
174
+ // through the proxy is checked on verdict; the raw grind above proves
175
+ // the detector. No spawns happen here, so no advisory WARN can fire.
176
+ const v = r.headers['x-agentguard-verdict'];
177
+ steps.push(v === 'WARN' ? 'WARN' : 'OK');
178
+ if (r.body !== upstream.bodies[upstream.bodies.length - 1])
179
+ byteIdentical = false;
180
+ }
181
+ }
182
+ finally {
183
+ await proxy.close();
184
+ upstream.server.close();
185
+ }
186
+ return { ...boundaries(steps), byteIdentical, stopBody };
187
+ }
188
+ // ---- composite -----------------------------------------------------------
189
+ async function composite(home) {
190
+ // Proxy supplies usage: 14 calls = 3.5B, so the session is in WARN territory.
191
+ const upstream = await fakeOllama(CALL_TOKENS);
192
+ const gateway = new gateway_1.Gateway(home);
193
+ const proxy = await (0, server_1.startProxy)({ upstream: upstream.url, gateway, host: 'ollama' });
194
+ try {
195
+ for (let i = 1; i <= GRIND_WARN_CALL - 1; i++)
196
+ await post(proxy.address, '/api/chat', { [events_1.SESSION_HEADER]: 'composite', [events_1.CALL_HEADER]: `c-${i}` });
197
+ }
198
+ finally {
199
+ await proxy.close();
200
+ upstream.server.close();
201
+ }
202
+ // Middleware supplies the tree on the same session ID.
203
+ const burn = (0, raw_api_1.createRawApiGuard)({ sessionId: 'composite', home });
204
+ let last = burn.beforeSpawn({ parentDepth: 0 });
205
+ for (let i = 1; i <= STOP_AT; i++) {
206
+ last = burn.beforeSpawn({ parentDepth: 0 });
207
+ if (!last.blocked)
208
+ last.started();
209
+ }
210
+ const detectors = new Set(last.decision.report.findings.map((f) => f.detector));
211
+ const view = gateway.peek('composite');
212
+ const hosts = view?.hosts.join('+') ?? '';
213
+ const ok = last.blocked && detectors.has('fanout') && detectors.has('sustained_burn') && view?.capabilities.usage === 'authoritative' && view?.capabilities.spawns === 'authoritative';
214
+ return {
215
+ ok,
216
+ detail: `spawn 41 ${last.blocked ? 'denied' : 'ALLOWED'}; findings ${[...detectors].join(',')}; hosts ${hosts}; coverage spawns:${view?.capabilities.spawns} usage:${view?.capabilities.usage}; tokens ${((view?.state.totalTokens ?? 0) / 1e9).toFixed(2)}B`,
217
+ };
218
+ }
219
+ // ---- runner --------------------------------------------------------------
220
+ async function runConformance() {
221
+ const home = freshHome();
222
+ const checks = [];
223
+ const expectStorm = (name, b) => checks.push({
224
+ name,
225
+ ok: b.firstWarn === WARN_AT && b.firstStop === STOP_AT,
226
+ detail: `fan-out WARN at ${b.firstWarn} (want ${WARN_AT}), STOP at ${b.firstStop} (want ${STOP_AT})${b.firstAnyWarn !== null && b.firstAnyWarn < WARN_AT ? `; advisory spawn-rate WARN at ${b.firstAnyWarn}` : ''}`,
227
+ });
228
+ const expectGrind = (name, b) => checks.push({ name, ok: b.firstWarn === GRIND_WARN_CALL && b.firstStop === GRIND_STOP_CALL, detail: `first WARN call ${b.firstWarn} (want ${GRIND_WARN_CALL}), first STOP call ${b.firstStop} (want ${GRIND_STOP_CALL})` });
229
+ try {
230
+ expectStorm('storm · raw middleware', stormRaw(home));
231
+ expectStorm('storm · cursor subagentStart', stormCursor(home));
232
+ expectStorm('storm · codex PreToolUse', stormCodex(home));
233
+ checks.push({ name: 'depth · raw middleware', ok: depthRaw(home), detail: 'depth 1 and 2 admitted, depth 3 denied' });
234
+ expectGrind('grind · raw middleware', grindRaw(home));
235
+ const p = await grindProxy(home);
236
+ expectGrind('grind · ollama proxy', p);
237
+ checks.push({ name: 'proxy · streamed bytes identical to upstream', ok: p.byteIdentical, detail: p.byteIdentical ? 'every streamed body matched upstream byte for byte' : 'MISMATCH' });
238
+ checks.push({ name: 'proxy · STOP answers 429 with the alarm box', ok: p.stopBody.includes('agentguard_burn_stop') && p.stopBody.includes('AGENTGUARD STOP'), detail: p.stopBody ? 'error.type=agentguard_burn_stop, box present' : 'no STOP body captured' });
239
+ const c = await composite(home);
240
+ checks.push({ name: 'composite · middleware tree + proxy usage, one session', ok: c.ok, detail: c.detail });
241
+ // Receipts: every spawn decision above was signed; verify the chain tail.
242
+ const receipts = require('node:fs')
243
+ .readFileSync((0, node_path_1.join)(home, 'receipts.ndjson'), 'utf8')
244
+ .split('\n')
245
+ .filter(Boolean)
246
+ .map((l) => JSON.parse(l));
247
+ const allValid = receipts.every((r) => (0, receipt_1.verifyReceipt)(r));
248
+ const contentFree = !receipts.some((r) => JSON.stringify(r.payload).match(/transcript|prompt|\/Users\/|content/i));
249
+ checks.push({ name: `receipts · ${receipts.length} signed, all verify, content-free`, ok: allValid && contentFree && receipts.length > 0, detail: `${receipts.length} receipts, verify=${allValid}, content-free=${contentFree}` });
250
+ }
251
+ finally {
252
+ (0, node_fs_1.rmSync)(home, { recursive: true, force: true });
253
+ }
254
+ const ok = checks.every((c) => c.ok);
255
+ const text = [
256
+ 'AGENTGUARD conformance · same failure, every door, same step',
257
+ ...checks.map((c) => ` ${c.ok ? 'PASS' : 'FAIL'} ${c.name.padEnd(52)} ${c.detail}`),
258
+ ok ? 'all adapters agree' : 'DISAGREEMENT: an adapter drifted from the core',
259
+ ].join('\n');
260
+ return { ok, text, checks };
261
+ }
@@ -21,6 +21,17 @@ import type { Policy, Thresholds } from './types';
21
21
  * absent from these thresholds.
22
22
  */
23
23
  export declare const DEFAULT_THRESHOLDS: Thresholds;
24
+ export declare const DEFAULT_LOCAL_COMPUTE: {
25
+ windowMs: number;
26
+ warnConcurrent: number;
27
+ stopConcurrent: number | null;
28
+ warnOccupiedMs: number | null;
29
+ stopOccupiedMs: number | null;
30
+ };
31
+ /** How long a model-call reservation may stay open before it is presumed dead. */
32
+ export declare const CALL_RESERVATION_TTL_MS: number;
33
+ /** Tokens assumed for a call whose caller gave no estimate. Replaced by the real count on completion. */
34
+ export declare const DEFAULT_CALL_ESTIMATE_TOKENS = 0;
24
35
  /** First-run policy. Shadow: evaluate and record, never block. */
25
36
  export declare const DEFAULT_POLICY: Policy;
26
37
  /** Idle gaps longer than this do not count as active time. */
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SHADOW_MIN_DAYS = exports.SHADOW_MIN_DECISIONS = exports.ACTIVE_GAP_CAP_MS = exports.DEFAULT_POLICY = exports.DEFAULT_THRESHOLDS = void 0;
3
+ exports.SHADOW_MIN_DAYS = exports.SHADOW_MIN_DECISIONS = exports.ACTIVE_GAP_CAP_MS = exports.DEFAULT_POLICY = exports.DEFAULT_CALL_ESTIMATE_TOKENS = exports.CALL_RESERVATION_TTL_MS = exports.DEFAULT_LOCAL_COMPUTE = exports.DEFAULT_THRESHOLDS = void 0;
4
4
  /**
5
5
  * Shipped thresholds.
6
6
  *
@@ -40,7 +40,22 @@ exports.DEFAULT_THRESHOLDS = {
40
40
  windowActiveMinutes: 300,
41
41
  warnConcurrentSessions: 4,
42
42
  },
43
+ // Local model runtimes. Elapsed request time is not GPU utilisation (it
44
+ // includes queueing and transport), so it is called occupied time and it
45
+ // only warns unless the operator sets a ceiling for their own hardware.
46
+ localCompute: {
47
+ windowMs: 15 * 60 * 1000,
48
+ warnConcurrent: 4,
49
+ stopConcurrent: null,
50
+ warnOccupiedMs: null,
51
+ stopOccupiedMs: null,
52
+ },
43
53
  };
54
+ exports.DEFAULT_LOCAL_COMPUTE = exports.DEFAULT_THRESHOLDS.localCompute;
55
+ /** How long a model-call reservation may stay open before it is presumed dead. */
56
+ exports.CALL_RESERVATION_TTL_MS = 30 * 60 * 1000;
57
+ /** Tokens assumed for a call whose caller gave no estimate. Replaced by the real count on completion. */
58
+ exports.DEFAULT_CALL_ESTIMATE_TOKENS = 0;
44
59
  /** First-run policy. Shadow: evaluate and record, never block. */
45
60
  exports.DEFAULT_POLICY = {
46
61
  mode: 'shadow',
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The local-compute plane.
3
+ *
4
+ * On a hosted API the loss is dollars, and the two session detectors in
5
+ * evaluate.ts cover it. On a local runtime the loss is different: the GPU is
6
+ * already paid for, so what a runaway costs you is the machine itself, wedged
7
+ * behind eight concurrent requests for an hour. Concurrency and occupied
8
+ * request time are the honest signals we can see from a proxy.
9
+ *
10
+ * This ships WARN-only. We cannot see the hardware, so a universal STOP would
11
+ * be a guess, and a guessed STOP is what gets a safety tool uninstalled.
12
+ * Operators who know their server set the ceilings in burn-policy.json.
13
+ */
14
+ import type { ComputeSnapshot } from '../state/reservations';
15
+ import type { Finding, Thresholds, Verdict } from '../types';
16
+ export declare function evaluateLocalCompute(snapshot: ComputeSnapshot, thresholds: Thresholds): {
17
+ verdict: Verdict;
18
+ findings: Finding[];
19
+ };
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ /**
3
+ * The local-compute plane.
4
+ *
5
+ * On a hosted API the loss is dollars, and the two session detectors in
6
+ * evaluate.ts cover it. On a local runtime the loss is different: the GPU is
7
+ * already paid for, so what a runaway costs you is the machine itself, wedged
8
+ * behind eight concurrent requests for an hour. Concurrency and occupied
9
+ * request time are the honest signals we can see from a proxy.
10
+ *
11
+ * This ships WARN-only. We cannot see the hardware, so a universal STOP would
12
+ * be a guess, and a guessed STOP is what gets a safety tool uninstalled.
13
+ * Operators who know their server set the ceilings in burn-policy.json.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.evaluateLocalCompute = evaluateLocalCompute;
17
+ const defaults_1 = require("../defaults");
18
+ function evaluateLocalCompute(snapshot, thresholds) {
19
+ const t = thresholds.localCompute ?? defaults_1.DEFAULT_LOCAL_COMPUTE;
20
+ const findings = [];
21
+ let verdict = 'OK';
22
+ const minutes = Math.round(snapshot.windowMs / 60_000);
23
+ // The candidate call is already reserved, so inFlight includes it.
24
+ if (t.stopConcurrent !== null && snapshot.inFlight > t.stopConcurrent) {
25
+ findings.push({
26
+ detector: 'local_compute',
27
+ verdict: 'STOP',
28
+ summary: `${snapshot.inFlight} model calls in flight on this machine; ceiling is ${t.stopConcurrent}.`,
29
+ observed: snapshot.inFlight,
30
+ threshold: t.stopConcurrent,
31
+ });
32
+ verdict = 'STOP';
33
+ }
34
+ else if (snapshot.inFlight >= t.warnConcurrent) {
35
+ findings.push({
36
+ detector: 'local_compute',
37
+ verdict: 'WARN',
38
+ summary: `${snapshot.inFlight} model calls in flight on this machine.`,
39
+ observed: snapshot.inFlight,
40
+ threshold: t.warnConcurrent,
41
+ });
42
+ verdict = 'WARN';
43
+ }
44
+ const occupiedSec = Math.round(snapshot.occupiedMs / 1000);
45
+ if (t.stopOccupiedMs !== null && snapshot.occupiedMs >= t.stopOccupiedMs) {
46
+ findings.push({
47
+ detector: 'local_compute',
48
+ verdict: 'STOP',
49
+ summary: `${occupiedSec}s of occupied request time in the last ${minutes} min; ceiling is ${Math.round(t.stopOccupiedMs / 1000)}s.`,
50
+ observed: snapshot.occupiedMs,
51
+ threshold: t.stopOccupiedMs,
52
+ });
53
+ verdict = 'STOP';
54
+ }
55
+ else if (t.warnOccupiedMs !== null && snapshot.occupiedMs >= t.warnOccupiedMs && verdict === 'OK') {
56
+ findings.push({
57
+ detector: 'local_compute',
58
+ verdict: 'WARN',
59
+ summary: `${occupiedSec}s of occupied request time in the last ${minutes} min.`,
60
+ observed: snapshot.occupiedMs,
61
+ threshold: t.warnOccupiedMs,
62
+ });
63
+ verdict = 'WARN';
64
+ }
65
+ return { verdict, findings };
66
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The host-neutral event vocabulary.
3
+ *
4
+ * Every adapter, whatever it can see, speaks this. A Cursor subagentStart
5
+ * hook, a Codex PreToolUse hook, an Ollama response through the proxy, and an
6
+ * orchestrator's beforeSpawn call all normalise into these shapes, and the
7
+ * detectors never learn which host produced them. That is the whole
8
+ * cross-tool claim in one file: one policy can only be enforced identically
9
+ * if the inputs are identical in kind.
10
+ *
11
+ * What is deliberately absent: prompts, completions, source code, transcript
12
+ * paths, raw tool arguments. Surfaces are digests. Session IDs are opaque.
13
+ *
14
+ * Coverage is part of correctness, not marketing. An adapter that cannot see
15
+ * a signal says so, so an OK verdict never masquerades as full visibility.
16
+ */
17
+ export type HostId = 'claude-code' | 'cursor' | 'codex' | 'ollama' | 'vllm' | 'lm-studio' | 'openai-compatible' | 'raw-api';
18
+ /** How much an adapter can actually see of each policy plane. */
19
+ export type Coverage = 'authoritative' | 'estimated' | 'missing';
20
+ export interface HostCapabilities {
21
+ spawns: Coverage;
22
+ depth: Coverage;
23
+ usage: Coverage;
24
+ }
25
+ /**
26
+ * How sure the adapter is that this event belongs to the session it names.
27
+ * A proxy request carrying x-agentguard-session is high. A proxy request
28
+ * identified only by client port is low, and a low-confidence session can
29
+ * never be hard-STOPped on session-scope grounds: reconnects fragment it.
30
+ */
31
+ export type Attribution = 'high' | 'low';
32
+ interface Base {
33
+ schemaVersion: 1;
34
+ eventId: string;
35
+ host: HostId;
36
+ /** Opaque logical session. Shared across hosts when the integration propagates it. */
37
+ sessionId: string;
38
+ at: number;
39
+ }
40
+ export interface SessionOpened extends Base {
41
+ kind: 'session_opened';
42
+ capabilities: HostCapabilities;
43
+ }
44
+ /** A model call is about to be made. Reserves the estimate under callId. */
45
+ export interface CallRequested extends Base {
46
+ kind: 'call_requested';
47
+ callId: string;
48
+ estimatedTokens: number;
49
+ attribution: Attribution;
50
+ }
51
+ /** Usage was observed. With a callId it *replaces* that call's reservation. */
52
+ export interface ModelUsageObserved extends Base {
53
+ kind: 'model_usage';
54
+ /** input + output. Cache-read is carried for explanation only. */
55
+ tokens: number;
56
+ cacheRead: number;
57
+ usageCoverage: Coverage;
58
+ callId?: string;
59
+ }
60
+ export interface ToolSurfaceRead extends Base {
61
+ kind: 'surface_read';
62
+ /** sha256 of the normalised path or pattern, never the path itself. */
63
+ surfaceDigest: string;
64
+ }
65
+ export interface SpawnRequested extends Base {
66
+ kind: 'spawn_requested';
67
+ spawnId: string;
68
+ /** Depth the child would have, when the host knows it. Root issuer proposes 1. */
69
+ proposedDepth?: number;
70
+ /** The agent issuing the spawn, when the host names it. Used to derive depth. */
71
+ issuerId?: string;
72
+ attribution: Attribution;
73
+ }
74
+ export interface SpawnStarted extends Base {
75
+ kind: 'spawn_started';
76
+ spawnId: string;
77
+ depth: number;
78
+ }
79
+ export interface SpawnFinished extends Base {
80
+ kind: 'spawn_finished';
81
+ spawnId: string;
82
+ }
83
+ export interface SessionClosed extends Base {
84
+ kind: 'session_closed';
85
+ }
86
+ export type AgentEvent = SessionOpened | CallRequested | ModelUsageObserved | ToolSurfaceRead | SpawnRequested | SpawnStarted | SpawnFinished | SessionClosed;
87
+ /** Session IDs are propagated between hosts through these, and only these. */
88
+ export declare const SESSION_HEADER = "x-agentguard-session";
89
+ export declare const CALL_HEADER = "x-agentguard-call";
90
+ export declare const SESSION_ENV = "AGENTGUARD_SESSION_ID";
91
+ export declare function isValidSessionId(value: string): boolean;
92
+ export declare function eventId(): string;
93
+ export declare const CAPABILITIES: Record<HostId, HostCapabilities>;
94
+ export {};
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ /**
3
+ * The host-neutral event vocabulary.
4
+ *
5
+ * Every adapter, whatever it can see, speaks this. A Cursor subagentStart
6
+ * hook, a Codex PreToolUse hook, an Ollama response through the proxy, and an
7
+ * orchestrator's beforeSpawn call all normalise into these shapes, and the
8
+ * detectors never learn which host produced them. That is the whole
9
+ * cross-tool claim in one file: one policy can only be enforced identically
10
+ * if the inputs are identical in kind.
11
+ *
12
+ * What is deliberately absent: prompts, completions, source code, transcript
13
+ * paths, raw tool arguments. Surfaces are digests. Session IDs are opaque.
14
+ *
15
+ * Coverage is part of correctness, not marketing. An adapter that cannot see
16
+ * a signal says so, so an OK verdict never masquerades as full visibility.
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.CAPABILITIES = exports.SESSION_ENV = exports.CALL_HEADER = exports.SESSION_HEADER = void 0;
20
+ exports.isValidSessionId = isValidSessionId;
21
+ exports.eventId = eventId;
22
+ /** Session IDs are propagated between hosts through these, and only these. */
23
+ exports.SESSION_HEADER = 'x-agentguard-session';
24
+ exports.CALL_HEADER = 'x-agentguard-call';
25
+ exports.SESSION_ENV = 'AGENTGUARD_SESSION_ID';
26
+ const SESSION_ID_RE = /^[A-Za-z0-9._:@/-]{1,256}$/;
27
+ function isValidSessionId(value) {
28
+ return SESSION_ID_RE.test(value);
29
+ }
30
+ function eventId() {
31
+ // Node built-ins only. Time-ordered enough for dedupe, unique enough for logs.
32
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
33
+ }
34
+ exports.CAPABILITIES = {
35
+ 'claude-code': { spawns: 'authoritative', depth: 'authoritative', usage: 'authoritative' },
36
+ // Cursor names the subagent and its parent; hosted-model usage is never exposed.
37
+ cursor: { spawns: 'authoritative', depth: 'estimated', usage: 'missing' },
38
+ // Codex gates spawn_agent through PreToolUse; the transcript is a locator, not a contract.
39
+ codex: { spawns: 'authoritative', depth: 'estimated', usage: 'estimated' },
40
+ // A proxy sees every token and no tree.
41
+ ollama: { spawns: 'missing', depth: 'missing', usage: 'authoritative' },
42
+ vllm: { spawns: 'missing', depth: 'missing', usage: 'authoritative' },
43
+ 'lm-studio': { spawns: 'missing', depth: 'missing', usage: 'authoritative' },
44
+ 'openai-compatible': { spawns: 'missing', depth: 'missing', usage: 'estimated' },
45
+ // The orchestrator knows everything, because it is the one doing it.
46
+ 'raw-api': { spawns: 'authoritative', depth: 'authoritative', usage: 'authoritative' },
47
+ };