@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,135 @@
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.overridden) {
68
+ return { permission: 'allow', agent_message: `AgentGuard STOP overridden${decision.overridden.once ? ' once' : ''} ("${decision.overridden.reason}"): ${decision.report.findings[0]?.summary ?? ''}` };
69
+ }
70
+ if (decision.notify && decision.verdict !== 'OK') {
71
+ return {
72
+ permission: 'allow',
73
+ agent_message: `AgentGuard ${decision.verdict}${decision.mode === 'shadow' && decision.wouldBlock ? ' (shadow: would have blocked)' : ''}: ${decision.report.findings[0]?.summary ?? ''}`,
74
+ };
75
+ }
76
+ return { permission: 'allow' };
77
+ }
78
+ if (input.event === 'subagentStop') {
79
+ const spawnId = input.subagentId ?? input.toolCallId;
80
+ if (spawnId) {
81
+ quietly(() => gateway.observe([{ schemaVersion: 1, kind: 'spawn_finished', eventId: `cursor:stop:${spawnId}`, host: HOST, sessionId: input.sessionId, at, spawnId }]));
82
+ }
83
+ return {};
84
+ }
85
+ if (input.event === 'sessionEnd') {
86
+ quietly(() => gateway.observe([{ schemaVersion: 1, kind: 'session_closed', eventId: `cursor:end:${input.sessionId}:${at}`, host: HOST, sessionId: input.sessionId, at }]));
87
+ return {};
88
+ }
89
+ return {};
90
+ }
91
+ function quietly(fn) {
92
+ try {
93
+ fn();
94
+ }
95
+ catch (error) {
96
+ process.stderr.write(`agentguard-burn: could not record observation: ${error instanceof Error ? error.message : String(error)}\n`);
97
+ }
98
+ }
99
+ function parse(value) {
100
+ if (!value || typeof value !== 'object' || Array.isArray(value))
101
+ return null;
102
+ const v = value;
103
+ const str = (...keys) => {
104
+ for (const k of keys) {
105
+ const x = v[k];
106
+ if (typeof x === 'string' && x.length > 0)
107
+ return x;
108
+ }
109
+ return undefined;
110
+ };
111
+ const name = (str('hook_event_name') ?? '').toLowerCase();
112
+ const event = name === 'subagentstart' ? 'subagentStart' : name === 'subagentstop' ? 'subagentStop' : name === 'sessionend' ? 'sessionEnd' : 'other';
113
+ const conversation = str('conversation_id');
114
+ const sessionId = conversation && (0, events_1.isValidSessionId)(conversation) ? conversation : 'cursor:unknown';
115
+ const ts = v.timestamp_ms ?? v.occurred_at_ms;
116
+ return {
117
+ event,
118
+ sessionId,
119
+ subagentId: str('subagent_id'),
120
+ parentConversationId: str('parent_conversation_id'),
121
+ toolCallId: str('tool_call_id'),
122
+ at: typeof ts === 'number' && Number.isFinite(ts) ? ts : undefined,
123
+ };
124
+ }
125
+ /** The ~/.cursor/hooks.json fragment. failClosed is on: a dead hook denies. */
126
+ function cursorHooksSnippet(command) {
127
+ return {
128
+ version: 1,
129
+ hooks: {
130
+ subagentStart: [{ command, timeout: 5, failClosed: true }],
131
+ subagentStop: [{ command, timeout: 5 }],
132
+ sessionEnd: [{ command, timeout: 3 }],
133
+ },
134
+ };
135
+ }
@@ -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>;
@@ -0,0 +1,130 @@
1
+ "use strict";
2
+ /**
3
+ * Raw orchestrator middleware.
4
+ *
5
+ * The strongest position of the adapter shapes, because the orchestrator
6
+ * knows things no host exposes: stable spawn IDs, the parent of each child,
7
+ * and the request before it leaves the process. That is why this adapter is
8
+ * the one that makes the full "40 spawns, depth 2, 5B tokens" claim true for
9
+ * an open-weights agent. A proxy alone sees tokens and no tree; a hook alone
10
+ * sees the tree and no tokens; this sees both.
11
+ *
12
+ * const burn = createRawApiGuard({ sessionId: 'nightly-refactor-17' });
13
+ *
14
+ * const spawn = burn.beforeSpawn({ parentDepth: 0 });
15
+ * spawn.throwIfBlocked();
16
+ * spawn.started();
17
+ * try { await worker() } finally { spawn.finished() }
18
+ *
19
+ * const call = burn.beforeCall({ estimatedTokens: 120_000 });
20
+ * call.throwIfBlocked();
21
+ * try {
22
+ * const res = await client.chat({ ..., headers: call.headers });
23
+ * call.complete({ tokens: res.usage.total_tokens });
24
+ * } catch (e) { call.fail(); throw e }
25
+ *
26
+ * A model call reserves an estimate and then commits the real number under
27
+ * the same call ID, so a proxy observing the same request (via call.headers)
28
+ * supersedes rather than duplicates it.
29
+ */
30
+ Object.defineProperty(exports, "__esModule", { value: true });
31
+ exports.BurnStopError = void 0;
32
+ exports.createRawApiGuard = createRawApiGuard;
33
+ const node_os_1 = require("node:os");
34
+ const node_path_1 = require("node:path");
35
+ const events_1 = require("../events");
36
+ const gateway_1 = require("../gateway");
37
+ const render_1 = require("../replay/render");
38
+ class BurnStopError extends Error {
39
+ decision;
40
+ constructor(decision) {
41
+ super((0, render_1.renderStop)(decision.report, { colour: false, subject: decision.action === 'spawn' ? 'spawn' : 'call' }));
42
+ this.decision = decision;
43
+ this.name = 'BurnStopError';
44
+ }
45
+ }
46
+ exports.BurnStopError = BurnStopError;
47
+ const HOST = 'raw-api';
48
+ function createRawApiGuard(opts) {
49
+ const home = opts.home ?? process.env.AGENTGUARD_HOME ?? (0, node_path_1.join)((0, node_os_1.homedir)(), '.agentguard');
50
+ const gateway = new gateway_1.Gateway(home, { sign: opts.sign, now: opts.now });
51
+ const now = opts.now ?? (() => Date.now());
52
+ const { sessionId } = opts;
53
+ gateway.observe([
54
+ { schemaVersion: 1, kind: 'session_opened', eventId: (0, events_1.eventId)(), host: HOST, sessionId, at: now(), capabilities: { spawns: 'authoritative', depth: 'authoritative', usage: 'authoritative' } },
55
+ ]);
56
+ return {
57
+ sessionId,
58
+ gateway,
59
+ beforeSpawn(args = { parentDepth: 0 }) {
60
+ const spawnId = args.spawnId ?? (0, events_1.eventId)();
61
+ const depth = args.parentDepth + 1;
62
+ const decision = gateway.beforeSpawn({
63
+ schemaVersion: 1,
64
+ kind: 'spawn_requested',
65
+ eventId: (0, events_1.eventId)(),
66
+ host: HOST,
67
+ sessionId,
68
+ at: now(),
69
+ spawnId,
70
+ proposedDepth: depth,
71
+ attribution: 'high',
72
+ });
73
+ return {
74
+ spawnId,
75
+ depth,
76
+ decision,
77
+ get blocked() {
78
+ return decision.blocked;
79
+ },
80
+ throwIfBlocked() {
81
+ if (decision.blocked)
82
+ throw new BurnStopError(decision);
83
+ },
84
+ started() {
85
+ gateway.observe([{ schemaVersion: 1, kind: 'spawn_started', eventId: (0, events_1.eventId)(), host: HOST, sessionId, at: now(), spawnId, depth }]);
86
+ },
87
+ finished() {
88
+ gateway.observe([{ schemaVersion: 1, kind: 'spawn_finished', eventId: (0, events_1.eventId)(), host: HOST, sessionId, at: now(), spawnId }]);
89
+ },
90
+ };
91
+ },
92
+ beforeCall(args = {}) {
93
+ const callId = args.callId ?? (0, events_1.eventId)();
94
+ const decision = gateway.beforeCall({
95
+ schemaVersion: 1,
96
+ kind: 'call_requested',
97
+ eventId: (0, events_1.eventId)(),
98
+ host: HOST,
99
+ sessionId,
100
+ at: now(),
101
+ callId,
102
+ estimatedTokens: Math.max(0, args.estimatedTokens ?? 0),
103
+ attribution: 'high',
104
+ });
105
+ return {
106
+ callId,
107
+ decision,
108
+ /** Attach to the outbound request so a local proxy correlates it. */
109
+ headers: { [events_1.SESSION_HEADER]: sessionId, [events_1.CALL_HEADER]: callId },
110
+ get blocked() {
111
+ return decision.blocked;
112
+ },
113
+ throwIfBlocked() {
114
+ if (decision.blocked)
115
+ throw new BurnStopError(decision);
116
+ },
117
+ complete(usage) {
118
+ gateway.completeCall({ host: HOST, sessionId, callId, tokens: usage.tokens, cacheRead: usage.cacheRead, usageCoverage: 'authoritative', at: now() });
119
+ },
120
+ fail() {
121
+ gateway.failCall({ host: HOST, sessionId, callId, at: now() });
122
+ },
123
+ };
124
+ },
125
+ /** Current state without deciding anything. */
126
+ status() {
127
+ return gateway.peek(sessionId);
128
+ },
129
+ };
130
+ }
package/dist/src/cli.d.ts CHANGED
@@ -4,11 +4,15 @@
4
4
  *
