@agentguard-run/burn 0.3.1 → 0.3.2
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 +7 -0
- package/README.md +25 -6
- package/dist/src/cli.d.ts +1 -0
- package/dist/src/cli.js +15 -1
- package/dist/src/gateway.js +5 -3
- package/dist/src/hook/pre-tool-use.js +2 -1
- package/dist/src/ledger-panel.d.ts +40 -0
- package/dist/src/ledger-panel.js +174 -0
- package/dist/src/live-panel.d.ts +12 -0
- package/dist/src/live-panel.js +27 -107
- package/dist/src/record-ledger.d.ts +30 -0
- package/dist/src/record-ledger.js +165 -0
- package/dist/src/recording.js +4 -1
- package/dist/src/state/spawn-window.d.ts +13 -1
- package/dist/src/state/spawn-window.js +18 -3
- package/docs/LIVE_PANEL_2026_09.md +52 -28
- package/docs/assets/burn-real-stop-sep20-stop-1920.png +0 -0
- package/docs/assets/burn-real-stop-sep20-stop-390.png +0 -0
- package/docs/assets/burn-real-stop-sep20.evidence.json +67 -0
- package/docs/assets/burn-real-stop-sep20.gif +0 -0
- package/docs/assets/burn-real-stop-sep20.jsonl +39 -0
- package/docs/assets/burn-real-stop-sep20.mp4 +0 -0
- package/docs/assets/{burn-live-sep19.mp4.provenance.json → burn-real-stop-sep20.mp4.provenance.json} +45 -81
- package/docs/assets/burn-real-stop-sep20.policy.json +47 -0
- package/docs/assets/burn-real-stop-sep20.receipts.ndjson +18 -0
- package/docs/burn-render.md +12 -2
- package/fixtures/live-sep20-stop-104x35.txt +35 -0
- package/package.json +1 -1
- package/docs/assets/burn-live-sep19-stop-1920.png +0 -0
- package/docs/assets/burn-live-sep19-stop-390.png +0 -0
- package/docs/assets/burn-live-sep19.evidence.json +0 -288
- package/docs/assets/burn-live-sep19.gif +0 -0
- package/docs/assets/burn-live-sep19.jsonl +0 -45
- package/docs/assets/burn-live-sep19.mp4 +0 -0
- package/docs/assets/burn-live-sep19.observations.jsonl +0 -45
- package/docs/assets/burn-live-sep19.receipt.json +0 -38
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ledgerTimestamp = ledgerTimestamp;
|
|
4
|
+
exports.validateCompanionRecording = validateCompanionRecording;
|
|
5
|
+
exports.recordLedgerArguments = recordLedgerArguments;
|
|
6
|
+
exports.reconstructLedgerFrames = reconstructLedgerFrames;
|
|
7
|
+
exports.recordLedger = recordLedger;
|
|
8
|
+
/** Reconstruct local instrument-panel observations from immutable ledger history. */
|
|
9
|
+
const node_fs_1 = require("node:fs");
|
|
10
|
+
const node_path_1 = require("node:path");
|
|
11
|
+
const ledger_panel_1 = require("./ledger-panel");
|
|
12
|
+
const live_panel_1 = require("./live-panel");
|
|
13
|
+
const USAGE = 'Usage: agentguard-burn record --session <id> [--from <iso>] [--to <iso>] --out <run.jsonl>';
|
|
14
|
+
const MAX_FRAMES = 10000;
|
|
15
|
+
/** Require an explicit timezone and reject dates Date.parse would normalize. */
|
|
16
|
+
function ledgerTimestamp(value) {
|
|
17
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?(Z|[+-]\d{2}:\d{2})$/.exec(value);
|
|
18
|
+
if (!match)
|
|
19
|
+
throw new Error('Record timestamps must be ISO dates with a timezone and at most millisecond precision.');
|
|
20
|
+
const year = Number(match[1]), month = Number(match[2]), day = Number(match[3]);
|
|
21
|
+
const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
22
|
+
const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
23
|
+
const offset = match[8];
|
|
24
|
+
const at = Date.parse(value);
|
|
25
|
+
if (month < 1 || month > 12 || day < 1 || day > days[month - 1] || Number(match[4]) > 23
|
|
26
|
+
|| Number(match[5]) > 59 || Number(match[6]) > 59 || !Number.isFinite(at) || at < 0
|
|
27
|
+
|| offset !== 'Z' && (Number(offset.slice(1, 3)) > 23 || Number(offset.slice(4)) > 59)) {
|
|
28
|
+
throw new Error('Record timestamps must contain a valid date, time and timezone.');
|
|
29
|
+
}
|
|
30
|
+
return at;
|
|
31
|
+
}
|
|
32
|
+
/** Compare aliases before writing, including a new file below a symlinked directory. */
|
|
33
|
+
function samePath(left, right) {
|
|
34
|
+
try {
|
|
35
|
+
const a = (0, node_fs_1.statSync)(left), b = (0, node_fs_1.statSync)(right);
|
|
36
|
+
if (a.dev === b.dev && a.ino === b.ino)
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
catch { /* A fresh output has no inode yet. */ }
|
|
40
|
+
const physical = (file) => {
|
|
41
|
+
let path = (0, node_path_1.resolve)(file);
|
|
42
|
+
const suffix = [];
|
|
43
|
+
for (;;) {
|
|
44
|
+
try {
|
|
45
|
+
return (0, node_path_1.resolve)((0, node_fs_1.realpathSync)(path), ...suffix);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
const parent = (0, node_path_1.dirname)(path);
|
|
49
|
+
if (parent === path)
|
|
50
|
+
return (0, node_path_1.resolve)(file);
|
|
51
|
+
suffix.unshift((0, node_path_1.basename)(path));
|
|
52
|
+
path = parent;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
return physical(left) === physical(right);
|
|
57
|
+
}
|
|
58
|
+
function validateCompanionRecording(home, output, recording) {
|
|
59
|
+
if (samePath(output, recording))
|
|
60
|
+
throw new Error('Use different paths for --out and --record.');
|
|
61
|
+
if (['decisions.ndjson', 'receipts.ndjson'].some(name => samePath(recording, (0, node_path_1.join)(home, name)))) {
|
|
62
|
+
throw new Error('Recording must not append to a source ledger.');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function recordLedgerArguments(args) {
|
|
66
|
+
const fields = {};
|
|
67
|
+
for (let i = 0; i < args.length; i++) {
|
|
68
|
+
const flag = args[i];
|
|
69
|
+
if (!['--session', '--from', '--to', '--out'].includes(flag))
|
|
70
|
+
throw new Error(USAGE);
|
|
71
|
+
const key = flag.slice(2), value = args[++i];
|
|
72
|
+
if (!value || value.startsWith('--') || fields[key] !== undefined)
|
|
73
|
+
throw new Error(USAGE);
|
|
74
|
+
fields[key] = value;
|
|
75
|
+
}
|
|
76
|
+
if (!fields.session || !fields.out)
|
|
77
|
+
throw new Error(USAGE);
|
|
78
|
+
return fields;
|
|
79
|
+
}
|
|
80
|
+
function reconstructLedgerFrames(rows, options) {
|
|
81
|
+
if (!options.session || /[\x00-\x20\x7f]/.test(options.session))
|
|
82
|
+
throw new Error('Record requires a session id or an unambiguous prefix.');
|
|
83
|
+
const selected = (0, ledger_panel_1.resolveLedgerSession)(rows, options.session);
|
|
84
|
+
if (!selected)
|
|
85
|
+
throw new Error(`No ledger decisions match session ${options.session}.`);
|
|
86
|
+
const history = rows.filter(row => row.sessionDigest === selected.sessionDigest);
|
|
87
|
+
if (!history.length)
|
|
88
|
+
throw new Error(`No ledger decisions match session ${options.session}.`);
|
|
89
|
+
const first = history[0], last = history.at(-1);
|
|
90
|
+
const from = options.from === undefined ? first.at : ledgerTimestamp(options.from);
|
|
91
|
+
const to = options.to === undefined ? last.at : ledgerTimestamp(options.to);
|
|
92
|
+
if (from > to)
|
|
93
|
+
throw new Error('Record --from must be at or before --to.');
|
|
94
|
+
if ((to - from) / 1000 + 1 > MAX_FRAMES)
|
|
95
|
+
throw new Error('Recording exceeds the 10,000 frame render limit; select a shorter time range.');
|
|
96
|
+
const within = history.filter(row => row.at >= from && row.at <= to);
|
|
97
|
+
if (!within.length)
|
|
98
|
+
throw new Error('No ledger decisions fall inside the requested time range.');
|
|
99
|
+
const session = selected.sessionId?.slice(0, 8) ?? selected.sessionDigest.slice(0, 8);
|
|
100
|
+
const project = (index, at) => {
|
|
101
|
+
const snapshot = (0, ledger_panel_1.projectLedgerPanel)(history.slice(0, index + 1), selected.sessionDigest, at);
|
|
102
|
+
return { ...snapshot, session, sessionDigest: selected.sessionDigest, startedAt: first.at, observedAt: at, elapsedMs: Math.max(0, at - first.at), controls: false };
|
|
103
|
+
};
|
|
104
|
+
// Seed the hold with prior history without treating an old STOP as a new event.
|
|
105
|
+
const hold = new live_panel_1.StopHold();
|
|
106
|
+
let priorIndex = -1;
|
|
107
|
+
while (priorIndex + 1 < history.length && history[priorIndex + 1].at < from - 2000)
|
|
108
|
+
priorIndex++;
|
|
109
|
+
if (priorIndex >= 0)
|
|
110
|
+
hold.update(project(priorIndex, from - 2000), from - 2000);
|
|
111
|
+
while (priorIndex + 1 < history.length && history[priorIndex + 1].at < from) {
|
|
112
|
+
priorIndex++;
|
|
113
|
+
hold.update(project(priorIndex, history[priorIndex].at), history[priorIndex].at);
|
|
114
|
+
}
|
|
115
|
+
const events = [];
|
|
116
|
+
const decisionTimes = new Set();
|
|
117
|
+
history.forEach((row, index) => {
|
|
118
|
+
if (row.at >= from && row.at <= to) {
|
|
119
|
+
events.push({ at: row.at, index });
|
|
120
|
+
decisionTimes.add(row.at);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
for (let at = from; at <= to; at += 1000)
|
|
124
|
+
if (!decisionTimes.has(at))
|
|
125
|
+
events.push({ at });
|
|
126
|
+
if ((to - from) % 1000 !== 0 && !decisionTimes.has(to))
|
|
127
|
+
events.push({ at: to });
|
|
128
|
+
events.sort((a, b) => a.at - b.at || (a.index ?? -1) - (b.index ?? -1));
|
|
129
|
+
if (events.length > MAX_FRAMES)
|
|
130
|
+
throw new Error('Recording exceeds the 10,000 frame render limit; select a shorter time range.');
|
|
131
|
+
const frames = events.map(event => {
|
|
132
|
+
if (event.index !== undefined)
|
|
133
|
+
priorIndex = event.index;
|
|
134
|
+
else
|
|
135
|
+
while (priorIndex + 1 < history.length && history[priorIndex + 1].at <= event.at)
|
|
136
|
+
priorIndex++;
|
|
137
|
+
const snapshot = hold.update(project(priorIndex, event.at), event.at);
|
|
138
|
+
return { version: 1, at: new Date(event.at).toISOString(), data: { kind: 'live', snapshot }, viewport: { width: 104, height: 35 } };
|
|
139
|
+
});
|
|
140
|
+
const lastStop = history.filter(row => row.at <= to && row.verdict === 'STOP').at(-1);
|
|
141
|
+
return { frames, session, from, to, decisionCount: within.length, clippedStopHold: Boolean(lastStop && lastStop.at + 2000 > to) };
|
|
142
|
+
}
|
|
143
|
+
function recordLedger(home, options) {
|
|
144
|
+
if (!options.out || /[\x00-\x1f\x7f]/.test(options.out))
|
|
145
|
+
throw new Error('Recording output must be a local path without control characters.');
|
|
146
|
+
const output = (0, node_path_1.resolve)(options.out);
|
|
147
|
+
if (['decisions.ndjson', 'receipts.ndjson'].some(name => output === (0, node_path_1.resolve)((0, node_path_1.join)(home, name)))) {
|
|
148
|
+
throw new Error('Recording output must not replace a source ledger.');
|
|
149
|
+
}
|
|
150
|
+
const result = reconstructLedgerFrames((0, ledger_panel_1.readPanelLedger)(home), options);
|
|
151
|
+
const bytes = result.frames.map(frame => JSON.stringify(frame)).join('\n') + '\n';
|
|
152
|
+
if (Buffer.byteLength(bytes) > 128 * 1024 * 1024)
|
|
153
|
+
throw new Error('Recording exceeds the 128 MB render limit; select a shorter time range.');
|
|
154
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(output), { recursive: true, mode: 0o700 });
|
|
155
|
+
try {
|
|
156
|
+
(0, node_fs_1.writeFileSync)(output, bytes, { flag: 'wx', mode: 0o600 });
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (error.code === 'EEXIST')
|
|
160
|
+
throw new Error(`Refusing to overwrite existing output: ${output}`);
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
return { output, session: result.session, from: new Date(result.from).toISOString(), to: new Date(result.to).toISOString(),
|
|
164
|
+
frameCount: result.frames.length, decisionCount: result.decisionCount, clippedStopHold: result.clippedStopHold };
|
|
165
|
+
}
|
package/dist/src/recording.js
CHANGED
|
@@ -59,7 +59,10 @@ function readRecording(file) {
|
|
|
59
59
|
return typeof d.id === 'string' && timestamp(d.at) && typeof d.tool === 'string' && typeof d.reason === 'string'
|
|
60
60
|
&& ['OK', 'WARN', 'STOP'].includes(String(d.verdict)) && (typeof d.blocked === 'boolean' || d.blocked === null);
|
|
61
61
|
};
|
|
62
|
-
|
|
62
|
+
const sessionSwitch = snapshot?.sessionSwitch;
|
|
63
|
+
if (!snapshot || typeof snapshot.session !== 'string'
|
|
64
|
+
|| !(snapshot.sessionDigest === undefined || typeof snapshot.sessionDigest === 'string' && /^[a-f0-9]{64}$/.test(snapshot.sessionDigest))
|
|
65
|
+
|| !(sessionSwitch === undefined || sessionSwitch && typeof sessionSwitch.from === 'string' && typeof sessionSwitch.to === 'string' && timestamp(sessionSwitch.at)) || typeof snapshot.note !== 'string'
|
|
63
66
|
|| !timestamp(snapshot.observedAt) || !timestamp(snapshot.startedAt)
|
|
64
67
|
|| !(snapshot.windowActiveMinutes === null || count(snapshot.windowActiveMinutes) && snapshot.windowActiveMinutes > 0)
|
|
65
68
|
|| !(snapshot.spawnCeiling === null || count(snapshot.spawnCeiling) && snapshot.spawnCeiling > 0)
|
|
@@ -1,10 +1,22 @@
|
|
|
1
|
+
import type { HostCapabilities } from '../events';
|
|
1
2
|
import type { BurnReport, SessionState, Thresholds } from '../types';
|
|
2
3
|
import type { ReserveResult, Transaction } from './reservations';
|
|
4
|
+
/** The exact decision-time measurements retained for local live and replay views. */
|
|
5
|
+
export interface DecisionWindow {
|
|
6
|
+
activeMinutes: number;
|
|
7
|
+
windowActiveMinutes: number;
|
|
8
|
+
spawns: number | null;
|
|
9
|
+
tokens: number | null;
|
|
10
|
+
spawnCeiling: number;
|
|
11
|
+
replayShare: number | null;
|
|
12
|
+
}
|
|
13
|
+
export declare function decisionWindow(state: SessionState, thresholds: Thresholds, proposedSpawn?: boolean, coverage?: Pick<HostCapabilities, 'spawns' | 'usage'>): DecisionWindow;
|
|
3
14
|
/** Evaluate and reserve together. Pending forks must not bypass either window. */
|
|
4
15
|
export declare function evaluateSpawnReservation(tx: Transaction, state: SessionState, thresholds: Thresholds, proposedDepth: number, toolUseId: string, now: number, account?: {
|
|
5
16
|
sessions: SessionState[];
|
|
6
17
|
now: number;
|
|
7
|
-
}): {
|
|
18
|
+
}, coverage?: Pick<HostCapabilities, 'spawns' | 'usage'>): {
|
|
8
19
|
report: BurnReport;
|
|
9
20
|
reservation: ReserveResult;
|
|
21
|
+
window: DecisionWindow;
|
|
10
22
|
};
|
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.decisionWindow = decisionWindow;
|
|
3
4
|
exports.evaluateSpawnReservation = evaluateSpawnReservation;
|
|
4
5
|
const evaluate_1 = require("../detectors/evaluate");
|
|
5
6
|
const defaults_1 = require("../defaults");
|
|
6
7
|
const session_1 = require("./session");
|
|
8
|
+
function decisionWindow(state, thresholds, proposedSpawn = false, coverage) {
|
|
9
|
+
const windowActiveMinutes = thresholds.spawnRate.windowActiveMinutes;
|
|
10
|
+
const spawnKnown = !coverage || coverage.spawns !== 'missing';
|
|
11
|
+
const usageKnown = !coverage || coverage.usage !== 'missing';
|
|
12
|
+
return {
|
|
13
|
+
activeMinutes: state.activeMinutes,
|
|
14
|
+
windowActiveMinutes,
|
|
15
|
+
spawns: spawnKnown ? (0, session_1.windowSum)(state.spawnsByActiveMinute, state.activeMinutes, windowActiveMinutes) + (proposedSpawn ? 1 : 0) : null,
|
|
16
|
+
tokens: usageKnown ? (0, session_1.windowSum)(state.tokensByActiveMinute, state.activeMinutes, windowActiveMinutes) : null,
|
|
17
|
+
spawnCeiling: thresholds.spawnRate.stop,
|
|
18
|
+
replayShare: usageKnown && state.totalTokens > 0 ? Math.min(1, state.totalCacheRead / state.totalTokens) : null,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
7
21
|
/** Evaluate and reserve together. Pending forks must not bypass either window. */
|
|
8
|
-
function evaluateSpawnReservation(tx, state, thresholds, proposedDepth, toolUseId, now, account) {
|
|
22
|
+
function evaluateSpawnReservation(tx, state, thresholds, proposedDepth, toolUseId, now, account, coverage) {
|
|
9
23
|
const pending = tx.pendingSpawns(state.sessionId, toolUseId, now);
|
|
10
24
|
const minute = Math.floor(state.activeMinutes);
|
|
11
25
|
const spawns = new Map(state.spawnsByActiveMinute);
|
|
@@ -13,13 +27,14 @@ function evaluateSpawnReservation(tx, state, thresholds, proposedDepth, toolUseI
|
|
|
13
27
|
spawns.set(minute, (spawns.get(minute) ?? 0) + pending);
|
|
14
28
|
const candidateState = { ...state, spawnCount: state.spawnCount + pending, spawnsByActiveMinute: spawns };
|
|
15
29
|
const report = (0, evaluate_1.evaluate)(candidateState, thresholds, proposedDepth, account);
|
|
30
|
+
const window = decisionWindow(candidateState, thresholds, true, coverage);
|
|
16
31
|
// Receipts retain the lifetime ordinal, while admission uses the active window.
|
|
17
32
|
const effectiveSpawns = state.spawnCount + pending + 1;
|
|
18
33
|
if (report.verdict === 'STOP') {
|
|
19
34
|
// A refused proposal never consumes a reservation. Its detector explains why.
|
|
20
|
-
return { report, reservation: { allowed: true, effectiveSpawns, pending } };
|
|
35
|
+
return { report, reservation: { allowed: true, effectiveSpawns, pending }, window };
|
|
21
36
|
}
|
|
22
37
|
const recent = (0, session_1.windowSum)(state.spawnsByActiveMinute, state.activeMinutes, thresholds.fanout.windowActiveMinutes ?? defaults_1.DEFAULT_THRESHOLDS.fanout.windowActiveMinutes);
|
|
23
38
|
const reservation = tx.reserve({ sessionId: state.sessionId, toolUseId, observedSpawns: recent, ceiling: thresholds.fanout.stop, now });
|
|
24
|
-
return { report, reservation: { ...reservation, effectiveSpawns } };
|
|
39
|
+
return { report, reservation: { ...reservation, effectiveSpawns }, window };
|
|
25
40
|
}
|
|
@@ -1,55 +1,79 @@
|
|
|
1
1
|
# Burn live instrument panel
|
|
2
2
|
|
|
3
|
-
`agentguard-burn live` reads the local
|
|
3
|
+
`agentguard-burn live` reads the local decision and receipt ledgers every half second. It follows the session with the newest decision on each refresh, unless `--session ID` pins a full ID, unique prefix or digest. The header shows the raw ID prefix when available. It does not change admission, write session state, or open a socket.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
|
-
agentguard-burn live --
|
|
6
|
+
agentguard-burn live --record local-run.jsonl
|
|
7
|
+
agentguard-burn live --session 0b0c2202 --record pinned-run.jsonl
|
|
7
8
|
agentguard-burn live --replay local-run.jsonl
|
|
8
9
|
agentguard-burn render local-run.jsonl --mp4 local-run.mp4 --gif local-run.gif
|
|
9
10
|
```
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
Use `--once` for one frame. `q` quits and `p` pauses display updates. An observed STOP holds its reason and matching counters for two seconds while intake continues. Switching sessions releases that hold immediately. Recordings carry a `sessionSwitch` object identifying the previous and next sessions and the switch time.
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
## One source for both columns
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
The hook now records its decision window alongside each ledger decision: active minutes, window length, spawn count including pending reservations and the proposed spawn, tokens in that window, the STOP ceiling, and session cache-read share. Gateway observations respect host coverage; missing usage remains unknown. These are measurements already available during evaluation, not a second evaluation. Receipt payloads and enforcement behavior are unchanged.
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
Both panel columns use the same session's ledger. Changing the current policy cannot rewrite a recorded window. Elapsed begins at that session's first ledger decision, including history before the displayed range. It does not use the transcript start time. The bar turns amber at 80 percent of the measured ceiling and red at the ceiling.
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
Historical ledgers lack exact token-window and cache-read measurements, so those values remain unknown. A spawn-rate finding supplies its observed spawn count. A complete witnessed sequence of proposals can establish earlier counts. Historical configuration is usable only when its canonical hash matches a verified receipt's policy digest. This prevents today's settings from being applied to an unrelated historical policy.
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
The right column shows the last eight decisions. When the ledger names the action `spawn` rather than the host tool name, the panel displays `spawn`. At fewer than 80 columns the right column is omitted. Both 104x35 and 80x24 are tested; the compact STOP image uses a native 43-column layout at 390 pixels.
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
Colored STOP reports use the same panel without reading files or delaying hooks. Their unrecorded window metrics and elapsed remain unknown. Plain-text hook cards and protocol output retain their previous paths.
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
## Reconstruct an event
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
```sh
|
|
29
|
+
agentguard-burn record --session 0b0c2202 --from 2026-09-20T20:20:32Z --to 2026-09-20T20:20:52Z --out run.jsonl
|
|
30
|
+
agentguard-burn render run.jsonl --mp4 run.mp4 --gif run.gif
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The output must be a new file. Optional bounds default to the session's first and last decisions. Reconstruction writes one frame per decision, including equal timestamps, plus one-second clock frames and explicit range boundaries. Prior rows supply context; future rows never supply earlier measurements. Clock ticks add no decisions or counts. The requested end clips any remaining STOP hold. The video renderer adds its standard one-second final frame; GIF timing is limited to centiseconds.
|
|
28
34
|
|
|
29
|
-
|
|
35
|
+
## September 20 evidence
|
|
30
36
|
|
|
31
|
-
The
|
|
37
|
+
Session `0b0c2202-d0be-4867-97a6-7e050a9b21e0` hashes to `58143098bae620e9ae380915798f2ba88d9342f2de8d3908277461ff7cb0c60d`. The old display's `58143098` therefore referred to this same session. The clip did expose a real defect: persisted transcript state showed zero spawns while pending reservations in the hook ledger advanced from 1 to 16. The previous elapsed clock also began at the older transcript start.
|
|
32
38
|
|
|
33
|
-
|
|
39
|
+
The first ledger decision is `2026-09-20T20:20:32.556Z`. The first enforced STOP is `2026-09-20T20:20:49.753Z`, receipt `e7e74842-5779-46e8-b9c2-3bf714e020f6`. Its `spawn_rate` finding records 16 against a threshold of 16. Elapsed at this decision is 17.197 seconds. Two subsequent STOPs at `20:20:50.936Z` and `20:20:51.935Z` remain at 16.
|
|
34
40
|
|
|
35
|
-
|
|
41
|
+
All 18 receipts in the interval verify, including their chain links. The normalized local policy's hash matches every receipt's policy digest: `7fc75c3841bbe6ee6401bfb57db26e1ce117de1d1dd53095cbb1f7fbc9c5cf6e`. It establishes the 15-active-minute window, WARN at 8 and STOP at 16. No policy or session file was changed. Token-window and replay-share displays remain unknown because those measurements were not recorded in this ledger.
|
|
36
42
|
|
|
37
|
-
|
|
38
|
-
- `burn-live-sep19-stop-1920.png`: the actual STOP frame.
|
|
39
|
-
- `burn-live-sep19-stop-390.png`: native compact Canvas rendering at 390x540.
|
|
40
|
-
- `burn-live-sep19.jsonl`: content-free frames written through the actual `live --replay --record` command.
|
|
41
|
-
- `burn-live-sep19.observations.jsonl`: the reconstructed input; CLI output preserves its frame data, timestamps and viewport exactly.
|
|
42
|
-
- `burn-live-sep19.receipt.json`: original signed receipt.
|
|
43
|
-
- `burn-live-sep19.evidence.json`: input hashes, receipt verification, timing, omissions, and media hashes.
|
|
44
|
-
- `burn-live-sep19.mp4.provenance.json`: renderer, font, frame times and encoding command.
|
|
43
|
+
The current showcase replaces the September 19 clip. Original decision timestamps, one-second display ticks and the recorded-run label are preserved. The STOP frame shows 16 spawns, the full red bar and its signed detector reason.
|
|
45
44
|
|
|
46
|
-
|
|
45
|
+
Assets under `docs/assets/`:
|
|
46
|
+
|
|
47
|
+
- `burn-real-stop-sep20.jsonl`: the ledger-derived recording.
|
|
48
|
+
- `burn-real-stop-sep20.mp4` and `burn-real-stop-sep20.gif`: 1920x1080 media.
|
|
49
|
+
- `burn-real-stop-sep20-stop-1920.png` and `burn-real-stop-sep20-stop-390.png`: the first STOP frame.
|
|
50
|
+
- `burn-real-stop-sep20.receipts.ndjson`: the 18 original signed, content-free receipts.
|
|
51
|
+
- `burn-real-stop-sep20.policy.json`: the normalized policy matching their signed digest.
|
|
52
|
+
- `burn-real-stop-sep20.evidence.json`: source hashes, timing, verification and media hashes.
|
|
53
|
+
- `burn-real-stop-sep20.mp4.provenance.json`: renderer version, source digest, frame times and encoding details.
|
|
54
|
+
|
|
55
|
+
Re-render the committed recording without private session data:
|
|
47
56
|
|
|
48
57
|
```sh
|
|
49
|
-
agentguard-burn
|
|
50
|
-
agentguard-burn render /tmp/burn-review.jsonl --mp4 /tmp/burn-review.mp4 --gif /tmp/burn-review.gif
|
|
58
|
+
agentguard-burn render docs/assets/burn-real-stop-sep20.jsonl --mp4 /tmp/burn-review.mp4 --gif /tmp/burn-review.gif
|
|
51
59
|
```
|
|
52
60
|
|
|
53
|
-
|
|
61
|
+
## Verification for 0.3.2
|
|
62
|
+
|
|
63
|
+
Node 22 full suite, including the optional concurrent-hook stress case:
|
|
64
|
+
|
|
65
|
+
```text
|
|
66
|
+
# STRESS: 240 hook processes; cap=40; admitted=40; denied=200; signed receipts=240; lock failures denied=0; chain valid
|
|
67
|
+
# tests 287
|
|
68
|
+
# pass 287
|
|
69
|
+
# fail 0
|
|
70
|
+
# skipped 0
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The initial sandbox run could not bind the local conformance server. The permitted full run passed. Regression coverage includes interleaved sessions, explicit pinning, identity-switch recordings, pending reservations, token-window parity, unknown coverage, historical policy hash matching, recording round trips and the real STOP frame snapshot. Copy checks found no prohibited punctuation or wording in changed text files.
|
|
74
|
+
|
|
75
|
+
The MP4 is 1920x1080 and 21.000001 seconds; the GIF is 1920x1080 and 21.00 seconds. The requested ledger interval is 20 seconds, followed by the renderer's one-second final frame. Both first-STOP PNGs were visually inspected. The live policy and the source session file remained byte-identical during reconstruction. Version 0.3.2 is prepared locally, with no publication.
|
|
76
|
+
|
|
77
|
+
MP4 SHA256: `9b31a4ece0dc99e235f3f90b6e07e5ce59d573d094d46ef3b7d9bb56fccd3bfd`.
|
|
54
78
|
|
|
55
|
-
|
|
79
|
+
GIF SHA256: `d9ca444b696ba84ec33f9e5a143c2a7014c6e7e7f465f45cd54b28d2ba3ea6c4`.
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"session_id": "0b0c2202-d0be-4867-97a6-7e050a9b21e0",
|
|
3
|
+
"session_digest": "58143098bae620e9ae380915798f2ba88d9342f2de8d3908277461ff7cb0c60d",
|
|
4
|
+
"old_display_was_same_session": true,
|
|
5
|
+
"first_decision": "2026-09-20T20:20:32.556Z",
|
|
6
|
+
"first_stop": "2026-09-20T20:20:49.753Z",
|
|
7
|
+
"first_stop_elapsed_ms": 17197,
|
|
8
|
+
"range": {
|
|
9
|
+
"from": "2026-09-20T20:20:32Z",
|
|
10
|
+
"to": "2026-09-20T20:20:52Z"
|
|
11
|
+
},
|
|
12
|
+
"source_sha256": {
|
|
13
|
+
"decisions": "20c23ef927848cf3ca1c738e0f80753ce0290ee9d77f6a9e12d1e6fd6afd8f89",
|
|
14
|
+
"receipts": "02cbb874d10f8e6d35fd1a96a2c7ac70b91242967accd12eff29b12e7dd74a6a"
|
|
15
|
+
},
|
|
16
|
+
"receipt_signatures_verified": 18,
|
|
17
|
+
"receipt_chain_links_verified": 18,
|
|
18
|
+
"policy_sha256": "7fc75c3841bbe6ee6401bfb57db26e1ce117de1d1dd53095cbb1f7fbc9c5cf6e",
|
|
19
|
+
"policy_matches_all_receipts": true,
|
|
20
|
+
"gate": {
|
|
21
|
+
"detector": "spawn_rate",
|
|
22
|
+
"observed": 16,
|
|
23
|
+
"threshold": 16,
|
|
24
|
+
"window_active_minutes": 15
|
|
25
|
+
},
|
|
26
|
+
"decision_counts": {
|
|
27
|
+
"OK": 7,
|
|
28
|
+
"WARN": 8,
|
|
29
|
+
"STOP": 3
|
|
30
|
+
},
|
|
31
|
+
"frame_count": 39,
|
|
32
|
+
"decision_times": [
|
|
33
|
+
"2026-09-20T20:20:32.556Z",
|
|
34
|
+
"2026-09-20T20:20:33.676Z",
|
|
35
|
+
"2026-09-20T20:20:34.808Z",
|
|
36
|
+
"2026-09-20T20:20:36.001Z",
|
|
37
|
+
"2026-09-20T20:20:37.136Z",
|
|
38
|
+
"2026-09-20T20:20:38.251Z",
|
|
39
|
+
"2026-09-20T20:20:39.443Z",
|
|
40
|
+
"2026-09-20T20:20:40.575Z",
|
|
41
|
+
"2026-09-20T20:20:41.787Z",
|
|
42
|
+
"2026-09-20T20:20:42.873Z",
|
|
43
|
+
"2026-09-20T20:20:44.005Z",
|
|
44
|
+
"2026-09-20T20:20:45.188Z",
|
|
45
|
+
"2026-09-20T20:20:46.314Z",
|
|
46
|
+
"2026-09-20T20:20:47.428Z",
|
|
47
|
+
"2026-09-20T20:20:48.629Z",
|
|
48
|
+
"2026-09-20T20:20:49.753Z",
|
|
49
|
+
"2026-09-20T20:20:50.936Z",
|
|
50
|
+
"2026-09-20T20:20:51.935Z"
|
|
51
|
+
],
|
|
52
|
+
"missing_measurements": [
|
|
53
|
+
"tokens in window",
|
|
54
|
+
"session cache-read share"
|
|
55
|
+
],
|
|
56
|
+
"timing": "Original decision timestamps plus one-second clock ticks and explicit range boundaries. STOP holds are display only; the end bound clips them. The renderer adds one second for the final frame. GIF uses centisecond precision.",
|
|
57
|
+
"output_sha256": {
|
|
58
|
+
"burn-real-stop-sep20.jsonl": "514152bfe025b12262f288c8fa7c3dc2cbafacd33f5b858f9bad26d981f47038",
|
|
59
|
+
"burn-real-stop-sep20.mp4": "9b31a4ece0dc99e235f3f90b6e07e5ce59d573d094d46ef3b7d9bb56fccd3bfd",
|
|
60
|
+
"burn-real-stop-sep20.gif": "d9ca444b696ba84ec33f9e5a143c2a7014c6e7e7f465f45cd54b28d2ba3ea6c4",
|
|
61
|
+
"burn-real-stop-sep20-stop-1920.png": "7063d9b26d97231db20663a114baf546689165bf782778e3b2f8202262750b4a",
|
|
62
|
+
"burn-real-stop-sep20-stop-390.png": "1b7eeeb357d9e1e310dc4f5bfb1d6b7babc6232381e4edddc30359aa745555b5",
|
|
63
|
+
"burn-real-stop-sep20.receipts.ndjson": "bd34a5cc07404d4a3c9023089b133ea01a4d55ee4c7a2558b4b04da966637fda",
|
|
64
|
+
"burn-real-stop-sep20.policy.json": "9a130d1bf4d8555ba2db0f4f41e24b881774461aaa20a3b6aed1c074e03bbca9",
|
|
65
|
+
"burn-real-stop-sep20.mp4.provenance.json": "9ef8f9d60b9802cc1f37eb85c87763e44b7577b8cdad3204fff8a8a08bd93f8d"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
Binary file
|