@agentguard-run/burn 0.2.7 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +78 -0
- package/dist/src/adapters/codex.js +6 -0
- package/dist/src/canvas.d.ts +36 -0
- package/dist/src/canvas.js +132 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +116 -25
- package/dist/src/defaults.d.ts +1 -1
- package/dist/src/defaults.js +4 -1
- package/dist/src/frames.d.ts +42 -0
- package/dist/src/frames.js +108 -0
- package/dist/src/gateway.js +3 -0
- package/dist/src/hook/pre-tool-use.d.ts +1 -0
- package/dist/src/hook/pre-tool-use.js +12 -1
- package/dist/src/idle/cache.d.ts +8 -0
- package/dist/src/idle/cache.js +70 -0
- package/dist/src/idle/classify.d.ts +13 -0
- package/dist/src/idle/classify.js +133 -0
- package/dist/src/idle/cli.d.ts +2 -0
- package/dist/src/idle/cli.js +72 -0
- package/dist/src/idle/collect.d.ts +38 -0
- package/dist/src/idle/collect.js +543 -0
- package/dist/src/idle/hook.d.ts +17 -0
- package/dist/src/idle/hook.js +61 -0
- package/dist/src/idle/platform.d.ts +25 -0
- package/dist/src/idle/platform.js +154 -0
- package/dist/src/idle/reap.d.ts +41 -0
- package/dist/src/idle/reap.js +262 -0
- package/dist/src/idle/render.d.ts +10 -0
- package/dist/src/idle/render.js +108 -0
- package/dist/src/idle/types.d.ts +56 -0
- package/dist/src/idle/types.js +8 -0
- package/dist/src/install.js +4 -2
- package/dist/src/live-panel.d.ts +42 -0
- package/dist/src/live-panel.js +283 -0
- package/dist/src/policy.d.ts +3 -1
- package/dist/src/policy.js +49 -3
- package/dist/src/presentation.d.ts +5 -0
- package/dist/src/presentation.js +23 -0
- package/dist/src/recording.d.ts +22 -0
- package/dist/src/recording.js +102 -0
- package/dist/src/render-recording.d.ts +15 -0
- package/dist/src/render-recording.js +200 -0
- package/dist/src/replay/render.js +5 -0
- package/dist/src/types.d.ts +6 -0
- package/docs/LIVE_PANEL_2026_09.md +55 -0
- 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 +288 -0
- package/docs/assets/burn-live-sep19.gif +0 -0
- package/docs/assets/burn-live-sep19.jsonl +45 -0
- package/docs/assets/burn-live-sep19.mp4 +0 -0
- package/docs/assets/burn-live-sep19.mp4.provenance.json +292 -0
- package/docs/assets/burn-live-sep19.observations.jsonl +45 -0
- package/docs/assets/burn-live-sep19.receipt.json +38 -0
- package/docs/burn-idle-audit.md +71 -0
- package/docs/burn-render.md +37 -0
- package/fixtures/live-panel-104x35.txt +35 -0
- package/fixtures/live-panel-80x24.txt +24 -0
- package/package.json +4 -2
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Canvas, type CanvasColor } from './canvas';
|
|
2
|
+
import type { FrameOptions } from './frames';
|
|
3
|
+
import type { BurnReport, Verdict } from './types';
|
|
4
|
+
export interface PanelDecision {
|
|
5
|
+
id: string;
|
|
6
|
+
at: number;
|
|
7
|
+
tool: string;
|
|
8
|
+
verdict: Verdict;
|
|
9
|
+
reason: string;
|
|
10
|
+
blocked: boolean | null;
|
|
11
|
+
}
|
|
12
|
+
export interface LivePanelSnapshot {
|
|
13
|
+
session: string;
|
|
14
|
+
startedAt: number;
|
|
15
|
+
observedAt: number;
|
|
16
|
+
spawns: number | null;
|
|
17
|
+
tokens: number | null;
|
|
18
|
+
replayShare: number | null;
|
|
19
|
+
windowActiveMinutes: number | null;
|
|
20
|
+
spawnCeiling: number | null;
|
|
21
|
+
decisions: PanelDecision[];
|
|
22
|
+
elapsedMs?: number | null;
|
|
23
|
+
controls?: boolean;
|
|
24
|
+
stopHold?: boolean;
|
|
25
|
+
note: string;
|
|
26
|
+
stop?: PanelDecision;
|
|
27
|
+
}
|
|
28
|
+
export declare function panelDecisions(home: string, sessionId: string, at?: number): PanelDecision[];
|
|
29
|
+
export declare function collectLivePanel(home: string, session?: string, now?: number): LivePanelSnapshot;
|
|
30
|
+
export declare function spawnBarColor(spawns: number | null, ceiling: number | null): CanvasColor;
|
|
31
|
+
export declare function renderLivePanel(snapshot: LivePanelSnapshot, options?: FrameOptions): Canvas;
|
|
32
|
+
/** STOP report adaptation uses only already-computed metadata, never local I/O. */
|
|
33
|
+
export declare function panelFromReport(report: BurnReport, at: number, outcome?: 'blocked' | 'shadow' | 'overridden' | 'allowed', subject?: 'spawn' | 'call'): LivePanelSnapshot;
|
|
34
|
+
/** Display-only hold. Intake continues and the latest observation follows the card. */
|
|
35
|
+
export declare class StopHold {
|
|
36
|
+
private held?;
|
|
37
|
+
private until;
|
|
38
|
+
private seen;
|
|
39
|
+
private latest?;
|
|
40
|
+
update(snapshot: LivePanelSnapshot, now: number): LivePanelSnapshot;
|
|
41
|
+
}
|
|
42
|
+
export declare function runLivePanel(home: string, args: string[]): Promise<number>;
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.StopHold = void 0;
|
|
4
|
+
exports.panelDecisions = panelDecisions;
|
|
5
|
+
exports.collectLivePanel = collectLivePanel;
|
|
6
|
+
exports.spawnBarColor = spawnBarColor;
|
|
7
|
+
exports.renderLivePanel = renderLivePanel;
|
|
8
|
+
exports.panelFromReport = panelFromReport;
|
|
9
|
+
exports.runLivePanel = runLivePanel;
|
|
10
|
+
/** Local, content-free instrument panel. It observes; it never changes admission. */
|
|
11
|
+
const node_crypto_1 = require("node:crypto");
|
|
12
|
+
const node_fs_1 = require("node:fs");
|
|
13
|
+
const node_path_1 = require("node:path");
|
|
14
|
+
const canvas_1 = require("./canvas");
|
|
15
|
+
const policy_1 = require("./policy");
|
|
16
|
+
const recording_1 = require("./recording");
|
|
17
|
+
const session_1 = require("./state/session");
|
|
18
|
+
const digest = (text) => (0, node_crypto_1.createHash)('sha256').update(text).digest('hex');
|
|
19
|
+
const number = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null;
|
|
20
|
+
const row = (v) => v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
21
|
+
const safe = (v, fallback) => typeof v === 'string' && /^[A-Za-z0-9_.:-]{1,80}$/.test(v) ? v : fallback;
|
|
22
|
+
const verdict = (v) => v === 'OK' || v === 'WARN' || v === 'STOP' ? v : null;
|
|
23
|
+
function buckets(value) {
|
|
24
|
+
if (!Array.isArray(value) || !value.every(v => Array.isArray(v) && v.length === 2 && number(v[0]) !== null && number(v[1]) !== null))
|
|
25
|
+
return null;
|
|
26
|
+
return new Map(value);
|
|
27
|
+
}
|
|
28
|
+
/** Read a bounded tail and discard an incomplete first/last JSON row. */
|
|
29
|
+
function ledger(file) {
|
|
30
|
+
try {
|
|
31
|
+
const size = (0, node_fs_1.statSync)(file).size, offset = Math.max(0, size - 4 * 1024 * 1024), fd = (0, node_fs_1.openSync)(file, 'r');
|
|
32
|
+
const bytes = Buffer.alloc(size - offset);
|
|
33
|
+
try {
|
|
34
|
+
(0, node_fs_1.readSync)(fd, bytes, 0, bytes.length, offset);
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
(0, node_fs_1.closeSync)(fd);
|
|
38
|
+
}
|
|
39
|
+
const text = bytes.toString('utf8'), lines = text.slice(offset ? text.indexOf('\n') + 1 : 0, text.lastIndexOf('\n') + 1).split('\n');
|
|
40
|
+
return lines.flatMap(line => { try {
|
|
41
|
+
return [row(JSON.parse(line))];
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return [];
|
|
45
|
+
} });
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function panelDecisions(home, sessionId, at = Date.now()) {
|
|
52
|
+
const sessionDigest = digest(sessionId);
|
|
53
|
+
const receiptRows = ledger((0, node_path_1.join)(home, 'receipts.ndjson')).flatMap(raw => {
|
|
54
|
+
const p = row(raw.payload);
|
|
55
|
+
if (p.sessionDigest !== sessionDigest || number(p.at) === null || Number(p.at) > at || !verdict(p.verdict))
|
|
56
|
+
return [];
|
|
57
|
+
return [{ id: safe(p.decisionId, digest(JSON.stringify(p))), at: Number(p.at), tool: safe(p.action, 'unknown'), verdict: verdict(p.verdict),
|
|
58
|
+
reason: Array.isArray(p.reasons) ? [...new Set(p.reasons.map(v => safe(v, 'unknown')))].join(' + ') : 'reason unavailable', blocked: p.blocked === true }];
|
|
59
|
+
});
|
|
60
|
+
const decisions = ledger((0, node_path_1.join)(home, 'decisions.ndjson')).flatMap(p => {
|
|
61
|
+
if (p.sessionId !== sessionId || number(p.at) === null || Number(p.at) > at || !verdict(p.verdict))
|
|
62
|
+
return [];
|
|
63
|
+
const findings = Array.isArray(p.findings) ? p.findings.map(row) : [];
|
|
64
|
+
return [{ id: safe(p.toolUseId, digest(JSON.stringify(p))), at: Number(p.at), tool: safe(p.action ?? p.tool, 'spawn'), verdict: verdict(p.verdict),
|
|
65
|
+
reason: findings.map(f => `${safe(f.detector, 'unknown')}:${verdict(f.verdict) ?? 'OK'}`).join(' + ') || (p.failClosed === true ? 'local fail-closed condition' : p.verdict === 'OK' ? 'within policy' : 'reason unavailable'), blocked: p.enforced === true }];
|
|
66
|
+
});
|
|
67
|
+
const merged = [...decisions], joined = new Set();
|
|
68
|
+
for (const d of receiptRows) {
|
|
69
|
+
const index = merged.findIndex((candidate, i) => !joined.has(i) && candidate.at === d.at && candidate.tool === d.tool && candidate.verdict === d.verdict && candidate.blocked === d.blocked);
|
|
70
|
+
if (index >= 0) {
|
|
71
|
+
merged[index] = d;
|
|
72
|
+
joined.add(index);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
joined.add(merged.length);
|
|
76
|
+
merged.push(d);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return merged.sort((a, b) => a.at - b.at).slice(-8);
|
|
80
|
+
}
|
|
81
|
+
function collectLivePanel(home, session, now = Date.now()) {
|
|
82
|
+
const policy = (0, policy_1.loadPolicy)(home, { notice: false });
|
|
83
|
+
const configured = policy.thresholds.spawnRate;
|
|
84
|
+
const windowActiveMinutes = number(configured?.windowActiveMinutes) || 15, spawnCeiling = number(configured?.stop) || 16;
|
|
85
|
+
const base = { session: 'unavailable', startedAt: now, observedAt: now, spawns: null, tokens: null, replayShare: null,
|
|
86
|
+
windowActiveMinutes, spawnCeiling, decisions: [], note: 'No readable session state. Waiting for local observations.' };
|
|
87
|
+
let names;
|
|
88
|
+
try {
|
|
89
|
+
names = (0, node_fs_1.readdirSync)((0, node_path_1.join)(home, 'sessions')).filter(name => name.endsWith('.json'));
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return base;
|
|
93
|
+
}
|
|
94
|
+
const candidates = names.flatMap(name => {
|
|
95
|
+
try {
|
|
96
|
+
const path = (0, node_path_1.join)(home, 'sessions', name);
|
|
97
|
+
if ((0, node_fs_1.statSync)(path).size > 16 * 1024 * 1024)
|
|
98
|
+
return [];
|
|
99
|
+
const raw = row(JSON.parse((0, node_fs_1.readFileSync)(path, 'utf8'))), state = row(raw.state);
|
|
100
|
+
if (typeof state.sessionId !== 'string' || number(state.lastEventAt) === null || number(state.startedAt) === null)
|
|
101
|
+
return [];
|
|
102
|
+
if (session && state.sessionId !== session && !state.sessionId.startsWith(session) && !digest(state.sessionId).startsWith(session))
|
|
103
|
+
return [];
|
|
104
|
+
return [{ raw, state }];
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
}).sort((a, b) => Number(b.state.lastEventAt) - Number(a.state.lastEventAt));
|
|
110
|
+
if (!candidates.length)
|
|
111
|
+
return base;
|
|
112
|
+
const { raw, state } = candidates[0], active = number(state.activeMinutes), spawned = buckets(state.spawnsByActiveMinute), tokens = buckets(state.tokensByActiveMinute);
|
|
113
|
+
const total = number(state.totalTokens), cached = number(state.totalCacheRead), decisions = panelDecisions(home, String(state.sessionId), now);
|
|
114
|
+
const coverage = row(raw.capabilities), gateway = Array.isArray(raw.hosts);
|
|
115
|
+
const spawnKnown = !gateway || ['authoritative', 'estimated'].includes(String(coverage.spawns));
|
|
116
|
+
const usageKnown = !gateway || ['authoritative', 'estimated'].includes(String(coverage.usage));
|
|
117
|
+
return { ...base, session: digest(String(state.sessionId)).slice(0, 8), startedAt: Number(state.startedAt),
|
|
118
|
+
spawns: spawnKnown && spawned && active !== null ? (0, session_1.windowSum)(spawned, active, windowActiveMinutes) : null,
|
|
119
|
+
tokens: usageKnown && tokens && active !== null ? (0, session_1.windowSum)(tokens, active, windowActiveMinutes) : null,
|
|
120
|
+
replayShare: usageKnown && total !== null && cached !== null && total > 0 ? Math.min(1, cached / total) : null, decisions,
|
|
121
|
+
note: row(raw.cursor).usageVersion === undefined && raw.cursor ? 'Legacy stored usage counts; no recount applied.' : gateway ? `Local observations. Coverage: spawns ${safe(coverage.spawns, 'unknown')}, usage ${safe(coverage.usage, 'unknown')}.` : 'Local observations only. Replay share is session cache-read / tokens.' };
|
|
122
|
+
}
|
|
123
|
+
const compact = (v) => v === null ? { digits: '-', unit: 'unknown' } : v >= 1e9 ? { digits: (v / 1e9).toFixed(2), unit: 'B tokens' } : v >= 1e6 ? { digits: (v / 1e6).toFixed(2), unit: 'M tokens' } : v >= 1e3 ? { digits: (v / 1e3).toFixed(1), unit: 'K tokens' } : { digits: String(Math.round(v)), unit: 'tokens' };
|
|
124
|
+
function spawnBarColor(spawns, ceiling) {
|
|
125
|
+
return spawns === null || ceiling === null ? 'slate' : spawns >= ceiling ? 'red' : spawns >= ceiling * .8 ? 'amber' : 'mint';
|
|
126
|
+
}
|
|
127
|
+
function elapsed(ms) {
|
|
128
|
+
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
129
|
+
return `${String(Math.floor(seconds / 3600)).padStart(2, '0')}:${String(Math.floor(seconds / 60) % 60).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
|
|
130
|
+
}
|
|
131
|
+
function renderLivePanel(snapshot, options = {}) {
|
|
132
|
+
const c = new canvas_1.Canvas(options), w = c.width, h = c.height, narrow = w < 80, split = narrow ? w : Math.floor(w * .45), right = narrow ? 0 : split + 3, room = w - right;
|
|
133
|
+
const at = options.at ?? new Date(snapshot.observedAt).toISOString(), color = spawnBarColor(snapshot.spawns, snapshot.spawnCeiling);
|
|
134
|
+
c.header('AGENTGUARD / BURN', options.recorded ? 'RECORDED RUN · 1x' : 'LIVE');
|
|
135
|
+
c.footer(snapshot.controls === false ? 'local observation only' : narrow ? 'q quit · p pause' : 'q quit · p pause · local only', `${narrow ? '' : 'elapsed '}${snapshot.elapsedMs === null ? 'unknown' : elapsed(snapshot.elapsedMs ?? snapshot.observedAt - snapshot.startedAt)}`);
|
|
136
|
+
c.put(2, 0, `session ${snapshot.session} · ${at.slice(11, 19)} UTC`, 'slate');
|
|
137
|
+
if (!narrow)
|
|
138
|
+
for (let y = 3; y < h - 3; y++)
|
|
139
|
+
c.put(y, split + 1, '│', 'slate');
|
|
140
|
+
const leftWidth = split, first = h >= 30 ? 4 : 3, second = h >= 30 ? 11 : 8, third = h >= 30 ? 18 : 13;
|
|
141
|
+
c.put(first, 0, 'SPAWNS', 'slate').digits(first + 1, 0, snapshot.spawns === null ? '-' : snapshot.spawns, color);
|
|
142
|
+
c.put(first + 4, 0, `${snapshot.windowActiveMinutes ?? '?'} active min · ceiling ${snapshot.spawnCeiling ?? '?'}`.slice(0, leftWidth), 'slate');
|
|
143
|
+
c.put(second, 0, 'TOKENS IN WINDOW', 'slate');
|
|
144
|
+
const tokens = compact(snapshot.tokens);
|
|
145
|
+
c.digits(second + 1, 0, tokens.digits, 'mint');
|
|
146
|
+
c.put(second + 4, 0, `${tokens.unit} · ${snapshot.windowActiveMinutes ?? '?'} active min`.slice(0, leftWidth), 'slate');
|
|
147
|
+
c.put(third, 0, 'REPLAY SHARE %', 'slate').digits(third + 1, 0, snapshot.replayShare === null ? '-' : Math.round(snapshot.replayShare * 100), 'mint');
|
|
148
|
+
c.put(third + 4, 0, 'session cache-read / tokens'.slice(0, leftWidth), 'slate');
|
|
149
|
+
const barRow = h - 6;
|
|
150
|
+
c.put(barRow, 0, `SPAWN RATE ${snapshot.spawns ?? '?'} / ${snapshot.spawnCeiling ?? '?'}`.slice(0, leftWidth), color);
|
|
151
|
+
c.bar(barRow + 1, 0, leftWidth, snapshot.spawns === null || snapshot.spawnCeiling === null ? 0 : snapshot.spawns / snapshot.spawnCeiling, color);
|
|
152
|
+
if (!narrow)
|
|
153
|
+
c.put(first, right, 'LAST 8 DECISIONS', 'slate');
|
|
154
|
+
(narrow ? [] : snapshot.decisions.slice(-8)).forEach((d, index) => {
|
|
155
|
+
const y = first + 1 + index * 2, chip = d.verdict === 'OK' ? 'clean' : d.verdict, tint = d.verdict === 'STOP' ? 'red' : d.verdict === 'WARN' ? 'amber' : 'mint';
|
|
156
|
+
c.put(y, right, `${new Date(d.at).toISOString().slice(11, 19)} ${d.tool}`.slice(0, Math.max(0, room - 8)), 'white');
|
|
157
|
+
c.put(y, Math.max(right, w - 7), `[${chip}]`, tint);
|
|
158
|
+
c.put(y + 1, right, d.reason.slice(0, room), 'slate');
|
|
159
|
+
});
|
|
160
|
+
if (!narrow && !snapshot.decisions.length)
|
|
161
|
+
c.put(6, right, 'No recorded decisions yet.', 'slate');
|
|
162
|
+
if (snapshot.stop) {
|
|
163
|
+
const y = h >= 30 ? h - 11 : 16;
|
|
164
|
+
for (let i = 0; i < 5; i++)
|
|
165
|
+
c.put(y + i, right, ' '.repeat(room), 'none');
|
|
166
|
+
c.put(y, right, 'STOP · ' + (snapshot.stop.blocked === true ? 'blocked' : snapshot.stop.blocked === false ? 'shadow / allowed' : 'outcome unknown'), 'red');
|
|
167
|
+
c.put(y + 1, right, snapshot.stop.reason.slice(0, room), 'red');
|
|
168
|
+
c.put(y + 2, right, snapshot.stopHold === false ? 'Latest STOP reason.' : 'Reason held for 2 seconds.', 'slate');
|
|
169
|
+
}
|
|
170
|
+
c.put(h - 3, 0, snapshot.note.slice(0, w), 'slate');
|
|
171
|
+
return c;
|
|
172
|
+
}
|
|
173
|
+
/** STOP report adaptation uses only already-computed metadata, never local I/O. */
|
|
174
|
+
function panelFromReport(report, at, outcome, subject) {
|
|
175
|
+
const rate = report.findings.find(f => f.detector === 'spawn_rate');
|
|
176
|
+
const decision = { id: digest(`${report.sessionId}:${at}`), at, tool: subject === 'call' ? 'model_call' : 'spawn', verdict: report.verdict,
|
|
177
|
+
reason: report.findings.map(f => `${f.detector}:${f.verdict} ${f.observed}/${f.threshold}`).join(' + ') || 'Reason not recorded.', blocked: outcome === undefined ? null : outcome === 'blocked' };
|
|
178
|
+
const note = outcome === 'blocked' ? (subject === 'call' ? 'model call blocked' : 'agent spawn blocked')
|
|
179
|
+
: outcome === 'shadow' ? 'shadow: would have blocked, call allowed' : outcome === 'overridden' ? 'override: call allowed'
|
|
180
|
+
: outcome === 'allowed' ? 'call allowed' : 'STOP boundary · enforcement outcome not recorded';
|
|
181
|
+
return { session: digest(report.sessionId).slice(0, 8), startedAt: at, observedAt: at, elapsedMs: null, controls: false, stopHold: false,
|
|
182
|
+
spawns: rate?.observed ?? null, tokens: null, replayShare: Number.isFinite(report.cacheReadRatio) ? report.cacheReadRatio : null,
|
|
183
|
+
windowActiveMinutes: null, spawnCeiling: rate?.verdict === 'STOP' ? rate.threshold : null,
|
|
184
|
+
decisions: [decision], stop: decision, note: `${note}. Unknown window metrics remain unknown.` };
|
|
185
|
+
}
|
|
186
|
+
/** Display-only hold. Intake continues and the latest observation follows the card. */
|
|
187
|
+
class StopHold {
|
|
188
|
+
held;
|
|
189
|
+
until = 0;
|
|
190
|
+
seen = new Set();
|
|
191
|
+
latest;
|
|
192
|
+
update(snapshot, now) {
|
|
193
|
+
this.latest = snapshot;
|
|
194
|
+
const stop = snapshot.decisions.filter(d => d.verdict === 'STOP' && !this.seen.has(d.id)).at(-1);
|
|
195
|
+
for (const decision of snapshot.decisions)
|
|
196
|
+
this.seen.add(decision.id);
|
|
197
|
+
if (stop) {
|
|
198
|
+
this.held = { ...snapshot, stop };
|
|
199
|
+
this.until = now + 2000;
|
|
200
|
+
}
|
|
201
|
+
if (this.held && now < this.until)
|
|
202
|
+
return this.held;
|
|
203
|
+
this.held = undefined;
|
|
204
|
+
return this.latest;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
exports.StopHold = StopHold;
|
|
208
|
+
async function runLivePanel(home, args) {
|
|
209
|
+
const flag = (key) => {
|
|
210
|
+
const i = args.indexOf(key);
|
|
211
|
+
if (i < 0)
|
|
212
|
+
return undefined;
|
|
213
|
+
if (!args[i + 1] || args[i + 1].startsWith('--'))
|
|
214
|
+
throw new Error(`Use ${key} with a value.`);
|
|
215
|
+
return args[i + 1];
|
|
216
|
+
};
|
|
217
|
+
const replayFile = flag('--replay'), selected = flag('--session');
|
|
218
|
+
const once = args.includes('--once') || !process.stdout.isTTY;
|
|
219
|
+
const colour = !args.includes('--no-color') && process.env.NO_COLOR === undefined && Boolean(process.stdout.isTTY);
|
|
220
|
+
const viewport = { width: Math.min(104, process.stdout.columns || 104), height: Math.min(35, process.stdout.rows || 35) };
|
|
221
|
+
let paused = false, stopped = false;
|
|
222
|
+
const key = (bytes) => { const text = String(bytes); if (text.includes('q') || text.includes('\x03'))
|
|
223
|
+
stopped = true; if (text.includes('p'))
|
|
224
|
+
paused = !paused; };
|
|
225
|
+
const stop = () => { stopped = true; };
|
|
226
|
+
const display = (snapshot, recorded, at) => {
|
|
227
|
+
(0, recording_1.recordFrame)({ kind: 'live', snapshot }, at, viewport);
|
|
228
|
+
process.stdout.write((process.stdout.isTTY ? '\x1b[H\x1b[2J' : '') + renderLivePanel(snapshot, { ...viewport, at, recorded, colour }).render() + '\n');
|
|
229
|
+
};
|
|
230
|
+
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
231
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
232
|
+
if (interactive) {
|
|
233
|
+
process.stdin.setRawMode(true);
|
|
234
|
+
process.stdin.resume();
|
|
235
|
+
process.stdin.on('data', key);
|
|
236
|
+
}
|
|
237
|
+
process.on('SIGINT', stop);
|
|
238
|
+
try {
|
|
239
|
+
if (replayFile) {
|
|
240
|
+
if (!(0, node_fs_1.existsSync)(replayFile))
|
|
241
|
+
throw new Error('Local recording is unavailable.');
|
|
242
|
+
const frames = (0, recording_1.readRecording)(replayFile).filter(frame => frame.data.kind === 'live');
|
|
243
|
+
if (!frames.length)
|
|
244
|
+
throw new Error('Recording has no live instrument frames.');
|
|
245
|
+
for (let i = 0; i < frames.length && !stopped; i++) {
|
|
246
|
+
const frame = frames[i];
|
|
247
|
+
if (frame.data.kind !== 'live')
|
|
248
|
+
continue;
|
|
249
|
+
display(frame.data.snapshot, true, frame.at);
|
|
250
|
+
if (!once) {
|
|
251
|
+
const delay = i + 1 < frames.length ? Date.parse(frames[i + 1].at) - Date.parse(frame.at) : 2000;
|
|
252
|
+
let remaining = delay;
|
|
253
|
+
while (remaining > 0 && !stopped) {
|
|
254
|
+
const step = Math.min(100, remaining);
|
|
255
|
+
await wait(step);
|
|
256
|
+
if (!paused)
|
|
257
|
+
remaining -= step;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
const hold = new StopHold();
|
|
264
|
+
do {
|
|
265
|
+
if (!paused) {
|
|
266
|
+
const now = Date.now(), snapshot = hold.update(collectLivePanel(home, selected, now), now);
|
|
267
|
+
display(snapshot, false, new Date(now).toISOString());
|
|
268
|
+
}
|
|
269
|
+
if (!once)
|
|
270
|
+
await wait(500);
|
|
271
|
+
} while (!once && !stopped);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
process.off('SIGINT', stop);
|
|
276
|
+
if (interactive) {
|
|
277
|
+
process.stdin.off('data', key);
|
|
278
|
+
process.stdin.setRawMode(false);
|
|
279
|
+
process.stdin.pause();
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return 0;
|
|
283
|
+
}
|
package/dist/src/policy.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Policy } from './types';
|
|
2
2
|
/** Normalize before evaluating or hashing so receipts bind the actual policy. */
|
|
3
3
|
export declare function normalizePolicy(policy: Policy): Policy;
|
|
4
|
-
export declare function loadPolicy(home: string
|
|
4
|
+
export declare function loadPolicy(home: string, options?: {
|
|
5
|
+
notice?: boolean;
|
|
6
|
+
}): Policy;
|
package/dist/src/policy.js
CHANGED
|
@@ -7,10 +7,56 @@ const node_fs_1 = require("node:fs");
|
|
|
7
7
|
const node_path_1 = require("node:path");
|
|
8
8
|
const defaults_1 = require("./defaults");
|
|
9
9
|
const noticed = new Set();
|
|
10
|
+
const teamNoticed = new Set();
|
|
11
|
+
const idleFields = ['idle_session_warn_hours', 'idle_browser_warn_minutes', 'orphan_workspace_warn_days'];
|
|
12
|
+
function validIdle(value, fallback) {
|
|
13
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
14
|
+
}
|
|
15
|
+
function withTeamThresholds(home, policy) {
|
|
16
|
+
if (!policy.teamPolicyFile)
|
|
17
|
+
return policy;
|
|
18
|
+
try {
|
|
19
|
+
if (typeof policy.teamPolicyFile !== 'string')
|
|
20
|
+
throw new Error('invalid_team_policy_path');
|
|
21
|
+
const file = (0, node_path_1.resolve)(home, policy.teamPolicyFile);
|
|
22
|
+
if (file === (0, node_path_1.resolve)(home, 'burn-policy.json'))
|
|
23
|
+
throw new Error('recursive_team_policy_path');
|
|
24
|
+
const team = JSON.parse((0, node_fs_1.readFileSync)(file, 'utf8'));
|
|
25
|
+
if (!team || !team.thresholds || typeof team.thresholds !== 'object' || Array.isArray(team.thresholds))
|
|
26
|
+
throw new Error('invalid_team_thresholds');
|
|
27
|
+
const thresholds = { ...policy.thresholds };
|
|
28
|
+
for (const key of ['fanout', 'sustained', 'burnDebt', 'spawnRate', 'duplicate', 'account', 'localCompute']) {
|
|
29
|
+
const value = team.thresholds[key];
|
|
30
|
+
if (value !== undefined) {
|
|
31
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
32
|
+
throw new Error('invalid_team_thresholds');
|
|
33
|
+
Object.assign(thresholds, { [key]: { ...thresholds[key], ...value } });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
for (const key of idleFields)
|
|
37
|
+
if (team.thresholds[key] !== undefined) {
|
|
38
|
+
const value = team.thresholds[key];
|
|
39
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0)
|
|
40
|
+
throw new Error('invalid_team_thresholds');
|
|
41
|
+
thresholds[key] = value;
|
|
42
|
+
}
|
|
43
|
+
return { ...policy, thresholds };
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
if (!teamNoticed.has(home)) {
|
|
47
|
+
teamNoticed.add(home);
|
|
48
|
+
process.stderr.write('AgentGuard shared thresholds unavailable; local thresholds and defaults apply.\n');
|
|
49
|
+
}
|
|
50
|
+
return policy;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
10
53
|
/** Normalize before evaluating or hashing so receipts bind the actual policy. */
|
|
11
54
|
function normalizePolicy(policy) {
|
|
12
55
|
const thresholds = policy.thresholds;
|
|
13
56
|
const normalized = {
|
|
57
|
+
idle_session_warn_hours: validIdle(thresholds.idle_session_warn_hours, defaults_1.DEFAULT_THRESHOLDS.idle_session_warn_hours),
|
|
58
|
+
idle_browser_warn_minutes: validIdle(thresholds.idle_browser_warn_minutes, defaults_1.DEFAULT_THRESHOLDS.idle_browser_warn_minutes),
|
|
59
|
+
orphan_workspace_warn_days: validIdle(thresholds.orphan_workspace_warn_days, defaults_1.DEFAULT_THRESHOLDS.orphan_workspace_warn_days),
|
|
14
60
|
fanout: { ...defaults_1.DEFAULT_THRESHOLDS.fanout, ...thresholds.fanout },
|
|
15
61
|
sustained: { ...defaults_1.DEFAULT_THRESHOLDS.sustained, ...thresholds.sustained },
|
|
16
62
|
burnDebt: { ...defaults_1.DEFAULT_THRESHOLDS.burnDebt, ...thresholds.burnDebt },
|
|
@@ -36,7 +82,7 @@ function noticeOnce(home, fields) {
|
|
|
36
82
|
}
|
|
37
83
|
process.stderr.write(`AgentGuard loaded missing policy fields from defaults: ${fields.join(', ')}; existing overrides are unchanged.\n`);
|
|
38
84
|
}
|
|
39
|
-
function loadPolicy(home) {
|
|
85
|
+
function loadPolicy(home, options = {}) {
|
|
40
86
|
try {
|
|
41
87
|
const parsed = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(home, 'burn-policy.json'), 'utf8'));
|
|
42
88
|
if (parsed && (parsed.mode === 'shadow' || parsed.mode === 'enforce') && parsed.thresholds) {
|
|
@@ -45,9 +91,9 @@ function loadPolicy(home) {
|
|
|
45
91
|
missing.push('fanout.windowActiveMinutes=120');
|
|
46
92
|
if (parsed.thresholds.spawnRate?.enforce === undefined)
|
|
47
93
|
missing.push('spawnRate.enforce=true');
|
|
48
|
-
if (missing.length)
|
|
94
|
+
if (missing.length && options.notice !== false)
|
|
49
95
|
noticeOnce(home, missing);
|
|
50
|
-
return normalizePolicy(parsed);
|
|
96
|
+
return normalizePolicy(withTeamThresholds(home, parsed));
|
|
51
97
|
}
|
|
52
98
|
}
|
|
53
99
|
catch {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.present = present;
|
|
4
|
+
/** Human terminal screens share the canvas; protocol and no-color bytes stay plain. */
|
|
5
|
+
const canvas_1 = require("./canvas");
|
|
6
|
+
const frames_1 = require("./frames");
|
|
7
|
+
const recording_1 = require("./recording");
|
|
8
|
+
function present(data, plain, options = {}) {
|
|
9
|
+
const colour = options.colour ?? (Boolean(process.stdout.isTTY) && process.env.NO_COLOR === undefined);
|
|
10
|
+
const screen = colour && !options.protocol;
|
|
11
|
+
const grid = new canvas_1.Canvas(screen ? {} : { width: 104, height: 35 });
|
|
12
|
+
const viewport = { width: Math.min(104, grid.width), height: Math.min(35, grid.height) };
|
|
13
|
+
const pages = screen || (0, recording_1.isRecording)() ? (0, frames_1.framePageCount)(data, viewport) : 1;
|
|
14
|
+
for (let page = 0; page < pages; page++) {
|
|
15
|
+
const frame = data.kind !== 'report' ? { ...data, page } : data;
|
|
16
|
+
const at = new Date().toISOString();
|
|
17
|
+
(0, recording_1.recordFrame)(frame, at, viewport);
|
|
18
|
+
if (screen)
|
|
19
|
+
process.stdout.write((0, frames_1.renderFrame)(frame, { at, ...viewport }).render() + '\n');
|
|
20
|
+
}
|
|
21
|
+
if (!screen)
|
|
22
|
+
process.stdout.write(plain);
|
|
23
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { BurnFrame } from './frames';
|
|
2
|
+
export interface RecordedFrame {
|
|
3
|
+
version: 1;
|
|
4
|
+
at: string;
|
|
5
|
+
data: BurnFrame;
|
|
6
|
+
viewport?: {
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export declare function configureRecording(file?: string): void;
|
|
12
|
+
export declare function isRecording(): boolean;
|
|
13
|
+
export declare function recordedFrameCount(): number;
|
|
14
|
+
export declare function recordFrame(data: BurnFrame, at?: string, viewport?: {
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
}): void;
|
|
18
|
+
export declare function readRecording(file: string): RecordedFrame[];
|
|
19
|
+
export declare function recordingArguments(argv: string[]): {
|
|
20
|
+
args: string[];
|
|
21
|
+
file?: string;
|
|
22
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.configureRecording = configureRecording;
|
|
4
|
+
exports.isRecording = isRecording;
|
|
5
|
+
exports.recordedFrameCount = recordedFrameCount;
|
|
6
|
+
exports.recordFrame = recordFrame;
|
|
7
|
+
exports.readRecording = readRecording;
|
|
8
|
+
exports.recordingArguments = recordingArguments;
|
|
9
|
+
/** Explicit, local-only frame recording. Never capture rendered output. */
|
|
10
|
+
const node_fs_1 = require("node:fs");
|
|
11
|
+
const node_path_1 = require("node:path");
|
|
12
|
+
let destination;
|
|
13
|
+
let frames = 0;
|
|
14
|
+
function configureRecording(file) { destination = file; frames = 0; }
|
|
15
|
+
function isRecording() { return destination !== undefined; }
|
|
16
|
+
function recordedFrameCount() { return frames; }
|
|
17
|
+
function recordFrame(data, at = new Date().toISOString(), viewport) {
|
|
18
|
+
if (!destination)
|
|
19
|
+
return;
|
|
20
|
+
try {
|
|
21
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(destination), { recursive: true, mode: 0o700 });
|
|
22
|
+
(0, node_fs_1.appendFileSync)(destination, JSON.stringify({ version: 1, at, data, ...(viewport ? { viewport } : {}) }) + '\n', { mode: 0o600 });
|
|
23
|
+
frames++;
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
// Recording cannot affect a hook's decision or an interactive cleanup.
|
|
27
|
+
process.stderr.write(`Burn recording unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
28
|
+
destination = undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function readRecording(file) {
|
|
32
|
+
if ((0, node_fs_1.statSync)(file).size > 128 * 1024 * 1024)
|
|
33
|
+
throw new Error('Recording exceeds the 128 MB render limit.');
|
|
34
|
+
const result = [];
|
|
35
|
+
for (const [index, line] of (0, node_fs_1.readFileSync)(file, 'utf8').split('\n').entries()) {
|
|
36
|
+
if (!line.trim())
|
|
37
|
+
continue;
|
|
38
|
+
let row;
|
|
39
|
+
try {
|
|
40
|
+
row = JSON.parse(line);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new Error(`Invalid recording JSON at line ${index + 1}.`);
|
|
44
|
+
}
|
|
45
|
+
if (row?.version !== 1 || typeof row.at !== 'string' || !Number.isFinite(Date.parse(row.at)) || !row.data || !['audit', 'report', 'replay', 'command', 'live'].includes(row.data.kind))
|
|
46
|
+
throw new Error(`Invalid frame at line ${index + 1}.`);
|
|
47
|
+
if (row.viewport && (!Number.isInteger(row.viewport.width) || row.viewport.width < 1 || row.viewport.width > 104 || !Number.isInteger(row.viewport.height) || row.viewport.height < 1 || row.viewport.height > 35))
|
|
48
|
+
throw new Error(`Invalid viewport at line ${index + 1}.`);
|
|
49
|
+
if (result.length && Date.parse(row.at) < Date.parse(result.at(-1).at))
|
|
50
|
+
throw new Error(`Recording timestamps go backwards at line ${index + 1}.`);
|
|
51
|
+
if (row.data.kind === 'live') {
|
|
52
|
+
const snapshot = row.data.snapshot;
|
|
53
|
+
const count = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
|
54
|
+
const timestamp = (value) => count(value) && Number(value) <= 8.64e15;
|
|
55
|
+
const decision = (value) => {
|
|
56
|
+
if (!value || typeof value !== 'object')
|
|
57
|
+
return false;
|
|
58
|
+
const d = value;
|
|
59
|
+
return typeof d.id === 'string' && timestamp(d.at) && typeof d.tool === 'string' && typeof d.reason === 'string'
|
|
60
|
+
&& ['OK', 'WARN', 'STOP'].includes(String(d.verdict)) && (typeof d.blocked === 'boolean' || d.blocked === null);
|
|
61
|
+
};
|
|
62
|
+
if (!snapshot || typeof snapshot.session !== 'string' || typeof snapshot.note !== 'string'
|
|
63
|
+
|| !timestamp(snapshot.observedAt) || !timestamp(snapshot.startedAt)
|
|
64
|
+
|| !(snapshot.windowActiveMinutes === null || count(snapshot.windowActiveMinutes) && snapshot.windowActiveMinutes > 0)
|
|
65
|
+
|| !(snapshot.spawnCeiling === null || count(snapshot.spawnCeiling) && snapshot.spawnCeiling > 0)
|
|
66
|
+
|| !(snapshot.elapsedMs === undefined || snapshot.elapsedMs === null || count(snapshot.elapsedMs))
|
|
67
|
+
|| ![snapshot.spawns, snapshot.tokens].every(value => value === null || count(value))
|
|
68
|
+
|| !(snapshot.replayShare === null || count(snapshot.replayShare) && snapshot.replayShare <= 1)
|
|
69
|
+
|| !Array.isArray(snapshot.decisions) || snapshot.decisions.length > 8 || !snapshot.decisions.every(decision)
|
|
70
|
+
|| snapshot.stop !== undefined && !decision(snapshot.stop))
|
|
71
|
+
throw new Error(`Invalid live frame at line ${index + 1}.`);
|
|
72
|
+
}
|
|
73
|
+
if (row.data.kind === 'audit' && (!Array.isArray(row.data.report?.rows) || !row.data.report?.totals))
|
|
74
|
+
throw new Error(`Invalid audit frame at line ${index + 1}.`);
|
|
75
|
+
if (row.data.kind === 'command' && (!row.data.values || typeof row.data.values !== 'object'))
|
|
76
|
+
throw new Error(`Invalid command frame at line ${index + 1}.`);
|
|
77
|
+
if (row.data.kind === 'report' && (!row.data.report?.totals || !Array.isArray(row.data.report.findings)))
|
|
78
|
+
throw new Error(`Invalid report frame at line ${index + 1}.`);
|
|
79
|
+
if (row.data.kind === 'replay' && !Array.isArray(row.data.summary?.sessions))
|
|
80
|
+
throw new Error(`Invalid replay frame at line ${index + 1}.`);
|
|
81
|
+
result.push(row);
|
|
82
|
+
if (result.length > 10000)
|
|
83
|
+
throw new Error('Recording exceeds the 10,000 frame render limit.');
|
|
84
|
+
}
|
|
85
|
+
if (!result.length)
|
|
86
|
+
throw new Error('Recording has no frames.');
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
function recordingArguments(argv) {
|
|
90
|
+
const args = [];
|
|
91
|
+
let file;
|
|
92
|
+
for (let i = 0; i < argv.length; i++) {
|
|
93
|
+
if (argv[i] !== '--record') {
|
|
94
|
+
args.push(argv[i]);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (file || !argv[i + 1] || argv[i + 1].startsWith('--'))
|
|
98
|
+
throw new Error('Use --record <file.jsonl> once.');
|
|
99
|
+
file = argv[++i];
|
|
100
|
+
}
|
|
101
|
+
return { args, file };
|
|
102
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type CanvasSnapshot } from './canvas';
|
|
2
|
+
export interface RenderRecordingResult {
|
|
3
|
+
framesDir: string;
|
|
4
|
+
provenancePath: string;
|
|
5
|
+
mp4: string | null;
|
|
6
|
+
gif: string | null;
|
|
7
|
+
ffmpegCommand: string;
|
|
8
|
+
frameCount: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function renderCanvasPng(snapshot: CanvasSnapshot, width?: number, height?: number): Promise<Buffer>;
|
|
11
|
+
export declare function renderRecording(source: string, options: {
|
|
12
|
+
mp4: string;
|
|
13
|
+
gif?: string;
|
|
14
|
+
ffmpeg?: string | null;
|
|
15
|
+
}): Promise<RenderRecordingResult>;
|