5
5
  * replay what enforcement would have stopped, on your history
6
6
  * calibrate fit thresholds to your own usage, write shadow policy
7
- * status current mode, shadow decisions, promotion eligibility
8
- * init install the PreToolUse hook (shadow mode)
7
+ * status mode, shadow decisions, eligibility, every host's sessions
8
+ * init [host] hook snippet for claude (default), cursor or codex
9
9
  * enforce promote shadow -> enforce, once eligible
10
10
  * shadow demote back to shadow
11
11
  * resume --once one audited override of the next STOP
12
- * hook (internal) stdin -> stdout hook entry point
12
+ * proxy loopback proxy in front of Ollama / vLLM / LM Studio
13
+ * conformance prove the storm and the grind stop identically per host
14
+ * hook (internal) Claude Code stdin -> stdout hook entry point
15
+ * cursor-hook (internal) Cursor hook entry point
16
+ * codex-hook (internal) Codex hook entry point
13
17
  */
14
18
  export {};
package/dist/src/cli.js CHANGED
@@ -5,23 +5,45 @@
5
5
  *
6
6
  * replay what enforcement would have stopped, on your history
7
7
  * calibrate fit thresholds to your own usage, write shadow policy
8
- * status current mode, shadow decisions, promotion eligibility
9
- * init install the PreToolUse hook (shadow mode)
8
+ * status mode, shadow decisions, eligibility, every host's sessions
9
+ * init [host] hook snippet for claude (default), cursor or codex
10
10
  * enforce promote shadow -> enforce, once eligible
