@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,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local reverse proxy for model runtimes.
|
|
3
|
+
*
|
|
4
|
+
* Put it in front of Ollama, vLLM, LM Studio or anything OpenAI-compatible
|
|
5
|
+
* on the same machine, point the agent at it, and every request becomes a
|
|
6
|
+
* model-call decision under the same policy as every other host.
|
|
7
|
+
*
|
|
8
|
+
* What it does, in order:
|
|
9
|
+
* 1. Resolve the session: x-agentguard-session header (high confidence)
|
|
10
|
+
* or remote port (low confidence; can never earn a session-scope STOP).
|
|
11
|
+
* 2. Ask the gateway to admit the call. A STOP answers 429 with the alarm
|
|
12
|
+
* box before anything is forwarded. A STOP therefore blocks the *next*
|
|
13
|
+
* request; it never cuts a stream that is already flowing.
|
|
14
|
+
* 3. Forward the request; stream every upstream chunk to the client BEFORE
|
|
15
|
+
* the observer parses it, honouring backpressure.
|
|
16
|
+
* 4. On end, commit the observed usage under the call ID, which replaces
|
|
17
|
+
* any estimate that raw middleware reserved for the same call.
|
|
18
|
+
*
|
|
19
|
+
* Loopback only, both sides, by default. Node built-ins only. No body is
|
|
20
|
+
* ever stored.
|
|
21
|
+
*/
|
|
22
|
+
import { type Server } from 'node:http';
|
|
23
|
+
import { type HostId } from '../events';
|
|
24
|
+
import type { Gateway } from '../gateway';
|
|
25
|
+
import { type ProxyProfile } from './usage-observer';
|
|
26
|
+
export type ProxyHost = Extract<HostId, 'ollama' | 'vllm' | 'lm-studio' | 'openai-compatible'>;
|
|
27
|
+
export interface ProxyOptions {
|
|
28
|
+
upstream: URL;
|
|
29
|
+
gateway: Gateway;
|
|
30
|
+
host: ProxyHost;
|
|
31
|
+
listenHost?: string;
|
|
32
|
+
listenPort?: number;
|
|
33
|
+
/** Session for requests that carry no header. Default: per-client-port, low confidence. */
|
|
34
|
+
defaultSession?: string;
|
|
35
|
+
/** Allow a non-loopback upstream (a GPU box on the LAN). Off by default. */
|
|
36
|
+
allowRemoteUpstream?: boolean;
|
|
37
|
+
log?: (line: string) => void;
|
|
38
|
+
}
|
|
39
|
+
export interface RunningProxy {
|
|
40
|
+
server: Server;
|
|
41
|
+
address: URL;
|
|
42
|
+
close(): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
export declare function profileFor(host: ProxyHost): ProxyProfile;
|
|
45
|
+
export declare function startProxy(opts: ProxyOptions): Promise<RunningProxy>;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Local reverse proxy for model runtimes.
|
|
4
|
+
*
|
|
5
|
+
* Put it in front of Ollama, vLLM, LM Studio or anything OpenAI-compatible
|
|
6
|
+
* on the same machine, point the agent at it, and every request becomes a
|
|
7
|
+
* model-call decision under the same policy as every other host.
|
|
8
|
+
*
|
|
9
|
+
* What it does, in order:
|
|
10
|
+
* 1. Resolve the session: x-agentguard-session header (high confidence)
|
|
11
|
+
* or remote port (low confidence; can never earn a session-scope STOP).
|
|
12
|
+
* 2. Ask the gateway to admit the call. A STOP answers 429 with the alarm
|
|
13
|
+
* box before anything is forwarded. A STOP therefore blocks the *next*
|
|
14
|
+
* request; it never cuts a stream that is already flowing.
|
|
15
|
+
* 3. Forward the request; stream every upstream chunk to the client BEFORE
|
|
16
|
+
* the observer parses it, honouring backpressure.
|
|
17
|
+
* 4. On end, commit the observed usage under the call ID, which replaces
|
|
18
|
+
* any estimate that raw middleware reserved for the same call.
|
|
19
|
+
*
|
|
20
|
+
* Loopback only, both sides, by default. Node built-ins only. No body is
|
|
21
|
+
* ever stored.
|
|
22
|
+
*/
|
|
23
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
|
+
exports.profileFor = profileFor;
|
|
25
|
+
exports.startProxy = startProxy;
|
|
26
|
+
const node_http_1 = require("node:http");
|
|
27
|
+
const node_https_1 = require("node:https");
|
|
28
|
+
const events_1 = require("../events");
|
|
29
|
+
const render_1 = require("../replay/render");
|
|
30
|
+
const usage_observer_1 = require("./usage-observer");
|
|
31
|
+
const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade']);
|
|
32
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|
33
|
+
function profileFor(host) {
|
|
34
|
+
return host === 'ollama' ? 'ollama' : 'openai-compatible';
|
|
35
|
+
}
|
|
36
|
+
async function startProxy(opts) {
|
|
37
|
+
const listenHost = opts.listenHost ?? '127.0.0.1';
|
|
38
|
+
if (!LOOPBACK.has(listenHost))
|
|
39
|
+
throw new Error(`AgentGuard proxy listens on loopback only; refusing ${listenHost}`);
|
|
40
|
+
if (!opts.allowRemoteUpstream && !LOOPBACK.has(opts.upstream.hostname)) {
|
|
41
|
+
throw new Error(`Upstream ${opts.upstream.hostname} is not loopback. Pass --allow-remote-upstream if that is intended.`);
|
|
42
|
+
}
|
|
43
|
+
const log = opts.log ?? (() => undefined);
|
|
44
|
+
const server = (0, node_http_1.createServer)((req, res) => {
|
|
45
|
+
handle(req, res, opts, log).catch((error) => {
|
|
46
|
+
if (!res.headersSent)
|
|
47
|
+
res.writeHead(502, { 'content-type': 'application/json; charset=utf-8' });
|
|
48
|
+
if (!res.writableEnded)
|
|
49
|
+
res.end(JSON.stringify({ error: { type: 'agentguard_proxy_error', message: error instanceof Error ? error.message : String(error) } }));
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
await new Promise((resolve, reject) => {
|
|
53
|
+
server.once('error', reject);
|
|
54
|
+
server.listen(opts.listenPort ?? 0, listenHost, () => {
|
|
55
|
+
server.off('error', reject);
|
|
56
|
+
resolve();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
const addr = server.address();
|
|
60
|
+
if (!addr || typeof addr === 'string')
|
|
61
|
+
throw new Error('proxy did not bind a TCP port');
|
|
62
|
+
return {
|
|
63
|
+
server,
|
|
64
|
+
address: new URL(`http://${listenHost}:${addr.port}`),
|
|
65
|
+
close: () => new Promise((resolve, reject) => server.close((e) => (e ? reject(e) : resolve()))),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
async function handle(req, res, opts, log) {
|
|
69
|
+
const startedAt = Date.now();
|
|
70
|
+
const explicit = first(req.headers[events_1.SESSION_HEADER]);
|
|
71
|
+
const sessionId = explicit && (0, events_1.isValidSessionId)(explicit) ? explicit : opts.defaultSession ?? `${opts.host}:port-${req.socket.remotePort ?? 0}`;
|
|
72
|
+
const attribution = explicit && (0, events_1.isValidSessionId)(explicit) ? 'high' : opts.defaultSession ? 'high' : 'low';
|
|
73
|
+
const callId = first(req.headers[events_1.CALL_HEADER]) ?? (0, events_1.eventId)();
|
|
74
|
+
const decision = opts.gateway.beforeCall({
|
|
75
|
+
schemaVersion: 1,
|
|
76
|
+
kind: 'call_requested',
|
|
77
|
+
eventId: `proxy:${callId}`,
|
|
78
|
+
host: opts.host,
|
|
79
|
+
sessionId,
|
|
80
|
+
at: startedAt,
|
|
81
|
+
callId,
|
|
82
|
+
estimatedTokens: 0,
|
|
83
|
+
attribution,
|
|
84
|
+
});
|
|
85
|
+
if (decision.blocked) {
|
|
86
|
+
req.resume();
|
|
87
|
+
const box = (0, render_1.renderStop)(decision.report, { colour: false, subject: 'call' });
|
|
88
|
+
log(`STOP ${sessionId} ${req.method} ${req.url}`);
|
|
89
|
+
res.writeHead(429, {
|
|
90
|
+
'content-type': 'application/json; charset=utf-8',
|
|
91
|
+
'retry-after': '60',
|
|
92
|
+
'cache-control': 'no-store',
|
|
93
|
+
'x-agentguard-verdict': 'STOP',
|
|
94
|
+
});
|
|
95
|
+
res.end(JSON.stringify({
|
|
96
|
+
error: {
|
|
97
|
+
type: 'agentguard_burn_stop',
|
|
98
|
+
message: box,
|
|
99
|
+
verdict: decision.verdict,
|
|
100
|
+
findings: decision.report.findings.map((f) => ({ detector: f.detector, verdict: f.verdict, summary: f.summary })),
|
|
101
|
+
prescriptions: decision.report.prescriptions,
|
|
102
|
+
receipt: decision.receipt,
|
|
103
|
+
},
|
|
104
|
+
}));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const target = new URL(req.url ?? '/', opts.upstream);
|
|
108
|
+
const headers = forward(req.headers);
|
|
109
|
+
headers.host = opts.upstream.host;
|
|
110
|
+
// Keep the bytes inspectable without ever decompressing on the hot path.
|
|
111
|
+
headers['accept-encoding'] = 'identity';
|
|
112
|
+
if (decision.verdict !== 'OK')
|
|
113
|
+
headers['x-agentguard-verdict'] = decision.verdict;
|
|
114
|
+
const requester = target.protocol === 'https:' ? node_https_1.request : node_http_1.request;
|
|
115
|
+
const profile = profileFor(opts.host);
|
|
116
|
+
await new Promise((resolve, reject) => {
|
|
117
|
+
const upstream = requester({ protocol: target.protocol, hostname: target.hostname, port: target.port || undefined, method: req.method, path: `${target.pathname}${target.search}`, headers }, (up) => {
|
|
118
|
+
const outHeaders = forward(up.headers);
|
|
119
|
+
if (decision.verdict !== 'OK')
|
|
120
|
+
outHeaders['x-agentguard-verdict'] = decision.verdict;
|
|
121
|
+
res.writeHead(up.statusCode ?? 502, outHeaders);
|
|
122
|
+
const observer = new usage_observer_1.UsageObserver(profile, first(up.headers['content-type']));
|
|
123
|
+
up.on('data', (raw) => {
|
|
124
|
+
const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
|
125
|
+
// Invariant: the client gets the bytes before we look at them.
|
|
126
|
+
const writable = res.write(chunk);
|
|
127
|
+
observer.observe(chunk);
|
|
128
|
+
if (!writable) {
|
|
129
|
+
up.pause();
|
|
130
|
+
res.once('drain', () => up.resume());
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
up.once('end', () => {
|
|
134
|
+
const seen = observer.finish();
|
|
135
|
+
res.end();
|
|
136
|
+
const finishedAt = Date.now();
|
|
137
|
+
if (seen.observed) {
|
|
138
|
+
opts.gateway.completeCall({ host: opts.host, sessionId, callId, tokens: seen.tokens, cacheRead: seen.cacheRead, usageCoverage: seen.authoritative ? 'authoritative' : 'estimated', at: finishedAt });
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
// The server sent no usage. Coverage drops; the count does not lie.
|
|
142
|
+
opts.gateway.failCall({ host: opts.host, sessionId, callId, at: finishedAt });
|
|
143
|
+
}
|
|
144
|
+
log(`${decision.verdict.padEnd(4)} ${sessionId} ${req.method} ${req.url} ${seen.observed ? `${seen.tokens} tok` : 'usage missing'} ${finishedAt - startedAt}ms`);
|
|
145
|
+
resolve();
|
|
146
|
+
});
|
|
147
|
+
up.once('error', (error) => {
|
|
148
|
+
res.destroy(error);
|
|
149
|
+
opts.gateway.failCall({ host: opts.host, sessionId, callId });
|
|
150
|
+
reject(error);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
upstream.once('error', (error) => {
|
|
154
|
+
opts.gateway.failCall({ host: opts.host, sessionId, callId });
|
|
155
|
+
reject(error);
|
|
156
|
+
});
|
|
157
|
+
req.pipe(upstream);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
function first(v) {
|
|
161
|
+
return Array.isArray(v) ? v[0] : v;
|
|
162
|
+
}
|
|
163
|
+
function forward(src) {
|
|
164
|
+
const out = {};
|
|
165
|
+
for (const [k, v] of Object.entries(src))
|
|
166
|
+
if (v !== undefined && !HOP_BY_HOP.has(k.toLowerCase()))
|
|
167
|
+
out[k] = v;
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watch response bytes go past and pick out the usage figures.
|
|
3
|
+
*
|
|
4
|
+
* This is a side channel, never a data path: the proxy writes every chunk to
|
|
5
|
+
* the client before this sees it, and this keeps only counts. Prompts and
|
|
6
|
+
* completions are parsed and dropped line by line. A non-streaming body is
|
|
7
|
+
* buffered up to a cap so a giant response cannot turn a guard into a
|
|
8
|
+
* memory leak.
|
|
9
|
+
*
|
|
10
|
+
* Ollama profile final NDJSON object, prompt_eval_count + eval_count
|
|
11
|
+
* OpenAI-compatible non-streaming `usage`, or the final SSE usage event
|
|
12
|
+
* when the server sends one (vLLM, LM Studio and
|
|
13
|
+
* llama.cpp do with stream_options.include_usage).
|
|
14
|
+
*
|
|
15
|
+
* Missing usage is reported as missing. It is never a guessed zero.
|
|
16
|
+
*/
|
|
17
|
+
export type ProxyProfile = 'ollama' | 'openai-compatible';
|
|
18
|
+
export interface ObservedUsage {
|
|
19
|
+
tokens: number;
|
|
20
|
+
cacheRead: number;
|
|
21
|
+
observed: boolean;
|
|
22
|
+
authoritative: boolean;
|
|
23
|
+
model?: string;
|
|
24
|
+
}
|
|
25
|
+
export declare class UsageObserver {
|
|
26
|
+
private readonly profile;
|
|
27
|
+
private lineBuffer;
|
|
28
|
+
private body;
|
|
29
|
+
private bodyBytes;
|
|
30
|
+
private overflow;
|
|
31
|
+
private usage;
|
|
32
|
+
private authoritative;
|
|
33
|
+
private model;
|
|
34
|
+
private readonly streaming;
|
|
35
|
+
constructor(profile: ProxyProfile, contentType?: string);
|
|
36
|
+
observe(chunk: Buffer): void;
|
|
37
|
+
finish(): ObservedUsage;
|
|
38
|
+
private parseLine;
|
|
39
|
+
private parseJson;
|
|
40
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Watch response bytes go past and pick out the usage figures.
|
|
4
|
+
*
|
|
5
|
+
* This is a side channel, never a data path: the proxy writes every chunk to
|
|
6
|
+
* the client before this sees it, and this keeps only counts. Prompts and
|
|
7
|
+
* completions are parsed and dropped line by line. A non-streaming body is
|
|
8
|
+
* buffered up to a cap so a giant response cannot turn a guard into a
|
|
9
|
+
* memory leak.
|
|
10
|
+
*
|
|
11
|
+
* Ollama profile final NDJSON object, prompt_eval_count + eval_count
|
|
12
|
+
* OpenAI-compatible non-streaming `usage`, or the final SSE usage event
|
|
13
|
+
* when the server sends one (vLLM, LM Studio and
|
|
14
|
+
* llama.cpp do with stream_options.include_usage).
|
|
15
|
+
*
|
|
16
|
+
* Missing usage is reported as missing. It is never a guessed zero.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.UsageObserver = void 0;
|
|
20
|
+
const MAX_BUFFER = 32 * 1024 * 1024;
|
|
21
|
+
class UsageObserver {
|
|
22
|
+
profile;
|
|
23
|
+
lineBuffer = '';
|
|
24
|
+
body = [];
|
|
25
|
+
bodyBytes = 0;
|
|
26
|
+
overflow = false;
|
|
27
|
+
usage = null;
|
|
28
|
+
authoritative = false;
|
|
29
|
+
model;
|
|
30
|
+
streaming;
|
|
31
|
+
constructor(profile, contentType = '') {
|
|
32
|
+
this.profile = profile;
|
|
33
|
+
const ct = contentType.toLowerCase();
|
|
34
|
+
this.streaming = ct.includes('text/event-stream') || ct.includes('ndjson') || (profile === 'ollama' && !ct.includes('application/json'));
|
|
35
|
+
}
|
|
36
|
+
observe(chunk) {
|
|
37
|
+
if (this.streaming) {
|
|
38
|
+
this.lineBuffer += chunk.toString('utf8');
|
|
39
|
+
for (;;) {
|
|
40
|
+
const nl = this.lineBuffer.indexOf('\n');
|
|
41
|
+
if (nl < 0)
|
|
42
|
+
break;
|
|
43
|
+
const line = this.lineBuffer.slice(0, nl).replace(/\r$/, '');
|
|
44
|
+
this.lineBuffer = this.lineBuffer.slice(nl + 1);
|
|
45
|
+
this.parseLine(line);
|
|
46
|
+
}
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (this.overflow)
|
|
50
|
+
return;
|
|
51
|
+
this.bodyBytes += chunk.length;
|
|
52
|
+
if (this.bodyBytes > MAX_BUFFER) {
|
|
53
|
+
this.overflow = true;
|
|
54
|
+
this.body = [];
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
this.body.push(Buffer.from(chunk));
|
|
58
|
+
}
|
|
59
|
+
finish() {
|
|
60
|
+
if (this.streaming && this.lineBuffer.trim())
|
|
61
|
+
this.parseLine(this.lineBuffer.trim());
|
|
62
|
+
if (!this.streaming && !this.overflow)
|
|
63
|
+
this.parseJson(Buffer.concat(this.body).toString('utf8'));
|
|
64
|
+
this.body = [];
|
|
65
|
+
return {
|
|
66
|
+
tokens: this.usage?.tokens ?? 0,
|
|
67
|
+
cacheRead: this.usage?.cacheRead ?? 0,
|
|
68
|
+
observed: this.usage !== null,
|
|
69
|
+
authoritative: this.authoritative,
|
|
70
|
+
model: this.model,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
parseLine(raw) {
|
|
74
|
+
const line = raw.trim();
|
|
75
|
+
if (!line || line.startsWith(':'))
|
|
76
|
+
return;
|
|
77
|
+
if (line.startsWith('data:')) {
|
|
78
|
+
const payload = line.slice(5).trim();
|
|
79
|
+
if (!payload || payload === '[DONE]')
|
|
80
|
+
return;
|
|
81
|
+
this.parseJson(payload);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (this.profile === 'ollama')
|
|
85
|
+
this.parseJson(line);
|
|
86
|
+
}
|
|
87
|
+
parseJson(text) {
|
|
88
|
+
let v;
|
|
89
|
+
try {
|
|
90
|
+
v = JSON.parse(text);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (!isRecord(v))
|
|
96
|
+
return;
|
|
97
|
+
if (typeof v.model === 'string')
|
|
98
|
+
this.model = v.model;
|
|
99
|
+
if (this.profile === 'ollama') {
|
|
100
|
+
const prompt = count(v.prompt_eval_count);
|
|
101
|
+
const completion = count(v.eval_count);
|
|
102
|
+
if (prompt !== undefined || completion !== undefined) {
|
|
103
|
+
this.usage = { tokens: (prompt ?? 0) + (completion ?? 0), cacheRead: 0 };
|
|
104
|
+
this.authoritative = v.done === true;
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (!isRecord(v.usage))
|
|
109
|
+
return;
|
|
110
|
+
const u = v.usage;
|
|
111
|
+
const prompt = count(u.prompt_tokens) ?? count(u.input_tokens);
|
|
112
|
+
const completion = count(u.completion_tokens) ?? count(u.output_tokens);
|
|
113
|
+
const total = count(u.total_tokens);
|
|
114
|
+
const details = isRecord(u.prompt_tokens_details) ? u.prompt_tokens_details : isRecord(u.input_tokens_details) ? u.input_tokens_details : undefined;
|
|
115
|
+
const cached = details ? count(details.cached_tokens) : undefined;
|
|
116
|
+
if (prompt !== undefined || completion !== undefined || total !== undefined) {
|
|
117
|
+
this.usage = { tokens: Math.max(total ?? 0, (prompt ?? 0) + (completion ?? 0)), cacheRead: cached ?? 0 };
|
|
118
|
+
this.authoritative = true;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
exports.UsageObserver = UsageObserver;
|
|
123
|
+
function isRecord(v) {
|
|
124
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
125
|
+
}
|
|
126
|
+
function count(v) {
|
|
127
|
+
return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.trunc(v) : undefined;
|
|
128
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-free signed receipts.
|
|
3
|
+
*
|
|
4
|
+
* Every decision that matters (a spawn admission, or any non-OK verdict on a
|
|
5
|
+
* model call) is signed with a local Ed25519 key and chained to the previous
|
|
6
|
+
* receipt for the same session. The payload is counts, digests and verdicts.
|
|
7
|
+
* It contains no prompt, no completion, no path, no tool input. That is what
|
|
8
|
+
* lets a receipt leave the machine when a session transcript never can.
|
|
9
|
+
*
|
|
10
|
+
* Node built-ins only. The key is generated on first use, 0600, under the
|
|
11
|
+
* AgentGuard home. This is developer-grade evidence, not enterprise key
|
|
12
|
+
* custody; rotation and external signers are a later product, not a hidden
|
|
13
|
+
* gap.
|
|
14
|
+
*/
|
|
15
|
+
import type { HostCapabilities, HostId } from './events';
|
|
16
|
+
import type { Verdict } from './types';
|
|
17
|
+
export interface ReceiptPayload {
|
|
18
|
+
schema: 'agentguard.burn.decision.v1';
|
|
19
|
+
decisionId: string;
|
|
20
|
+
at: number;
|
|
21
|
+
host: HostId;
|
|
22
|
+
/** sha256 of the session id: correlatable, not reversible. */
|
|
23
|
+
sessionDigest: string;
|
|
24
|
+
action: 'spawn' | 'model_call';
|
|
25
|
+
policy: {
|
|
26
|
+
mode: 'shadow' | 'enforce';
|
|
27
|
+
digest: string;
|
|
28
|
+
};
|
|
29
|
+
measured: {
|
|
30
|
+
sessionTokens: number;
|
|
31
|
+
sessionSpawns: number;
|
|
32
|
+
proposedDepth: number | null;
|
|
33
|
+
inFlight: number;
|
|
34
|
+
occupiedMs: number;
|
|
35
|
+
};
|
|
36
|
+
coverage: HostCapabilities;
|
|
37
|
+
verdict: Verdict;
|
|
38
|
+
blocked: boolean;
|
|
39
|
+
reasons: string[];
|
|
40
|
+
previous: string | null;
|
|
41
|
+
}
|
|
42
|
+
export interface SignedReceipt {
|
|
43
|
+
payload: ReceiptPayload;
|
|
44
|
+
keyId: string;
|
|
45
|
+
algorithm: 'Ed25519';
|
|
46
|
+
publicKey: string;
|
|
47
|
+
signature: string;
|
|
48
|
+
}
|
|
49
|
+
/** Stable key order so the bytes that were signed can be reproduced anywhere. */
|
|
50
|
+
export declare function canonical(value: unknown): string;
|
|
51
|
+
export declare function sha256(text: string): string;
|
|
52
|
+
export declare class ReceiptSigner {
|
|
53
|
+
private readonly privatePem;
|
|
54
|
+
readonly publicKey: string;
|
|
55
|
+
readonly keyId: string;
|
|
56
|
+
constructor(privatePem: string);
|
|
57
|
+
static loadOrCreate(home: string): ReceiptSigner;
|
|
58
|
+
sign(payload: ReceiptPayload): SignedReceipt;
|
|
59
|
+
}
|
|
60
|
+
export declare function verifyReceipt(receipt: SignedReceipt): boolean;
|
|
61
|
+
export declare function receiptDigest(receipt: SignedReceipt): string;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Content-free signed receipts.
|
|
4
|
+
*
|
|
5
|
+
* Every decision that matters (a spawn admission, or any non-OK verdict on a
|
|
6
|
+
* model call) is signed with a local Ed25519 key and chained to the previous
|
|
7
|
+
* receipt for the same session. The payload is counts, digests and verdicts.
|
|
8
|
+
* It contains no prompt, no completion, no path, no tool input. That is what
|
|
9
|
+
* lets a receipt leave the machine when a session transcript never can.
|
|
10
|
+
*
|
|
11
|
+
* Node built-ins only. The key is generated on first use, 0600, under the
|
|
12
|
+
* AgentGuard home. This is developer-grade evidence, not enterprise key
|
|
13
|
+
* custody; rotation and external signers are a later product, not a hidden
|
|
14
|
+
* gap.
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.ReceiptSigner = void 0;
|
|
18
|
+
exports.canonical = canonical;
|
|
19
|
+
exports.sha256 = sha256;
|
|
20
|
+
exports.verifyReceipt = verifyReceipt;
|
|
21
|
+
exports.receiptDigest = receiptDigest;
|
|
22
|
+
const node_crypto_1 = require("node:crypto");
|
|
23
|
+
const node_fs_1 = require("node:fs");
|
|
24
|
+
const node_path_1 = require("node:path");
|
|
25
|
+
/** Stable key order so the bytes that were signed can be reproduced anywhere. */
|
|
26
|
+
function canonical(value) {
|
|
27
|
+
if (value === null || typeof value !== 'object')
|
|
28
|
+
return JSON.stringify(value);
|
|
29
|
+
if (Array.isArray(value))
|
|
30
|
+
return `[${value.map(canonical).join(',')}]`;
|
|
31
|
+
const entries = Object.keys(value)
|
|
32
|
+
.sort()
|
|
33
|
+
.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`);
|
|
34
|
+
return `{${entries.join(',')}}`;
|
|
35
|
+
}
|
|
36
|
+
function sha256(text) {
|
|
37
|
+
return (0, node_crypto_1.createHash)('sha256').update(text).digest('hex');
|
|
38
|
+
}
|
|
39
|
+
class ReceiptSigner {
|
|
40
|
+
privatePem;
|
|
41
|
+
publicKey;
|
|
42
|
+
keyId;
|
|
43
|
+
constructor(privatePem) {
|
|
44
|
+
const priv = (0, node_crypto_1.createPrivateKey)(privatePem);
|
|
45
|
+
this.privatePem = priv.export({ type: 'pkcs8', format: 'pem' }).toString();
|
|
46
|
+
const spki = (0, node_crypto_1.createPublicKey)(priv).export({ type: 'spki', format: 'der' });
|
|
47
|
+
this.publicKey = spki.toString('base64');
|
|
48
|
+
this.keyId = sha256(this.publicKey).slice(0, 16);
|
|
49
|
+
}
|
|
50
|
+
static loadOrCreate(home) {
|
|
51
|
+
const file = (0, node_path_1.join)(home, 'receipt-signing-key.pem');
|
|
52
|
+
if ((0, node_fs_1.existsSync)(file))
|
|
53
|
+
return new ReceiptSigner((0, node_fs_1.readFileSync)(file, 'utf8'));
|
|
54
|
+
(0, node_fs_1.mkdirSync)(home, { recursive: true, mode: 0o700 });
|
|
55
|
+
// Sixty hook processes on a fresh home all arrive here at once. Exactly
|
|
56
|
+
// one key must win: write to a private temp name, then link it into
|
|
57
|
+
// place (link fails with EEXIST if a sibling got there first), and the
|
|
58
|
+
// losers read the winner's key. Nobody ever sees a half-written PEM.
|
|
59
|
+
const pair = (0, node_crypto_1.generateKeyPairSync)('ed25519');
|
|
60
|
+
const pem = pair.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
|
61
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
62
|
+
(0, node_fs_1.writeFileSync)(tmp, pem, { mode: 0o600 });
|
|
63
|
+
try {
|
|
64
|
+
(0, node_fs_1.linkSync)(tmp, file);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error.code !== 'EEXIST')
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
(0, node_fs_1.rmSync)(tmp, { force: true });
|
|
72
|
+
}
|
|
73
|
+
return new ReceiptSigner((0, node_fs_1.readFileSync)(file, 'utf8'));
|
|
74
|
+
}
|
|
75
|
+
sign(payload) {
|
|
76
|
+
const bytes = Buffer.from(canonical(payload));
|
|
77
|
+
return {
|
|
78
|
+
payload,
|
|
79
|
+
keyId: this.keyId,
|
|
80
|
+
algorithm: 'Ed25519',
|
|
81
|
+
publicKey: this.publicKey,
|
|
82
|
+
signature: (0, node_crypto_1.sign)(null, bytes, this.privatePem).toString('base64'),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
exports.ReceiptSigner = ReceiptSigner;
|
|
87
|
+
function verifyReceipt(receipt) {
|
|
88
|
+
try {
|
|
89
|
+
const key = (0, node_crypto_1.createPublicKey)({ key: Buffer.from(receipt.publicKey, 'base64'), type: 'spki', format: 'der' });
|
|
90
|
+
return (0, node_crypto_1.verify)(null, Buffer.from(canonical(receipt.payload)), key, Buffer.from(receipt.signature, 'base64'));
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function receiptDigest(receipt) {
|
|
97
|
+
return sha256(canonical(receipt));
|
|
98
|
+
}
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Terminal rendering
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Terminal rendering. This output is the distribution artifact, so it is
|
|
3
|
+
* designed to be screenshotted: one giant number, a curve you can see the
|
|
4
|
+
* runaway in, one line worth quoting. Pure ASCII/ANSI, degrades cleanly.
|
|
5
5
|
*/
|
|
6
|
+
import type { BurnReport } from '../types';
|
|
6
7
|
import type { ReplaySummary, SessionReplay } from './simulate';
|
|
8
|
+
export declare function sparkline(curve: number[], stopAt: number | null, on: boolean): string;
|
|
9
|
+
export declare function comparison(tokens: number): string;
|
|
7
10
|
export declare function renderReplay(summary: ReplaySummary, opts?: {
|
|
8
11
|
colour?: boolean;
|
|
9
12
|
top?: number;
|
|
10
13
|
}): string;
|
|
14
|
+
export declare function renderStop(report: BurnReport, opts?: {
|
|
15
|
+
colour?: boolean;
|
|
16
|
+
subject?: 'spawn' | 'call';
|
|
17
|
+
}): string;
|
|
11
18
|
export declare function renderSessionRow(s: SessionReplay): string;
|