@agentguard-run/burn 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/README.md +110 -1
- package/dist/src/adapters/codex.d.ts +48 -0
- package/dist/src/adapters/codex.js +194 -0
- package/dist/src/adapters/cursor.d.ts +35 -0
- package/dist/src/adapters/cursor.js +132 -0
- package/dist/src/adapters/raw-api.d.ts +76 -0
- package/dist/src/adapters/raw-api.js +130 -0
- package/dist/src/cli.d.ts +7 -3
- package/dist/src/cli.js +99 -10
- package/dist/src/conformance.d.ts +26 -0
- package/dist/src/conformance.js +261 -0
- package/dist/src/defaults.d.ts +11 -0
- package/dist/src/defaults.js +16 -1
- package/dist/src/detectors/local-compute.d.ts +19 -0
- package/dist/src/detectors/local-compute.js +66 -0
- package/dist/src/events.d.ts +94 -0
- package/dist/src/events.js +47 -0
- package/dist/src/gateway.d.ts +134 -0
- package/dist/src/gateway.js +522 -0
- package/dist/src/hook/pre-tool-use.js +5 -4
- package/dist/src/index.d.ts +15 -4
- package/dist/src/index.js +41 -1
- package/dist/src/proxy/server.d.ts +45 -0
- package/dist/src/proxy/server.js +169 -0
- package/dist/src/proxy/usage-observer.d.ts +40 -0
- package/dist/src/proxy/usage-observer.js +128 -0
- package/dist/src/receipt.d.ts +61 -0
- package/dist/src/receipt.js +98 -0
- package/dist/src/replay/render.d.ts +10 -3
- package/dist/src/replay/render.js +175 -44
- package/dist/src/replay/simulate.d.ts +4 -0
- package/dist/src/replay/simulate.js +24 -1
- package/dist/src/state/reservations.d.ts +115 -11
- package/dist/src/state/reservations.js +293 -59
- package/dist/src/state/session.d.ts +6 -0
- package/dist/src/state/session.js +17 -0
- package/dist/src/status.d.ts +11 -0
- package/dist/src/status.js +48 -0
- package/dist/src/types.d.ts +14 -1
- package/package.json +34 -7
|
@@ -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
|
|
8
|
-
* init
|
|
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
|
-
*
|
|
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,43 @@
|
|
|
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
|
|
9
|
-
* init
|
|
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
|
-
*
|
|
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 server_1 = require("./proxy/server");
|
|
22
31
|
const render_1 = require("./replay/render");
|
|
23
32
|
const simulate_1 = require("./replay/simulate");
|
|
33
|
+
const status_1 = require("./status");
|
|
24
34
|
const HOME = process.env.AGENTGUARD_HOME ?? (0, node_path_1.join)((0, node_os_1.homedir)(), '.agentguard');
|
|
35
|
+
const PROXY_HOSTS = ['ollama', 'vllm', 'lm-studio', 'openai-compatible'];
|
|
36
|
+
function readStdinJson() {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse((0, node_fs_1.readFileSync)(0, 'utf8'));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Unparseable payload: allow. We never break the host over our own bug.
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
25
45
|
function savePolicy(policy) {
|
|
26
46
|
(0, node_fs_1.mkdirSync)(HOME, { recursive: true, mode: 0o700 });
|
|
27
47
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(HOME, 'burn-policy.json'), JSON.stringify(policy, null, 2), { mode: 0o600 });
|
|
@@ -109,23 +129,90 @@ async function main(argv) {
|
|
|
109
129
|
case 'status': {
|
|
110
130
|
const policy = (0, pre_tool_use_1.loadPolicy)(HOME);
|
|
111
131
|
const e = shadowEligibility();
|
|
132
|
+
const gateway = new gateway_1.Gateway(HOME, { sign: false });
|
|
133
|
+
const lc = policy.thresholds.localCompute;
|
|
112
134
|
process.stdout.write([
|
|
113
135
|
`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`,
|
|
136
|
+
`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
137
|
policy.calibration ? `calibrated from ${policy.calibration.sessionsSampled} sessions` : 'using shipped defaults (run: agentguard-burn calibrate)',
|
|
116
138
|
`shadow observation: ${e.decisions} decisions over ${e.days.toFixed(1)} days`,
|
|
117
139
|
` would have warned: ${e.warns} would have blocked: ${e.wouldBlock}`,
|
|
118
140
|
`eligible for enforcement: ${e.eligible ? 'yes' : `no (need ${defaults_1.SHADOW_MIN_DECISIONS} decisions and ${defaults_1.SHADOW_MIN_DAYS} days)`}`,
|
|
141
|
+
'',
|
|
142
|
+
(0, status_1.renderMachineStatus)(gateway.sessions(), gateway.compute()),
|
|
143
|
+
'',
|
|
144
|
+
'content logging: off · telemetry: none · state: this machine only',
|
|
119
145
|
].join('\n') + '\n');
|
|
120
146
|
return 0;
|
|
121
147
|
}
|
|
122
148
|
case 'init': {
|
|
123
149
|
const policy = (0, pre_tool_use_1.loadPolicy)(HOME);
|
|
124
150
|
savePolicy(policy);
|
|
125
|
-
const
|
|
126
|
-
|
|
151
|
+
const cli = (0, node_path_1.join)(__dirname, 'cli.js');
|
|
152
|
+
const target = rest.find((a) => !a.startsWith('--')) ?? 'claude';
|
|
153
|
+
if (target === 'cursor') {
|
|
154
|
+
process.stdout.write(`Add this to ~/.cursor/hooks.json (merge into existing "hooks"):\n\n${JSON.stringify((0, cursor_1.cursorHooksSnippet)(`node ${cli} cursor-hook`), null, 2)}\n\n` +
|
|
155
|
+
`Cursor support is BETA: verified against the documented hook schema, not yet against every installed version. ` +
|
|
156
|
+
`"failClosed": true means a crashed hook denies the subagent.\n` +
|
|
157
|
+
`Installed in ${policy.mode} mode. Nothing is blocked until you run: agentguard-burn enforce\n`);
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
if (target === 'codex') {
|
|
161
|
+
process.stdout.write(`Add this to ~/.codex/hooks.json (merge into existing "hooks"):\n\n${JSON.stringify((0, codex_1.codexHooksSnippet)(`node ${cli} codex-hook`), null, 2)}\n\n` +
|
|
162
|
+
`Codex support is EXPERIMENTAL: the deny shape is verified against the documented schema; transcript usage is best-effort and marked estimated.\n` +
|
|
163
|
+
`Installed in ${policy.mode} mode. Nothing is blocked until you run: agentguard-burn enforce\n`);
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
process.stdout.write(`Add this to ~/.claude/settings.json (merge into existing "hooks"):\n\n${JSON.stringify((0, pre_tool_use_1.settingsSnippet)(`node ${cli} hook`), null, 2)}\n\nInstalled in ${policy.mode} mode. Nothing is blocked until you run: agentguard-burn enforce\n` +
|
|
167
|
+
`Other hosts: agentguard-burn init cursor | init codex | proxy --upstream http://127.0.0.1:11434 --host ollama\n`);
|
|
168
|
+
return 0;
|
|
169
|
+
}
|
|
170
|
+
case 'cursor-hook': {
|
|
171
|
+
const gateway = new gateway_1.Gateway(HOME);
|
|
172
|
+
process.stdout.write(JSON.stringify((0, cursor_1.handleCursorHook)(readStdinJson(), gateway)));
|
|
127
173
|
return 0;
|
|
128
174
|
}
|
|
175
|
+
case 'codex-hook': {
|
|
176
|
+
const gateway = new gateway_1.Gateway(HOME);
|
|
177
|
+
process.stdout.write(JSON.stringify((0, codex_1.handleCodexHook)(readStdinJson(), gateway)));
|
|
178
|
+
return 0;
|
|
179
|
+
}
|
|
180
|
+
case 'proxy': {
|
|
181
|
+
const upstreamRaw = flag('--upstream') ?? 'http://127.0.0.1:11434';
|
|
182
|
+
const host = (flag('--host') ?? 'ollama');
|
|
183
|
+
if (!PROXY_HOSTS.includes(host)) {
|
|
184
|
+
process.stderr.write(`--host must be one of ${PROXY_HOSTS.join(', ')}\n`);
|
|
185
|
+
return 64;
|
|
186
|
+
}
|
|
187
|
+
const listen = flag('--listen') ?? '127.0.0.1:18080';
|
|
188
|
+
const [listenHost, listenPortRaw] = listen.includes(':') ? [listen.slice(0, listen.lastIndexOf(':')), listen.slice(listen.lastIndexOf(':') + 1)] : ['127.0.0.1', listen];
|
|
189
|
+
const gateway = new gateway_1.Gateway(HOME);
|
|
190
|
+
const running = await (0, server_1.startProxy)({
|
|
191
|
+
upstream: new URL(upstreamRaw),
|
|
192
|
+
gateway,
|
|
193
|
+
host,
|
|
194
|
+
listenHost,
|
|
195
|
+
listenPort: Number(listenPortRaw),
|
|
196
|
+
defaultSession: flag('--session') ?? process.env.AGENTGUARD_SESSION_ID,
|
|
197
|
+
allowRemoteUpstream: has('--allow-remote-upstream'),
|
|
198
|
+
log: (l) => process.stderr.write(`${l}\n`),
|
|
199
|
+
});
|
|
200
|
+
const policy = (0, pre_tool_use_1.loadPolicy)(HOME);
|
|
201
|
+
process.stderr.write(`agentguard-burn proxy ${running.address.origin} -> ${upstreamRaw} (${host}, ${policy.mode} mode)\n` +
|
|
202
|
+
`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` +
|
|
203
|
+
`Ctrl-C to stop.\n`);
|
|
204
|
+
await new Promise((resolve) => {
|
|
205
|
+
const stop = () => running.close().then(resolve, resolve);
|
|
206
|
+
process.once('SIGINT', stop);
|
|
207
|
+
process.once('SIGTERM', stop);
|
|
208
|
+
});
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
211
|
+
case 'conformance': {
|
|
212
|
+
const result = await (0, conformance_1.runConformance)();
|
|
213
|
+
process.stdout.write(result.text + '\n');
|
|
214
|
+
return result.ok ? 0 : 1;
|
|
215
|
+
}
|
|
129
216
|
case 'enforce': {
|
|
130
217
|
const policy = (0, pre_tool_use_1.loadPolicy)(HOME);
|
|
131
218
|
const e = shadowEligibility();
|
|
@@ -150,13 +237,15 @@ async function main(argv) {
|
|
|
150
237
|
return 0;
|
|
151
238
|
}
|
|
152
239
|
default:
|
|
153
|
-
process.stdout.write('agentguard-burn <replay|calibrate|status|init|enforce|shadow|resume|
|
|
240
|
+
process.stdout.write('agentguard-burn <replay|calibrate|status|init|enforce|shadow|resume|proxy|conformance>\n' +
|
|
154
241
|
' replay [files...] [--json] [--top N] [--min-tokens N]\n' +
|
|
155
242
|
' calibrate fit thresholds to your history (writes shadow policy)\n' +
|
|
156
|
-
' status mode, shadow observations, eligibility\n' +
|
|
157
|
-
' init
|
|
243
|
+
' status mode, shadow observations, eligibility, every host\n' +
|
|
244
|
+
' init [claude|cursor|codex] print the hook snippet for that host\n' +
|
|
158
245
|
' enforce [--force] shadow -> enforce\n' +
|
|
159
|
-
' resume --once --reason "..."\n'
|
|
246
|
+
' resume --once --reason "..."\n' +
|
|
247
|
+
' proxy --upstream http://127.0.0.1:11434 --host ollama|vllm|lm-studio|openai-compatible [--listen 127.0.0.1:18080] [--session ID]\n' +
|
|
248
|
+
' conformance replay the storm and the grind through every adapter shape\n');
|
|
160
249
|
return command ? 64 : 0;
|
|
161
250
|
}
|
|
162
251
|
}
|
|
@@ -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>;
|
|
@@ -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
|
+
}
|
package/dist/src/defaults.d.ts
CHANGED
|
@@ -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. */
|
package/dist/src/defaults.js
CHANGED
|
@@ -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',
|