11
11
  * shadow demote back to shadow
12
12
  * resume --once one audited override of the next STOP
13
- * hook (internal) stdin -> stdout hook entry point
13
+ * proxy loopback proxy in front of Ollama / vLLM / LM Studio
14
+ * conformance prove the storm and the grind stop identically per host
15
+ * hook (internal) Claude Code stdin -> stdout hook entry point
16
+ * cursor-hook (internal) Cursor hook entry point
17
+ * codex-hook (internal) Codex hook entry point
14
18
  */
15
19
  Object.defineProperty(exports, "__esModule", { value: true });
16
20
  const node_fs_1 = require("node:fs");
17
21
  const node_os_1 = require("node:os");
18
22
  const node_path_1 = require("node:path");
23
+ const cursor_1 = require("./adapters/cursor");
24
+ const codex_1 = require("./adapters/codex");
19
25
  const calibrate_1 = require("./calibrate");
26
+ const conformance_1 = require("./conformance");
20
27
  const defaults_1 = require("./defaults");
28
+ const gateway_1 = require("./gateway");
21
29
  const pre_tool_use_1 = require("./hook/pre-tool-use");
30
+ const install_1 = require("./install");
31
+ const override_1 = require("./override");
32
+ const server_1 = require("./proxy/server");
22
33
  const render_1 = require("./replay/render");
23
34
  const simulate_1 = require("./replay/simulate");
35
+ const status_1 = require("./status");
24
36
  const HOME = process.env.AGENTGUARD_HOME ?? (0, node_path_1.join)((0, node_os_1.homedir)(), '.agentguard');
