@agentguard-run/burn 0.2.7 → 0.3.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 +9 -0
- package/README.md +69 -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 +111 -25
- package/dist/src/defaults.d.ts +1 -1
- package/dist/src/defaults.js +4 -1
- package/dist/src/frames.d.ts +38 -0
- package/dist/src/frames.js +98 -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/policy.js +47 -1
- 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 +80 -0
- package/dist/src/render-recording.d.ts +13 -0
- package/dist/src/render-recording.js +193 -0
- package/dist/src/replay/render.js +5 -0
- package/dist/src/types.d.ts +6 -0
- package/docs/burn-idle-audit.md +71 -0
- package/docs/burn-render.md +37 -0
- package/package.json +4 -2
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 },
|
|
@@ -47,7 +93,7 @@ function loadPolicy(home) {
|
|
|
47
93
|
missing.push('spawnRate.enforce=true');
|
|
48
94
|
if (missing.length)
|
|
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,80 @@
|
|
|
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'].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 === 'audit' && (!Array.isArray(row.data.report?.rows) || !row.data.report?.totals))
|
|
52
|
+
throw new Error(`Invalid audit frame at line ${index + 1}.`);
|
|
53
|
+
if (row.data.kind === 'command' && (!row.data.values || typeof row.data.values !== 'object'))
|
|
54
|
+
throw new Error(`Invalid command frame at line ${index + 1}.`);
|
|
55
|
+
if (row.data.kind === 'report' && (!row.data.report?.totals || !Array.isArray(row.data.report.findings)))
|
|
56
|
+
throw new Error(`Invalid report frame at line ${index + 1}.`);
|
|
57
|
+
if (row.data.kind === 'replay' && !Array.isArray(row.data.summary?.sessions))
|
|
58
|
+
throw new Error(`Invalid replay frame at line ${index + 1}.`);
|
|
59
|
+
result.push(row);
|
|
60
|
+
if (result.length > 10000)
|
|
61
|
+
throw new Error('Recording exceeds the 10,000 frame render limit.');
|
|
62
|
+
}
|
|
63
|
+
if (!result.length)
|
|
64
|
+
throw new Error('Recording has no frames.');
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
function recordingArguments(argv) {
|
|
68
|
+
const args = [];
|
|
69
|
+
let file;
|
|
70
|
+
for (let i = 0; i < argv.length; i++) {
|
|
71
|
+
if (argv[i] !== '--record') {
|
|
72
|
+
args.push(argv[i]);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (file || !argv[i + 1] || argv[i + 1].startsWith('--'))
|
|
76
|
+
throw new Error('Use --record <file.jsonl> once.');
|
|
77
|
+
file = argv[++i];
|
|
78
|
+
}
|
|
79
|
+
return { args, file };
|
|
80
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface RenderRecordingResult {
|
|
2
|
+
framesDir: string;
|
|
3
|
+
provenancePath: string;
|
|
4
|
+
mp4: string | null;
|
|
5
|
+
gif: string | null;
|
|
6
|
+
ffmpegCommand: string;
|
|
7
|
+
frameCount: number;
|
|
8
|
+
}
|
|
9
|
+
export declare function renderRecording(source: string, options: {
|
|
10
|
+
mp4: string;
|
|
11
|
+
gif?: string;
|
|
12
|
+
ffmpeg?: string | null;
|
|
13
|
+
}): Promise<RenderRecordingResult>;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.renderRecording = renderRecording;
|
|
4
|
+
/** Offline PNG and video rendering from validated, recorded Canvas data. */
|
|
5
|
+
const node_fs_1 = require("node:fs");
|
|
6
|
+
const promises_1 = require("node:fs/promises");
|
|
7
|
+
const node_crypto_1 = require("node:crypto");
|
|
8
|
+
const node_child_process_1 = require("node:child_process");
|
|
9
|
+
const node_util_1 = require("node:util");
|
|
10
|
+
const node_path_1 = require("node:path");
|
|
11
|
+
const canvas_1 = require("@napi-rs/canvas");
|
|
12
|
+
const canvas_2 = require("./canvas");
|
|
13
|
+
const frames_1 = require("./frames");
|
|
14
|
+
const recording_1 = require("./recording");
|
|
15
|
+
const run = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
16
|
+
const CELL_WIDTH = 18, CELL_HEIGHT = 28;
|
|
17
|
+
const FONT_ALIAS = 'BurnRecordingMono';
|
|
18
|
+
const FONT_PATHS = [
|
|
19
|
+
'/System/Library/Fonts/Menlo.ttc',
|
|
20
|
+
'/Library/Fonts/Menlo.ttc',
|
|
21
|
+
'/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf',
|
|
22
|
+
'/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf',
|
|
23
|
+
'/usr/share/fonts/TTF/DejaVuSansMono.ttf',
|
|
24
|
+
];
|
|
25
|
+
async function font() {
|
|
26
|
+
for (const path of FONT_PATHS) {
|
|
27
|
+
try {
|
|
28
|
+
await (0, promises_1.access)(path, node_fs_1.constants.R_OK);
|
|
29
|
+
if (canvas_1.GlobalFonts.registerFromPath(path, FONT_ALIAS) && canvas_1.GlobalFonts.has(FONT_ALIAS))
|
|
30
|
+
return path;
|
|
31
|
+
}
|
|
32
|
+
catch { /* Try the next installed local font. */ }
|
|
33
|
+
}
|
|
34
|
+
throw new Error('Recording render needs an installed Menlo or DejaVu Sans Mono font; no font was downloaded.');
|
|
35
|
+
}
|
|
36
|
+
async function executable(requested) {
|
|
37
|
+
const paths = (0, node_path_1.isAbsolute)(requested) || requested.includes('/') || requested.includes('\\')
|
|
38
|
+
? [(0, node_path_1.resolve)(requested)] : (process.env.PATH || '').split(node_path_1.delimiter).filter(Boolean).map(path => (0, node_path_1.resolve)(path, requested));
|
|
39
|
+
for (const path of paths) {
|
|
40
|
+
try {
|
|
41
|
+
await (0, promises_1.access)(path, node_fs_1.constants.X_OK);
|
|
42
|
+
if ((await (0, promises_1.lstat)(path)).isFile() || (await (0, promises_1.lstat)(path)).isSymbolicLink())
|
|
43
|
+
return path;
|
|
44
|
+
}
|
|
45
|
+
catch { /* Not on this PATH entry. */ }
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
async function requireUnused(path) {
|
|
50
|
+
try {
|
|
51
|
+
await (0, promises_1.lstat)(path);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error.code === 'ENOENT')
|
|
55
|
+
return;
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
throw new Error(`Refusing to overwrite existing output: ${path}`);
|
|
59
|
+
}
|
|
60
|
+
/** For display only; execution always passes an argument array without a shell. */
|
|
61
|
+
function shellWord(value) { return `'${value.replace(/'/g, "'\\''")}'`; }
|
|
62
|
+
/** FFmpeg represents concat timestamps in microseconds; Date.parse truncates below milliseconds. */
|
|
63
|
+
function timestampMicros(at) {
|
|
64
|
+
const milliseconds = Date.parse(at);
|
|
65
|
+
if (!Number.isFinite(milliseconds))
|
|
66
|
+
throw new Error('Recording timestamps must be valid and ordered.');
|
|
67
|
+
const fraction = /\.(\d+)(?:Z|[+-]\d{2}:?\d{2})$/i.exec(at)?.[1] ?? '';
|
|
68
|
+
return BigInt(milliseconds) * 1000n + BigInt(fraction.slice(3, 6).padEnd(3, '0'));
|
|
69
|
+
}
|
|
70
|
+
async function png(snapshot) {
|
|
71
|
+
const canvas = (0, canvas_1.createCanvas)(1920, 1080);
|
|
72
|
+
const paddingX = Math.floor((canvas.width - snapshot.width * CELL_WIDTH) / 2);
|
|
73
|
+
const paddingY = Math.floor((canvas.height - snapshot.height * CELL_HEIGHT) / 2);
|
|
74
|
+
const ctx = canvas.getContext('2d');
|
|
75
|
+
ctx.fillStyle = '#0B1117';
|
|
76
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
77
|
+
ctx.font = `26px "${FONT_ALIAS}"`;
|
|
78
|
+
ctx.textBaseline = 'alphabetic';
|
|
79
|
+
for (let row = 0; row < snapshot.height; row++)
|
|
80
|
+
for (let col = 0; col < snapshot.width; col++) {
|
|
81
|
+
const cell = snapshot.cells[row][col];
|
|
82
|
+
if (cell.char === ' ')
|
|
83
|
+
continue;
|
|
84
|
+
ctx.fillStyle = canvas_2.CANVAS_PALETTE[cell.color === 'none' ? 'white' : cell.color].hex;
|
|
85
|
+
const x = paddingX + col * CELL_WIDTH, y = paddingY + row * CELL_HEIGHT;
|
|
86
|
+
if (cell.char === '█')
|
|
87
|
+
ctx.fillRect(x, y, CELL_WIDTH, CELL_HEIGHT);
|
|
88
|
+
else if (cell.char === '▀')
|
|
89
|
+
ctx.fillRect(x, y, CELL_WIDTH, CELL_HEIGHT / 2);
|
|
90
|
+
else if (cell.char === '▄')
|
|
91
|
+
ctx.fillRect(x, y + CELL_HEIGHT / 2, CELL_WIDTH, CELL_HEIGHT / 2);
|
|
92
|
+
else if (cell.char === '─')
|
|
93
|
+
ctx.fillRect(x, y + CELL_HEIGHT / 2, CELL_WIDTH, 1);
|
|
94
|
+
else
|
|
95
|
+
ctx.fillText(cell.char, x + 1, y + 23, CELL_WIDTH - 2);
|
|
96
|
+
}
|
|
97
|
+
return canvas.encode('png');
|
|
98
|
+
}
|
|
99
|
+
async function renderRecording(source, options) {
|
|
100
|
+
const sourcePath = (0, node_path_1.resolve)(source), mp4Path = (0, node_path_1.resolve)(options.mp4);
|
|
101
|
+
const gifPath = options.gif ? (0, node_path_1.resolve)(options.gif) : null;
|
|
102
|
+
const provenancePath = `${mp4Path}.provenance.json`;
|
|
103
|
+
for (const path of [sourcePath, mp4Path, provenancePath, ...(gifPath ? [gifPath] : [])]) {
|
|
104
|
+
if (/[\x00-\x1f\x7f]/.test(path))
|
|
105
|
+
throw new Error('Recording paths must not contain control characters.');
|
|
106
|
+
}
|
|
107
|
+
if (new Set([sourcePath, mp4Path, provenancePath, ...(gifPath ? [gifPath] : [])]).size !== (gifPath ? 4 : 3)) {
|
|
108
|
+
throw new Error('Recording source and each output must use different paths.');
|
|
109
|
+
}
|
|
110
|
+
for (const path of [mp4Path, provenancePath, ...(gifPath ? [gifPath] : [])])
|
|
111
|
+
await requireUnused(path);
|
|
112
|
+
const sourceStat = await (0, promises_1.stat)(sourcePath);
|
|
113
|
+
if (!sourceStat.isFile() || sourceStat.size > 128 * 1024 * 1024)
|
|
114
|
+
throw new Error('Recording must be a regular local file no larger than 128 MB.');
|
|
115
|
+
const sourceBytes = await (0, promises_1.readFile)(sourcePath);
|
|
116
|
+
const frames = await (0, recording_1.readRecording)(sourcePath);
|
|
117
|
+
if (!frames.length)
|
|
118
|
+
throw new Error('Recording contains no frames.');
|
|
119
|
+
const times = frames.map(frame => timestampMicros(frame.at));
|
|
120
|
+
const videoIndices = [];
|
|
121
|
+
for (let index = 0; index < frames.length; index++) {
|
|
122
|
+
if (index && times[index] < times[index - 1])
|
|
123
|
+
throw new Error('Recording timestamps must be valid and ordered.');
|
|
124
|
+
// No elapsed time exists between equal timestamps. Keep the latest state in video,
|
|
125
|
+
// while retaining every observation as its own PNG and provenance entry.
|
|
126
|
+
if (index === frames.length - 1 || times[index] !== times[index + 1])
|
|
127
|
+
videoIndices.push(index);
|
|
128
|
+
}
|
|
129
|
+
if (!sourceBytes.equals(await (0, promises_1.readFile)(sourcePath)))
|
|
130
|
+
throw new Error('Recording changed while it was being read; render the saved file again.');
|
|
131
|
+
const sourceHash = (0, node_crypto_1.createHash)('sha256').update(sourceBytes).digest('hex');
|
|
132
|
+
const fontPath = await font();
|
|
133
|
+
await (0, promises_1.mkdir)((0, node_path_1.dirname)(mp4Path), { recursive: true });
|
|
134
|
+
if (gifPath)
|
|
135
|
+
await (0, promises_1.mkdir)((0, node_path_1.dirname)(gifPath), { recursive: true });
|
|
136
|
+
const framesDir = await (0, promises_1.mkdtemp)((0, node_path_1.join)((0, node_path_1.dirname)(mp4Path), `${(0, node_path_1.basename)(mp4Path)}.frames-`));
|
|
137
|
+
const concatPath = (0, node_path_1.join)(framesDir, 'frames.ffconcat');
|
|
138
|
+
const lines = ['ffconcat version 1.0'];
|
|
139
|
+
for (let index = 0; index < frames.length; index++) {
|
|
140
|
+
const frame = frames[index];
|
|
141
|
+
const name = `frame-${String(index).padStart(6, '0')}.png`;
|
|
142
|
+
const image = (0, frames_1.renderFrame)(frame.data, { recorded: true, at: frame.at, width: frame.viewport?.width ?? 104, height: frame.viewport?.height ?? 35, colour: true });
|
|
143
|
+
await (0, promises_1.writeFile)((0, node_path_1.join)(framesDir, name), await png(image.toJSON()), { flag: 'wx', mode: 0o600 });
|
|
144
|
+
}
|
|
145
|
+
for (const [position, index] of videoIndices.entries()) {
|
|
146
|
+
const next = videoIndices[position + 1];
|
|
147
|
+
const seconds = next === undefined ? 1 : Number(times[next] - times[index]) / 1_000_000;
|
|
148
|
+
lines.push(`file 'frame-${String(index).padStart(6, '0')}.png'`, 'option framerate 1000000', `duration ${seconds.toFixed(6)}`);
|
|
149
|
+
}
|
|
150
|
+
// The concat demuxer needs a terminal file to honor the final frame hold.
|
|
151
|
+
lines.push(`file 'frame-${String(frames.length - 1).padStart(6, '0')}.png'`, 'option framerate 1000000');
|
|
152
|
+
await (0, promises_1.writeFile)(concatPath, `${lines.join('\n')}\n`, { flag: 'wx', mode: 0o600 });
|
|
153
|
+
const ffmpeg = options.ffmpeg === null ? null : await executable(options.ffmpeg || 'ffmpeg');
|
|
154
|
+
const common = ['-hide_banner', '-loglevel', 'error', '-nostdin', '-n', '-protocol_whitelist', 'file,pipe', '-f', 'concat', '-safe', '0', '-i', concatPath];
|
|
155
|
+
// B-frame reordering can collapse the MP4 track duration to rapid DTS steps even
|
|
156
|
+
// when the final presentation timestamp correctly includes the one-second hold.
|
|
157
|
+
const mp4Args = [...common, '-fps_mode', 'vfr', '-c:v', 'libx264', '-bf', '0', '-enc_time_base', '1:1000000', '-video_track_timescale', '1000000', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', mp4Path];
|
|
158
|
+
const gifArgs = [...common, '-fps_mode', 'vfr', '-vf', 'split[a][b];[a]palettegen[p];[b][p]paletteuse', '-loop', '0', ...(gifPath ? [gifPath] : [])];
|
|
159
|
+
const command = [ffmpeg || 'ffmpeg', ...mp4Args].map(shellWord).join(' ')
|
|
160
|
+
+ (gifPath ? `\n${[ffmpeg || 'ffmpeg', ...gifArgs].map(shellWord).join(' ')}` : '');
|
|
161
|
+
const provenance = {
|
|
162
|
+
source_sha256: sourceHash,
|
|
163
|
+
renderer_version: require('../../package.json').version,
|
|
164
|
+
rendered_at: new Date().toISOString(),
|
|
165
|
+
font: { family: FONT_ALIAS, path: fontPath },
|
|
166
|
+
frame_count: frames.length,
|
|
167
|
+
frame_times: frames.map(frame => frame.at),
|
|
168
|
+
frame_viewports: frames.map(frame => frame.viewport ?? { width: 104, height: 35 }),
|
|
169
|
+
video_frame_indices: videoIndices,
|
|
170
|
+
video_timestamp_precision: 'microseconds; equal timestamps retain the last frame in video and every PNG',
|
|
171
|
+
final_hold_seconds: 1,
|
|
172
|
+
ffmpeg_command: command,
|
|
173
|
+
format: 'Recorded viewport (legacy 104x35), centered 18x28 pixel cells, 1920x1080 PNG',
|
|
174
|
+
video_status: ffmpeg ? 'rendering' : 'ffmpeg_unavailable_png_frames_written',
|
|
175
|
+
};
|
|
176
|
+
await (0, promises_1.writeFile)(provenancePath, `${JSON.stringify(provenance, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
177
|
+
if (ffmpeg) {
|
|
178
|
+
try {
|
|
179
|
+
await run(ffmpeg, mp4Args, { timeout: 300_000, maxBuffer: 1024 * 1024 });
|
|
180
|
+
if (gifPath)
|
|
181
|
+
await run(ffmpeg, gifArgs, { timeout: 300_000, maxBuffer: 1024 * 1024 });
|
|
182
|
+
provenance.video_status = 'complete';
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
provenance.video_status = 'failed_png_frames_written';
|
|
186
|
+
throw new Error(`Video encoding failed; PNG frames remain at ${framesDir}: ${error.message}`);
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
await (0, promises_1.writeFile)(provenancePath, `${JSON.stringify(provenance, null, 2)}\n`, { mode: 0o600 });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return { framesDir, provenancePath, mp4: ffmpeg ? mp4Path : null, gif: ffmpeg && gifPath ? gifPath : null, ffmpegCommand: command, frameCount: frames.length };
|
|
193
|
+
}
|
|
@@ -10,6 +10,7 @@ exports.comparison = comparison;
|
|
|
10
10
|
exports.renderReplay = renderReplay;
|
|
11
11
|
exports.renderStop = renderStop;
|
|
12
12
|
exports.renderSessionRow = renderSessionRow;
|
|
13
|
+
const frames_1 = require("../frames");
|
|
13
14
|
const evaluate_1 = require("../detectors/evaluate");
|
|
14
15
|
const C = {
|
|
15
16
|
reset: '\x1b[0m',
|
|
@@ -117,6 +118,8 @@ function scenarioUsd(tokens, cacheShare) {
|
|
|
117
118
|
}
|
|
118
119
|
function renderReplay(summary, opts = {}) {
|
|
119
120
|
const on = opts.colour ?? Boolean(process.stdout.isTTY);
|
|
121
|
+
if (on)
|
|
122
|
+
return (0, frames_1.renderFrame)({ kind: 'replay', summary }, { colour: true }).render();
|
|
120
123
|
const top = opts.top ?? 6;
|
|
121
124
|
const out = [];
|
|
122
125
|
const cacheShare = summary.totalTokens
|
|
@@ -175,6 +178,8 @@ function renderReplay(summary, opts = {}) {
|
|
|
175
178
|
// ---------------------------------------------------------------------------
|
|
176
179
|
function renderStop(report, opts = {}) {
|
|
177
180
|
const on = opts.colour ?? false;
|
|
181
|
+
if (on)
|
|
182
|
+
return (0, frames_1.renderFrame)({ kind: 'report', report, subject: opts.subject, outcome: 'blocked' }, { colour: true }).render();
|
|
178
183
|
const lead = report.findings.find((f) => f.verdict === 'STOP')?.summary ?? 'Fan-out ceiling reached.';
|
|
179
184
|
const w = 64;
|
|
180
185
|
const bar = '─'.repeat(w);
|
package/dist/src/types.d.ts
CHANGED
|
@@ -80,6 +80,10 @@ export interface BurnReport {
|
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
82
|
export interface Thresholds {
|
|
83
|
+
/** Additive local idle-resource warnings; no automatic process termination. */
|
|
84
|
+
idle_session_warn_hours?: number;
|
|
85
|
+
idle_browser_warn_minutes?: number;
|
|
86
|
+
orphan_workspace_warn_days?: number;
|
|
83
87
|
fanout: {
|
|
84
88
|
warn: number;
|
|
85
89
|
stop: number;
|
|
@@ -131,6 +135,8 @@ export interface Thresholds {
|
|
|
131
135
|
}
|
|
132
136
|
export type Mode = 'shadow' | 'enforce';
|
|
133
137
|
export interface Policy {
|
|
138
|
+
/** Shared threshold file; relative paths resolve from the Burn home. */
|
|
139
|
+
teamPolicyFile?: string;
|
|
134
140
|
mode: Mode;
|
|
135
141
|
thresholds: Thresholds;
|
|
136
142
|
/** Additive local usage advisories. Older policy files use these defaults. */
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Local idle audit with Burn
|
|
2
|
+
|
|
3
|
+
Status: docs page draft for Burn 0.3.0. No service, account or upload is involved.
|
|
4
|
+
|
|
5
|
+
Agent sessions and automation can keep holding machine resources after the developer stops using them. Burn reports this cost exposure locally and offers a deliberately limited interactive close command.
|
|
6
|
+
|
|
7
|
+
## See what is still alive
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
agentguard-burn ps
|
|
11
|
+
agentguard-burn ps --json
|
|
12
|
+
agentguard-burn ps --record ./idle-run.jsonl
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The table ranks processes by resident memory, followed by candidate workspaces ranked by disk size. It covers Claude Code and Codex processes with their Node children, plugin daemons associated with temporary or abandoned paths, and automation Chrome, Chromium or Chrome for Testing processes. Normal browser profiles are excluded. Automation markers include remote-debugging arguments, enable-automation, temporary user-data directories and Playwright or Puppeteer profiles. Browser subprocess memory is grouped under its automation browser; available native window counts are shown separately.
|
|
16
|
+
|
|
17
|
+
Each agent row includes PID, host, working directory, uptime, idle time, idle method and resident memory. Idle uses the controlling terminal's access timestamp when readable. Otherwise Burn uses recent transcript file-write metadata, with the scope and limitations stated in the output. A fallback is evidence for a warning, not proof that the developer stopped typing.
|
|
18
|
+
|
|
19
|
+
Candidate workspaces are searched under the temporary and Claude scratchpad roots. A git worktree, .git entry or package.json identifies a candidate. Size, modification time, dirty state and open handles are metadata. Orphan status requires evidence that the workspace has no registered worktree ownership or live process use. Permission failures, scan limits, missing tools and unknown idle evidence are reported rather than converted into zero activity. The scan does not execute package scripts or inspect source content. The full command bounds collection at thirty seconds, with explicit process, directory-entry, discovery-depth and workspace-count limits. Discovered workspaces still appear with unknown evidence when collection runs out of budget. Incomplete totals are lower bounds. A directory timestamp shown after an incomplete scan is not used to trigger an idle warning.
|
|
20
|
+
|
|
21
|
+
Interactive output uses the Burn fixed-grid canvas. At smaller terminal sizes side detail is dropped and rows paginate rather than wrap. The plain table remains available with --no-color; --json retains all collected rows and skipped capabilities. See [recording and rendering](burn-render.md) for local PNG and video exports.
|
|
22
|
+
|
|
23
|
+
Totals show held memory for idle agent sessions and disk occupied by orphan workspaces. RSS can count shared pages more than once and is not a bill. Current swap use is reported separately. Swap is only released by the operating system on reboot. Burn does not touch it.
|
|
24
|
+
|
|
25
|
+
## Close only what you select
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
agentguard-burn reap
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Reap prints the numbered audit and asks for explicit row numbers. Both input and output must be interactive terminals. Blank input cancels. There is no --yes option, no piped confirmation and no automatic cleanup. Only the typed process selections can receive SIGTERM. Burn waits and reports; a surviving process remains alive. SIGKILL is never sent.
|
|
32
|
+
|
|
33
|
+
All of these refusals apply:
|
|
34
|
+
|
|
35
|
+
1. A session with terminal input within the last 24 hours is refused. Missing or unreadable terminal-idle evidence is also refused; transcript inactivity does not override it.
|
|
36
|
+
2. A process with modified or untracked files in its working directory's repository is refused. Unknown working-directory safety is refused, including unreadable or missing directories. A verified non-repository directory has no git changes to protect.
|
|
37
|
+
3. Workspaces are always information only. A workspace with open handles is explicitly marked, and no workspace can be deleted or closed by this command.
|
|
38
|
+
4. Reap itself and every ancestor are protected. A group containing one of those processes is refused.
|
|
39
|
+
5. The confirmed process identity, ancestry, classification and working-directory safety are checked again before signaling. Reused PIDs, changed ownership and newly discovered group members are not silently included.
|
|
40
|
+
|
|
41
|
+
Permission failures are reported. Warning thresholds do not relax the fixed 24-hour session protection. Reap never deletes anything on disk.
|
|
42
|
+
|
|
43
|
+
## Warning policy
|
|
44
|
+
|
|
45
|
+
Add these keys to thresholds in the existing Burn policy file:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"thresholds": {
|
|
50
|
+
"idle_session_warn_hours": 24,
|
|
51
|
+
"idle_browser_warn_minutes": 60,
|
|
52
|
+
"orphan_workspace_warn_days": 2
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The policy is normally ~/.agentguard/burn-policy.json. AGENTGUARD_HOME changes that root. Missing or invalid idle settings use these defaults. The optional local teamPolicyFile points to another JSON file containing thresholds, resolved relative to the Burn home unless absolute. Known team threshold fields override local fields; shared policy loading does not change enforcement mode. A missing or invalid team file produces a notice and uses the local settings. Policy files are not rewritten by an audit.
|
|
58
|
+
|
|
59
|
+
Claude Code and Codex SessionStart hooks use a five-minute private metadata cache. A cache miss gets a bounded 150 ms scan attempt. A timeout skips the warning rather than slowing session start or blocking a tool. The hook prints at most one WARN line:
|
|
60
|
+
|
|
61
|
+
```text
|
|
62
|
+
N idle agent sessions holding X GB, run agentguard-burn ps
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
If only browsers or workspaces exceed a threshold, N can be zero and ps shows those rows. The ordinary ps command refreshes the cache. Existing installations need the updated hook snippet from agentguard-burn init claude or agentguard-burn init codex, reviewed and merged into the host configuration. The init command only writes configuration when explicitly asked to do so.
|
|
66
|
+
|
|
67
|
+
## Platform and privacy boundary
|
|
68
|
+
|
|
69
|
+
macOS collection uses ps, lsof, terminal device timestamps and sysctl. Browser window counts use available local window metadata and may be unavailable. Linux reads /proc and uses local git and filesystem metadata, with explicit notices when a capability is unavailable. Windows prints "not supported on this platform yet" and exits 0.
|
|
70
|
+
|
|
71
|
+
No sockets, remote browser debugging connections, telemetry, cloud API or cleanup service are used. Process arguments are inspected locally for classification but are not printed or stored in the audit cache. Transcript contents, tool input, tool output and source files are not uploaded. Directory names, local PIDs and resource totals are visible in the local report and JSON export, so review that metadata before sharing it yourself.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Burn terminal recordings
|
|
2
|
+
|
|
3
|
+
Status: docs page draft for Burn 0.3.0.
|
|
4
|
+
|
|
5
|
+
Burn's interactive screens share a fixed-grid instrument panel. The default is 104 columns by 35 rows. Smaller terminals hide side detail and paginate without wrapping. Mint means good, slate is neutral, amber means risk and red is reserved for STOP. Truecolor terminals receive the palette's RGB values; other color terminals use 256-color codes. --no-color keeps existing plain command output and hook cards. Machine-readable JSON, hook replies and statusline output remain plain protocols.
|
|
6
|
+
|
|
7
|
+
## Record observations
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
agentguard-burn ps --record ./idle-run.jsonl
|
|
11
|
+
agentguard-burn status --record ./status-run.jsonl
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
--record works before or after the command. It appends one JSON object per frame. There is no implicit recording or upload. A line has this versioned envelope:
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{"version":1,"at":"2026-09-20T10:00:00.000Z","data":{"kind":"command","command":"status","values":{"mode":"shadow"}}}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Frame kinds are audit, report, replay and command. Audit frames contain resource metadata without raw process arguments or unrelated processes. Reports contain detector measurements; replay frames contain the replay summary; command frames contain named observations. Frame data is not rendered text, ANSI or a terminal capture. A page index and viewport identify each visible page when a screen needs multiple grids. No-color and JSON commands record every data page at the standard 104 by 35 size while keeping their stdout unchanged. STOP frames distinguish blocked, shadow and overridden outcomes; older frames without an outcome say that it was not recorded. Hooks record detector reports when they display one and otherwise a command outcome without copying hook input. Recording failure never changes a hook's decision.
|
|
21
|
+
|
|
22
|
+
Recordings may include local paths, process identifiers, policy thresholds and resource totals. Keep them private unless you choose to share that metadata. Tool input, tool output, prompts and transcript contents are not recorded. Burn adds no network request.
|
|
23
|
+
|
|
24
|
+
## Render locally
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
agentguard-burn render ./idle-run.jsonl --mp4 ./idle-run.mp4
|
|
28
|
+
agentguard-burn render ./idle-run.jsonl --mp4 ./idle-with-gif.mp4 --gif ./idle-run.gif
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The renderer verifies ordered timestamps and draws each frame with the same Canvas at 1920 by 1080. It uses an installed Menlo font on macOS or DejaVu Sans Mono on Linux. Windows prints "not supported on this platform yet" and exits zero before loading the renderer. Missing fonts on supported platforms produce an explicit error; none are downloaded. The title reads RECORDED RUN · 1x and the footer reads recorded run. Frame intervals follow the original wall-clock timestamps, with a one-second hold after the last frame. Equal timestamps have zero duration; video keeps the last state at that instant while retaining every PNG and recording its index in provenance. Timestamp precision in encoded video is limited by the encoder time base; GIF timing is limited by that format.
|
|
32
|
+
|
|
33
|
+
The PNG renderer is bundled as a native Node dependency and loads only for render. ffmpeg is a separately installed local encoder. If ffmpeg is unavailable, Burn writes numbered PNG frames, an ffconcat timing file and a shell-quoted encoding command. Existing media or provenance outputs are refused. PNGs stay beside the requested output in a unique directory so a failed encoder never loses the rendered evidence.
|
|
34
|
+
|
|
35
|
+
Provenance is written to the MP4 path plus .provenance.json. It includes source_sha256, renderer_version, rendered_at, the local font, frame timestamps, final hold duration and encoding status. The digest identifies the original JSONL bytes, not a claim that the observations are signed or independently attested. The source file is never changed. The render command reads only local paths and restricts ffmpeg input protocols to local files and pipes.
|
|
36
|
+
|
|
37
|
+
Rendering rejects recordings above 128 MB or 10,000 frames. Unknown schema versions, malformed lines and backward timestamps are errors. This is a local replay renderer, not a screen recorder or billing meter. ps totals describe held memory and cost exposure.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentguard-run/burn",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Local session usage and runaway-agent circuit breaker. Explain tokens, cache rewrites and API list cost, track pace, warn on heavy turns, and gate agent fan-out with signed receipts. Nothing leaves the machine.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -50,7 +50,9 @@
|
|
|
50
50
|
"conformance": "tsc -p tsconfig.json && node dist/src/cli.js conformance",
|
|
51
51
|
"replay": "node dist/src/cli.js replay"
|
|
52
52
|
},
|
|
53
|
-
"dependencies": {
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@napi-rs/canvas": "^1.0.9"
|
|
55
|
+
},
|
|
54
56
|
"devDependencies": {
|
|
55
57
|
"@types/node": "^22",
|
|
56
58
|
"typescript": "^5.0.0"
|