37
+ const PROXY_HOSTS = ['ollama', 'vllm', 'lm-studio', 'openai-compatible'];
38
+ function readStdinJson() {
39
+ try {
40
+ return JSON.parse((0, node_fs_1.readFileSync)(0, 'utf8'));
41
+ }
42
+ catch {
43
+ // Unparseable payload: allow. We never break the host over our own bug.
44
+ return null;
45
+ }
46
+ }
25
47
  function savePolicy(policy) {
26
48
  (0, node_fs_1.mkdirSync)(HOME, { recursive: true, mode: 0o700 });
27
49
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(HOME, 'burn-policy.json'), JSON.stringify(policy, null, 2), { mode: 0o600 });
@@ -109,23 +131,109 @@ async function main(argv) {
109
131
  case 'status': {
110
132
  const policy = (0, pre_tool_use_1.loadPolicy)(HOME);
111
133
  const e = shadowEligibility();
134
+ const gateway = new gateway_1.Gateway(HOME, { sign: false });
135
+ const lc = policy.thresholds.localCompute;
136
+ const override = (0, override_1.readOverride)(HOME);
112
137
  process.stdout.write([
113
- `mode: ${policy.mode}`,
114
- `thresholds: fan-out ${policy.thresholds.fanout.warn}/${policy.thresholds.fanout.stop} sustained ${(policy.thresholds.sustained.warnTokens / 1e9).toFixed(1)}B/${(policy.thresholds.sustained.stopTokens / 1e9).toFixed(1)}B`,
138
+ `mode: ${policy.mode}${policy.mode === 'enforce' ? ' (the next STOP blocks)' : ' (recording only; nothing is blocked)'}`,
139
+ `thresholds: fan-out ${policy.thresholds.fanout.warn}/${policy.thresholds.fanout.stop} sustained ${(policy.thresholds.sustained.warnTokens / 1e9).toFixed(1)}B/${(policy.thresholds.sustained.stopTokens / 1e9).toFixed(1)}B local-compute warn at ${lc?.warnConcurrent ?? 4} concurrent${lc?.stopConcurrent ? `, stop at ${lc.stopConcurrent}` : ' (no stop set)'}`,
115
140
  policy.calibration ? `calibrated from ${policy.calibration.sessionsSampled} sessions` : 'using shipped defaults (run: agentguard-burn calibrate)',
116
141
  `shadow observation: ${e.decisions} decisions over ${e.days.toFixed(1)} days`,
117
142
  ` would have warned: ${e.warns} would have blocked: ${e.wouldBlock}`,
118
- `eligible for enforcement: ${e.eligible ? 'yes' : `no (need ${defaults_1.SHADOW_MIN_DECISIONS} decisions and ${defaults_1.SHADOW_MIN_DAYS} days)`}`,
119
- ].join('\n') + '\n');
143
+ `eligible for enforcement: ${e.eligible ? 'yes (agentguard-burn enforce)' : `no (need ${defaults_1.SHADOW_MIN_DECISIONS} decisions and ${defaults_1.SHADOW_MIN_DAYS} days)`}`,
144
+ override ? `override ACTIVE: ${override.once ? 'the next STOP' : `every STOP until ${new Date(override.until).toLocaleTimeString()}`} passes ("${override.reason}")` : '',
145
+ '',
146
+ (0, status_1.renderHostHealth)(['claude', 'cursor', 'codex'].map((h) => (0, install_1.health)(h))),
147
+ '',
148
+ (0, status_1.renderMachineStatus)([...(0, status_1.readHookSessions)(HOME), ...gateway.sessions()], gateway.compute()),
149
+ '',
150
+ 'content logging: off · telemetry: none · state: this machine only',
151
+ ]
152
+ .filter((l) => l !== '' || true)
153
+ .join('\n')
154
+ .replace(/\n\n\n/g, '\n\n') + '\n');
120
155
  return 0;
121
156
  }
122
157
  case 'init': {
123
158
  const policy = (0, pre_tool_use_1.loadPolicy)(HOME);
124
159
  savePolicy(policy);
125
- const command = `node ${(0, node_path_1.join)(__dirname, 'cli.js')} hook`;
126
- process.stdout.write(`Add this to ~/.claude/settings.json (merge into existing "hooks"):\n\n${JSON.stringify((0, pre_tool_use_1.settingsSnippet)(command), null, 2)}\n\nInstalled in ${policy.mode} mode. Nothing is blocked until you run: agentguard-burn enforce\n`);
160
+ const cli = (0, node_path_1.join)(__dirname, 'cli.js');
161
+ const target = (rest.find((a) => !a.startsWith('--')) ?? 'claude');
162
+ if (!['claude', 'cursor', 'codex'].includes(target)) {
163
+ process.stderr.write(`init: host must be claude, cursor or codex\n`);
164
+ return 64;
165
+ }
166
+ const label = { claude: '', cursor: 'Cursor support is BETA: verified against the documented hook schema, not yet against every installed build. "failClosed": true means a crashed hook denies the subagent.', codex: 'Codex support is BETA: a live deny, allow and override canary passed on codex-cli 0.151.0; transcript usage is best-effort and marked estimated. Trust the hook once with /hooks inside Codex.' }[target];
167
+ const tail = `Installed in ${policy.mode} mode. Nothing is blocked until you run: agentguard-burn enforce\n`;
168
+ if (has('--write')) {
169
+ let result;
170
+ try {
171
+ result = (0, install_1.install)(target, cli);
172
+ }
173
+ catch (error) {
174
+ process.stderr.write(`init: refusing to touch ${(0, install_1.configPath)(target)}: ${error instanceof Error ? error.message : String(error)}\n`);
175
+ return 1;
176
+ }
177
+ process.stdout.write((result.changed ? `Wrote ${result.file}${result.backup ? ` (backup: ${result.backup})` : ''}\n` : `${result.file} already has the current hook; nothing changed.\n`) +
178
+ ` ${result.command}\n` +
179
+ (label ? `${label}\n` : '') +
180
+ tail +
181
+ `Check it: agentguard-burn status\n`);
182
+ return 0;
183
+ }
184
+ const snippet = target === 'cursor' ? (0, cursor_1.cursorHooksSnippet)(`node ${cli} cursor-hook`) : target === 'codex' ? (0, codex_1.codexHooksSnippet)(`node ${cli} codex-hook`) : (0, pre_tool_use_1.settingsSnippet)(`node ${cli} hook`);
185
+ process.stdout.write(`Add this to ${(0, install_1.configPath)(target).replace(process.env.HOME ?? '', '~')} (merge into existing "hooks"), or run: agentguard-burn init ${target} --write\n\n${JSON.stringify(snippet, null, 2)}\n\n` +
186
+ (label ? `${label}\n` : '') +
187
+ tail +
188
+ (target === 'claude' ? `Other hosts: agentguard-burn init cursor | init codex | proxy --upstream http://127.0.0.1:11434 --host ollama\n` : ''));
189
+ return 0;
190
+ }
191
+ case 'cursor-hook': {
192
+ const gateway = new gateway_1.Gateway(HOME);
193
+ process.stdout.write(JSON.stringify((0, cursor_1.handleCursorHook)(readStdinJson(), gateway)));
127
194
  return 0;
128
195
  }
196
+ case 'codex-hook': {
197
+ const gateway = new gateway_1.Gateway(HOME);
198
+ process.stdout.write(JSON.stringify((0, codex_1.handleCodexHook)(readStdinJson(), gateway)));
199
+ return 0;
200
+ }
201
+ case 'proxy': {
202
+ const upstreamRaw = flag('--upstream') ?? 'http://127.0.0.1:11434';
203
+ const host = (flag('--host') ?? 'ollama');
204
+ if (!PROXY_HOSTS.includes(host)) {
205
+ process.stderr.write(`--host must be one of ${PROXY_HOSTS.join(', ')}\n`);
206
+ return 64;
207
+ }
208
+ const listen = flag('--listen') ?? '127.0.0.1:18080';
209
+ const [listenHost, listenPortRaw] = listen.includes(':') ? [listen.slice(0, listen.lastIndexOf(':')), listen.slice(listen.lastIndexOf(':') + 1)] : ['127.0.0.1', listen];
210
+ const gateway = new gateway_1.Gateway(HOME);
211
+ const running = await (0, server_1.startProxy)({
212
+ upstream: new URL(upstreamRaw),
213
+ gateway,
214
+ host,
215
+ listenHost,
216
+ listenPort: Number(listenPortRaw),
217
+ defaultSession: flag('--session') ?? process.env.AGENTGUARD_SESSION_ID,
218
+ allowRemoteUpstream: has('--allow-remote-upstream'),
219
+ log: (l) => process.stderr.write(`${l}\n`),
220
+ });
221
+ const policy = (0, pre_tool_use_1.loadPolicy)(HOME);
222
+ process.stderr.write(`agentguard-burn proxy ${running.address.origin} -> ${upstreamRaw} (${host}, ${policy.mode} mode)\n` +
223
+ `Point your agent at ${running.address.origin}. Send x-agentguard-session: <id> so calls join a session; without it each client port is its own low-confidence session.\n` +
224
+ `Ctrl-C to stop.\n`);
225
+ await new Promise((resolve) => {
226
+ const stop = () => running.close().then(resolve, resolve);
227
+ process.once('SIGINT', stop);
228
+ process.once('SIGTERM', stop);
229
+ });
230
+ return 0;
231
+ }
232
+ case 'conformance': {
233
+ const result = await (0, conformance_1.runConformance)();
234
+ process.stdout.write(result.text + '\n');
235
+ return result.ok ? 0 : 1;
236
+ }
129
237
  case 'enforce': {
130
238
  const policy = (0, pre_tool_use_1.loadPolicy)(HOME);
131
239
  const e = shadowEligibility();
@@ -143,20 +251,36 @@ async function main(argv) {
143
251
  return 0;
144
252
  }
145
253
  case 'resume': {
146
- const reason = flag('--reason') ?? 'no reason given';
147
- (0, node_fs_1.mkdirSync)(HOME, { recursive: true, mode: 0o700 });
148
- (0, node_fs_1.writeFileSync)((0, node_path_1.join)(HOME, 'override.json'), JSON.stringify({ at: Date.now(), once: has('--once'), reason }), { mode: 0o600 });
149
- process.stdout.write(`Override recorded${has('--once') ? ' for the next STOP only' : ''}: ${reason}\n`);
254
+ if (has('--clear')) {
255
+ (0, override_1.clearOverride)(HOME);
256
+ process.stdout.write('Override cleared. The next STOP blocks.\n');
257
+ return 0;
258
+ }
259
+ const reason = flag('--reason');
260
+ if (!reason) {
261
+ process.stderr.write('resume: --reason "..." is required; every override is written to the decisions ledger with it.\n');
262
+ return 64;
263
+ }
264
+ const once = has('--once');
265
+ const now = Date.now();
266
+ (0, override_1.writeOverride)(HOME, once ? { at: now, once: true, reason } : { at: now, once: false, reason, until: now + override_1.OVERRIDE_WINDOW_MS });
267
+ process.stdout.write(once
268
+ ? `Override recorded for the next STOP only, on any host: "${reason}"\n`
269
+ : `Override recorded for the next ${override_1.OVERRIDE_WINDOW_MS / 60000} minutes, on any host: "${reason}"\n (agentguard-burn resume --clear to end it early)\n`);
150
270
  return 0;
151
271
  }
152
272
  default:
153
- process.stdout.write('agentguard-burn <replay|calibrate|status|init|enforce|shadow|resume|hook>\n' +
273
+ process.stdout.write('agentguard-burn <replay|calibrate|status|init|enforce|shadow|resume|proxy|conformance>\n' +
154
274
  ' replay [files...] [--json] [--top N] [--min-tokens N]\n' +
155
275
  ' calibrate fit thresholds to your history (writes shadow policy)\n' +
156
- ' status mode, shadow observations, eligibility\n' +
157
- ' init print the settings.json hook snippet\n' +
276
+ ' status mode, shadow observations, eligibility, hook health, every host\n' +
277
+ ' init [claude|cursor|codex] [--write] print the hook snippet, or merge it into the config (with a backup)\n' +
158
278
  ' enforce [--force] shadow -> enforce\n' +
159
- ' resume --once --reason "..."\n');
279
+ ' shadow enforce -> shadow\n' +
280
+ ' resume --once --reason "..." let the next STOP through, once, on any host\n' +
281
+ ' resume --reason "..." let STOPs through for 15 minutes; --clear ends it\n' +
282
+ ' proxy --upstream http://127.0.0.1:11434 --host ollama|vllm|lm-studio|openai-compatible [--listen 127.0.0.1:18080] [--session ID]\n' +
283
+ ' conformance replay the storm and the grind through every adapter shape\n');
160
284
  return command ? 64 : 0;
161
285
  }
162
286
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Conformance: the same failure, through every door, stops at the same step.
3
+ *
4
+ * Two fixtures, fitted on real sessions:
5
+ *
6
+ * the storm 42 candidate spawns. First WARN must be spawn 24, first STOP
7
+ * must be spawn 41. Every spawn-capable adapter replays it.
8
+ * the grind model calls of 250M tokens each. First WARN must be the call
9
+ * after 3.5B, first STOP the call after 5B. Every usage-capable
10
+ * adapter replays it.
11
+ * composite raw middleware supplies the spawns, the proxy supplies the
12
+ * usage, one session ID. Candidate spawn 41 must see BOTH
13
+ * planes in its findings. That is the claim on the box.
14
+ *
15
+ * Runs in a throwaway home in enforce mode. Nothing here touches ~/.agentguard.
16
+ */
17
+ export interface ConformanceResult {
18
+ ok: boolean;
19
+ text: string;
20
+ checks: {
21
+ name: string;
22
+ ok: boolean;
23
+ detail: string;
24
+ }[];
25
+ }
26
+ export declare function runConformance(): Promise<ConformanceResult>;