@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,154 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runLocal = void 0;
|
|
4
|
+
exports.elapsedSeconds = elapsedSeconds;
|
|
5
|
+
exports.parsePs = parsePs;
|
|
6
|
+
exports.parseLsofCwds = parseLsofCwds;
|
|
7
|
+
exports.parseWorktrees = parseWorktrees;
|
|
8
|
+
exports.parseSwap = parseSwap;
|
|
9
|
+
exports.parseProcStat = parseProcStat;
|
|
10
|
+
exports.procProcesses = procProcesses;
|
|
11
|
+
exports.browserWindows = browserWindows;
|
|
12
|
+
const node_child_process_1 = require("node:child_process");
|
|
13
|
+
const node_fs_1 = require("node:fs");
|
|
14
|
+
const node_path_1 = require("node:path");
|
|
15
|
+
const runLocal = (command, args, options = {}) => new Promise(resolve => {
|
|
16
|
+
if (options.signal?.aborted) {
|
|
17
|
+
resolve({ stdout: '', stderr: 'aborted', code: -1 });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
(0, node_child_process_1.execFile)(command, args, { cwd: options.cwd, timeout: options.timeoutMs ?? 1500,
|
|
21
|
+
signal: options.signal, killSignal: 'SIGTERM', maxBuffer: options.maxBuffer ?? 4 * 1024 * 1024,
|
|
22
|
+
encoding: 'utf8', env: { ...process.env, LC_ALL: 'C', GIT_OPTIONAL_LOCKS: '0' } }, (error, stdout, stderr) => {
|
|
23
|
+
resolve({ stdout, stderr: stderr || (error && typeof error.code !== 'number' ? error.message : ''), code: error ? typeof error.code === 'number' ? error.code : -1 : 0 });
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
exports.runLocal = runLocal;
|
|
27
|
+
function elapsedSeconds(text) {
|
|
28
|
+
const match = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(text);
|
|
29
|
+
if (!match)
|
|
30
|
+
return /^\d+$/.test(text) ? Number(text) : null;
|
|
31
|
+
return Number(match[1] || 0) * 86400 + Number(match[2] || 0) * 3600 + Number(match[3]) * 60 + Number(match[4]);
|
|
32
|
+
}
|
|
33
|
+
/** lstart is retained verbatim as an OS identity, never reconstructed from elapsed time. */
|
|
34
|
+
function parsePs(text) {
|
|
35
|
+
const rows = [], seen = new Set();
|
|
36
|
+
for (const line of text.split('\n')) {
|
|
37
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+([A-Za-z]{3}\s+[A-Za-z]{3}\s+\d{1,2}\s+\d\d:\d\d:\d\d\s+\d{4})\s+(\S+)\s+(.+?)\s*$/.exec(line);
|
|
38
|
+
if (!match)
|
|
39
|
+
continue;
|
|
40
|
+
const pid = Number(match[1]);
|
|
41
|
+
if (seen.has(pid))
|
|
42
|
+
continue;
|
|
43
|
+
seen.add(pid);
|
|
44
|
+
rows.push({ pid, ppid: Number(match[2]), rssBytes: Number(match[3]) * 1024, tty: ['?', '??', '-'].includes(match[4]) ? null : match[4],
|
|
45
|
+
startedAt: 'ps:' + match[5].replace(/\s+/g, ' '), uptimeSeconds: elapsedSeconds(match[6]), command: match[7], cwd: null, terminalIdleSeconds: null });
|
|
46
|
+
}
|
|
47
|
+
return rows;
|
|
48
|
+
}
|
|
49
|
+
function parseLsofCwds(text) {
|
|
50
|
+
const result = new Map();
|
|
51
|
+
let pid = null;
|
|
52
|
+
for (const line of text.split('\n')) {
|
|
53
|
+
if (/^p\d+$/.test(line))
|
|
54
|
+
pid = Number(line.slice(1));
|
|
55
|
+
else if (pid !== null && line.startsWith('n/'))
|
|
56
|
+
result.set(pid, line.slice(1));
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
function parseWorktrees(text) {
|
|
61
|
+
return text.split('\n').filter(line => line.startsWith('worktree ')).map(line => line.slice(9));
|
|
62
|
+
}
|
|
63
|
+
function parseSwap(text, platform) {
|
|
64
|
+
if (platform === 'darwin') {
|
|
65
|
+
const match = /used\s*=\s*([\d.]+)([KMG])\b/.exec(text);
|
|
66
|
+
return match ? Math.round(Number(match[1]) * ({ K: 1024, M: 1024 ** 2, G: 1024 ** 3 }[match[2]])) : null;
|
|
67
|
+
}
|
|
68
|
+
const total = /^SwapTotal:\s+(\d+)\s+kB$/m.exec(text), free = /^SwapFree:\s+(\d+)\s+kB$/m.exec(text);
|
|
69
|
+
return total && free ? Math.max(0, Number(total[1]) - Number(free[1])) * 1024 : null;
|
|
70
|
+
}
|
|
71
|
+
function parseProcStat(text, command, bootId, ticks, now, uptime) {
|
|
72
|
+
const match = /^(\d+) \((.*)\) (.*)$/.exec(text.trim());
|
|
73
|
+
if (!match)
|
|
74
|
+
return null;
|
|
75
|
+
const fields = match[3].split(/\s+/), start = Number(fields[19]), rssPages = Number(fields[21]);
|
|
76
|
+
if (!Number.isSafeInteger(start) || !Number.isFinite(rssPages) || !ticks)
|
|
77
|
+
return null;
|
|
78
|
+
return { pid: Number(match[1]), ppid: Number(fields[1]), command: command || match[2],
|
|
79
|
+
startedAt: `proc:${bootId}:${start}`, uptimeSeconds: Math.max(0, uptime - start / ticks), rssBytes: rssPages * 4096,
|
|
80
|
+
cwd: null, tty: null, terminalIdleSeconds: null };
|
|
81
|
+
}
|
|
82
|
+
/** Linux fallback reads proc metadata only. cmdline is process identity, never transcript content. */
|
|
83
|
+
async function procProcesses(root, runner, signal, limit, skipped) {
|
|
84
|
+
let names, bootId, uptime;
|
|
85
|
+
try {
|
|
86
|
+
names = (await node_fs_1.promises.readdir(root)).filter(name => /^\d+$/.test(name)).sort((a, b) => Number(a) - Number(b));
|
|
87
|
+
bootId = (await node_fs_1.promises.readFile((0, node_path_1.join)(root, 'sys/kernel/random/boot_id'), 'utf8')).trim();
|
|
88
|
+
uptime = Number((await node_fs_1.promises.readFile((0, node_path_1.join)(root, 'uptime'), 'utf8')).split(' ')[0]);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
skipped.push('Linux process metadata is unavailable.');
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
if (names.length > limit) {
|
|
95
|
+
skipped.push('Linux process scan exceeds its limit; process ownership is incomplete.');
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
const clock = await runner('getconf', ['CLK_TCK'], { signal, timeoutMs: 500 });
|
|
99
|
+
const pages = await runner('getconf', ['PAGESIZE'], { signal, timeoutMs: 500 });
|
|
100
|
+
const ticks = clock.code === 0 ? Number(clock.stdout.trim()) : NaN, pageSize = pages.code === 0 ? Number(pages.stdout.trim()) : NaN;
|
|
101
|
+
if (!Number.isFinite(ticks) || !Number.isFinite(pageSize)) {
|
|
102
|
+
skipped.push('Linux clock tick or page size is unavailable; stable process snapshots were skipped.');
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
const rows = [];
|
|
106
|
+
for (const name of names) {
|
|
107
|
+
if (signal?.aborted)
|
|
108
|
+
break;
|
|
109
|
+
try {
|
|
110
|
+
const [stat, cmdline] = await Promise.all([node_fs_1.promises.readFile((0, node_path_1.join)(root, name, 'stat'), 'utf8'), node_fs_1.promises.readFile((0, node_path_1.join)(root, name, 'cmdline'), 'utf8')]);
|
|
111
|
+
const row = parseProcStat(stat, cmdline.replace(/\0/g, ' ').trim(), bootId, ticks, Date.now(), uptime);
|
|
112
|
+
if (!row)
|
|
113
|
+
continue;
|
|
114
|
+
row.rssBytes = row.rssBytes / 4096 * pageSize;
|
|
115
|
+
try {
|
|
116
|
+
row.cwd = await node_fs_1.promises.readlink((0, node_path_1.join)(root, name, 'cwd'));
|
|
117
|
+
}
|
|
118
|
+
catch { /* Exited or inaccessible. */ }
|
|
119
|
+
try {
|
|
120
|
+
const tty = await node_fs_1.promises.readlink((0, node_path_1.join)(root, name, 'fd/0'));
|
|
121
|
+
if (/^\/dev\/(?:pts\/\d+|tty\w*)$/.test(tty))
|
|
122
|
+
row.tty = tty.slice(5);
|
|
123
|
+
}
|
|
124
|
+
catch { /* No accessible terminal. */ }
|
|
125
|
+
rows.push(row);
|
|
126
|
+
}
|
|
127
|
+
catch { /* Process exited between enumeration and stat. */ }
|
|
128
|
+
}
|
|
129
|
+
return rows;
|
|
130
|
+
}
|
|
131
|
+
/** CoreGraphics reads local window metadata without Apple Events or accessibility control. */
|
|
132
|
+
async function browserWindows(pids, runner, options) {
|
|
133
|
+
if (!pids.length)
|
|
134
|
+
return new Map();
|
|
135
|
+
const safePids = pids.filter(pid => Number.isSafeInteger(pid) && pid > 0);
|
|
136
|
+
// The default JXA bridge treats CFArrayRef as an opaque Ref. An explicit
|
|
137
|
+
// toll-free NSObject return type makes the local array readable without AX.
|
|
138
|
+
const source = `ObjC.import('CoreGraphics'); ObjC.bindFunction('CGWindowListCopyWindowInfo',['id',['unsigned int','unsigned int']]); var ids=${JSON.stringify(safePids)}; var counts={}; ids.forEach(function(id){counts[id]=0;}); var windows=ObjC.deepUnwrap($.CGWindowListCopyWindowInfo(0,0)); windows.forEach(function(w){var pid=Number(w.kCGWindowOwnerPID); if(Number(w.kCGWindowLayer)===0 && ids.indexOf(pid)!==-1) counts[pid]++;}); JSON.stringify(counts);`;
|
|
139
|
+
const result = await runner('osascript', ['-l', 'JavaScript', '-e', source], options);
|
|
140
|
+
if (result.code !== 0)
|
|
141
|
+
return null;
|
|
142
|
+
try {
|
|
143
|
+
const parsed = JSON.parse(result.stdout), values = new Map();
|
|
144
|
+
for (const pid of safePids) {
|
|
145
|
+
if (!Number.isSafeInteger(parsed[pid]) || parsed[pid] < 0)
|
|
146
|
+
return null;
|
|
147
|
+
values.set(pid, parsed[pid]);
|
|
148
|
+
}
|
|
149
|
+
return values;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { AuditReport } from './types';
|
|
2
|
+
type Awaitable<T> = T | Promise<T>;
|
|
3
|
+
export interface ReapDependencies {
|
|
4
|
+
inputIsTTY: boolean;
|
|
5
|
+
outputIsTTY: boolean;
|
|
6
|
+
readAudit(): Awaitable<AuditReport>;
|
|
7
|
+
/** The CLI supplies OS operations. Tests supply fakes, never real signals. */
|
|
8
|
+
signal(pid: number, signal: 'SIGTERM'): Awaitable<void>;
|
|
9
|
+
alive(pid: number): Awaitable<boolean>;
|
|
10
|
+
gitDirty?(cwd: string): Awaitable<boolean | null>;
|
|
11
|
+
wait?(milliseconds: number): Promise<void>;
|
|
12
|
+
currentPid?: number;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
}
|
|
15
|
+
export interface InteractiveReapDependencies extends ReapDependencies {
|
|
16
|
+
question(prompt: string): Awaitable<string>;
|
|
17
|
+
}
|
|
18
|
+
export interface ReapPidResult {
|
|
19
|
+
pid: number;
|
|
20
|
+
status: 'exited' | 'still_running' | 'signal_failed' | 'refused';
|
|
21
|
+
reason: string;
|
|
22
|
+
}
|
|
23
|
+
export interface ReapRowResult {
|
|
24
|
+
selection: number;
|
|
25
|
+
id: string;
|
|
26
|
+
label: string;
|
|
27
|
+
status: 'refused' | 'terminated' | 'still_running' | 'signal_failed';
|
|
28
|
+
reason: string;
|
|
29
|
+
pids: ReapPidResult[];
|
|
30
|
+
}
|
|
31
|
+
export interface ReapResult {
|
|
32
|
+
cancelled: boolean;
|
|
33
|
+
results: ReapRowResult[];
|
|
34
|
+
}
|
|
35
|
+
/** Accept numbers actually typed at the prompt, never flags or an implied all. */
|
|
36
|
+
export declare function parseReapSelection(value: string, rowCount: number): number[];
|
|
37
|
+
/** The displayed snapshot is immutable authorization: recollection can only narrow it. */
|
|
38
|
+
export declare function reapSelected(snapshot: AuditReport, selectionText: string, dependencies: ReapDependencies): Promise<ReapResult>;
|
|
39
|
+
/** The CLI prints the entire audit before calling this prompt. */
|
|
40
|
+
export declare function interactiveReap(snapshot: AuditReport, dependencies: InteractiveReapDependencies): Promise<ReapResult>;
|
|
41
|
+
export {};
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseReapSelection = parseReapSelection;
|
|
4
|
+
exports.reapSelected = reapSelected;
|
|
5
|
+
exports.interactiveReap = interactiveReap;
|
|
6
|
+
const DAY_SECONDS = 24 * 60 * 60;
|
|
7
|
+
function interactive(dependencies) {
|
|
8
|
+
if (dependencies.inputIsTTY !== true || dependencies.outputIsTTY !== true) {
|
|
9
|
+
throw new Error('Reap requires an interactive terminal for both input and output. Nothing was closed.');
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** Accept numbers actually typed at the prompt, never flags or an implied all. */
|
|
13
|
+
function parseReapSelection(value, rowCount) {
|
|
14
|
+
if (typeof value !== 'string')
|
|
15
|
+
throw new Error('Type displayed item numbers to select processes.');
|
|
16
|
+
const input = value.trim();
|
|
17
|
+
if (!input)
|
|
18
|
+
return [];
|
|
19
|
+
if (!/^[1-9]\d*(?:[\s,]+[1-9]\d*)*$/.test(input)) {
|
|
20
|
+
throw new Error('Type displayed item numbers separated by spaces or commas. Flags, ranges and yes are not accepted.');
|
|
21
|
+
}
|
|
22
|
+
const numbers = input.split(/[\s,]+/).map(Number);
|
|
23
|
+
if (numbers.some(number => !Number.isSafeInteger(number) || number > rowCount)) {
|
|
24
|
+
throw new Error('Every selected number must name an item in the displayed audit. Nothing was closed.');
|
|
25
|
+
}
|
|
26
|
+
return [...new Set(numbers)];
|
|
27
|
+
}
|
|
28
|
+
function byPid(report) {
|
|
29
|
+
const result = new Map();
|
|
30
|
+
for (const item of report.processes) {
|
|
31
|
+
if (!Number.isSafeInteger(item.pid) || item.pid < 1 || result.has(item.pid))
|
|
32
|
+
return null;
|
|
33
|
+
result.set(item.pid, item);
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
function protectedPids(report, currentPid) {
|
|
38
|
+
const map = byPid(report);
|
|
39
|
+
if (!map || !Number.isSafeInteger(currentPid) || currentPid < 1)
|
|
40
|
+
return null;
|
|
41
|
+
const protectedSet = new Set([1]);
|
|
42
|
+
let pid = currentPid;
|
|
43
|
+
while (pid > 1) {
|
|
44
|
+
if (protectedSet.has(pid))
|
|
45
|
+
return null;
|
|
46
|
+
protectedSet.add(pid);
|
|
47
|
+
const item = map.get(pid);
|
|
48
|
+
if (!item || !Number.isSafeInteger(item.ppid) || item.ppid < 0)
|
|
49
|
+
return null;
|
|
50
|
+
pid = item.ppid;
|
|
51
|
+
}
|
|
52
|
+
return protectedSet;
|
|
53
|
+
}
|
|
54
|
+
function sameProcess(before, after) {
|
|
55
|
+
return typeof before.startedAt === 'string' && before.startedAt.length > 0 && before.startedAt === after.startedAt
|
|
56
|
+
&& before.pid === after.pid && before.ppid === after.ppid && before.command.length > 0 && before.command === after.command
|
|
57
|
+
&& before.cwd === after.cwd && before.tty === after.tty;
|
|
58
|
+
}
|
|
59
|
+
function sameOwnership(before, after) {
|
|
60
|
+
return before.id === after.id && before.kind === after.kind && before.host === after.host
|
|
61
|
+
&& before.pid === after.pid && before.label === after.label && before.cwd === after.cwd;
|
|
62
|
+
}
|
|
63
|
+
function descendants(pid, processes) {
|
|
64
|
+
const rows = [...processes], result = new Set();
|
|
65
|
+
let changed = true;
|
|
66
|
+
while (changed) {
|
|
67
|
+
changed = false;
|
|
68
|
+
for (const item of rows)
|
|
69
|
+
if ((item.ppid === pid || result.has(item.ppid)) && !result.has(item.pid) && item.pid !== pid) {
|
|
70
|
+
result.add(item.pid);
|
|
71
|
+
changed = true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
function listMatches(values, expected) {
|
|
77
|
+
return values.length === new Set(values).size && values.length === expected.size && values.every(pid => expected.has(pid));
|
|
78
|
+
}
|
|
79
|
+
async function refusal(snapshot, row, fresh, dependencies, alreadyExited = new Set(), checkGit = true) {
|
|
80
|
+
if (row.kind === 'workspace')
|
|
81
|
+
return row.openHandles === true
|
|
82
|
+
? 'Workspace has open file handles; information only. Workspaces are never deleted or closed.'
|
|
83
|
+
: 'Workspaces are informational only and are never deleted or closed.';
|
|
84
|
+
if (!['session', 'browser', 'daemon'].includes(row.kind))
|
|
85
|
+
return 'This item has no supported process ownership classification.';
|
|
86
|
+
if (row.pid === null || !row.pids.includes(row.pid) || row.pids.length === 0)
|
|
87
|
+
return 'The audit has no complete process group for this item.';
|
|
88
|
+
const currentPid = dependencies.currentPid ?? process.pid;
|
|
89
|
+
const originalProtected = protectedPids(snapshot, currentPid), currentProtected = protectedPids(fresh, currentPid);
|
|
90
|
+
if (!originalProtected || !currentProtected)
|
|
91
|
+
return 'The current process ancestry could not be verified.';
|
|
92
|
+
if (row.pids.some(pid => originalProtected.has(pid) || currentProtected.has(pid)))
|
|
93
|
+
return 'The selection includes this reap process or one of its ancestors.';
|
|
94
|
+
const initial = byPid(snapshot), current = byPid(fresh);
|
|
95
|
+
if (!initial || !current)
|
|
96
|
+
return 'The process inventory is ambiguous or incomplete.';
|
|
97
|
+
const matches = fresh.rows.filter(item => item.id === row.id);
|
|
98
|
+
if (matches.length !== 1 || !sameOwnership(row, matches[0]))
|
|
99
|
+
return 'The item disappeared or its ownership classification changed after confirmation.';
|
|
100
|
+
const latest = matches[0];
|
|
101
|
+
if (latest.dirty === true)
|
|
102
|
+
return 'The selected process group has a working directory with dirty or untracked files.';
|
|
103
|
+
if (!checkGit && latest.dirty !== false)
|
|
104
|
+
return 'Fresh Git status for the selected process group is unknown. Nothing further will be closed.';
|
|
105
|
+
const remaining = new Set(row.pids.filter(pid => !alreadyExited.has(pid)));
|
|
106
|
+
if (!listMatches(row.processes.map(item => item.pid), new Set(row.pids)))
|
|
107
|
+
return 'The confirmed audit did not include every process identity.';
|
|
108
|
+
if (!listMatches(latest.pids, remaining) || !listMatches(latest.processes.map(item => item.pid), remaining)) {
|
|
109
|
+
return 'The process group changed after confirmation. New or unlisted processes will not be closed.';
|
|
110
|
+
}
|
|
111
|
+
for (const child of descendants(row.pid, current.values())) {
|
|
112
|
+
if (!remaining.has(child))
|
|
113
|
+
return `Unlisted descendant PID ${child} prevents closing this process group.`;
|
|
114
|
+
}
|
|
115
|
+
for (const pid of remaining) {
|
|
116
|
+
const before = initial.get(pid), now = current.get(pid);
|
|
117
|
+
const rowBefore = row.processes.find(item => item.pid === pid), rowNow = latest.processes.find(item => item.pid === pid);
|
|
118
|
+
if (!before || !now || !rowBefore || !rowNow || !sameProcess(before, rowBefore) || !sameProcess(now, rowNow) || !sameProcess(before, now)) {
|
|
119
|
+
return `PID ${pid} disappeared, was reused, changed working directory or was reparented after confirmation.`;
|
|
120
|
+
}
|
|
121
|
+
const session = row.kind === 'session' || fresh.rows.some(item => item.kind === 'session' && item.pid === pid);
|
|
122
|
+
const unknownTerminal = now.terminalIdleSeconds === null || !Number.isFinite(now.terminalIdleSeconds);
|
|
123
|
+
if ((session && unknownTerminal) || (!unknownTerminal && now.terminalIdleSeconds < DAY_SECONDS)) {
|
|
124
|
+
return now.terminalIdleSeconds === null || !Number.isFinite(now.terminalIdleSeconds)
|
|
125
|
+
? `Terminal activity for PID ${pid} is unknown. Transcript or profile age cannot authorize closing it.`
|
|
126
|
+
: `PID ${pid} has terminal input within the last 24 hours.`;
|
|
127
|
+
}
|
|
128
|
+
if (!now.cwd)
|
|
129
|
+
return `The working directory for PID ${pid} is unknown.`;
|
|
130
|
+
if (!checkGit)
|
|
131
|
+
continue;
|
|
132
|
+
let dirty = null;
|
|
133
|
+
try {
|
|
134
|
+
if (dependencies.gitDirty)
|
|
135
|
+
dirty = await dependencies.gitDirty(now.cwd);
|
|
136
|
+
else {
|
|
137
|
+
const evidence = fresh.rows.filter(item => item.cwd === now.cwd).map(item => item.dirty);
|
|
138
|
+
dirty = evidence.includes(true) ? true : evidence.length > 0 && evidence.every(value => value === false) ? false : null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch { /* Unknown Git state never authorizes a close. */ }
|
|
142
|
+
if (dirty === true)
|
|
143
|
+
return `PID ${pid} has a working directory with dirty or untracked files.`;
|
|
144
|
+
if (dirty !== false)
|
|
145
|
+
return `Git status for PID ${pid}'s working directory is unknown.`;
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
function childFirst(row) {
|
|
150
|
+
const processes = new Map(row.processes.map(item => [item.pid, item]));
|
|
151
|
+
const depth = (pid) => {
|
|
152
|
+
const visited = new Set();
|
|
153
|
+
let current = pid;
|
|
154
|
+
while (processes.has(current) && !visited.has(current)) {
|
|
155
|
+
visited.add(current);
|
|
156
|
+
current = processes.get(current).ppid;
|
|
157
|
+
}
|
|
158
|
+
return visited.size;
|
|
159
|
+
};
|
|
160
|
+
return [...row.pids].sort((a, b) => depth(b) - depth(a) || a - b);
|
|
161
|
+
}
|
|
162
|
+
async function waitForExit(pid, dependencies) {
|
|
163
|
+
const requested = dependencies.timeoutMs ?? 3000;
|
|
164
|
+
const duration = Number.isFinite(requested) ? Math.max(0, Math.min(10000, requested)) : 3000;
|
|
165
|
+
const wait = dependencies.wait ?? (milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)));
|
|
166
|
+
let elapsed = 0;
|
|
167
|
+
while (true) {
|
|
168
|
+
try {
|
|
169
|
+
if (!await dependencies.alive(pid))
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
if (elapsed >= duration)
|
|
176
|
+
return false;
|
|
177
|
+
const interval = Math.min(100, duration - elapsed);
|
|
178
|
+
try {
|
|
179
|
+
await wait(interval);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
elapsed += interval;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** The displayed snapshot is immutable authorization: recollection can only narrow it. */
|
|
188
|
+
async function reapSelected(snapshot, selectionText, dependencies) {
|
|
189
|
+
interactive(dependencies);
|
|
190
|
+
const selections = parseReapSelection(selectionText, snapshot.rows.length);
|
|
191
|
+
if (selections.length === 0)
|
|
192
|
+
return { cancelled: true, results: [] };
|
|
193
|
+
const authorization = structuredClone(snapshot);
|
|
194
|
+
const results = [];
|
|
195
|
+
const signaled = new Set();
|
|
196
|
+
for (const selection of selections) {
|
|
197
|
+
const row = authorization.rows[selection - 1];
|
|
198
|
+
const result = { selection, id: row.id, label: row.label, status: 'refused', reason: '', pids: [] };
|
|
199
|
+
results.push(result);
|
|
200
|
+
const exited = new Set();
|
|
201
|
+
for (const pid of childFirst(row)) {
|
|
202
|
+
let reason;
|
|
203
|
+
try {
|
|
204
|
+
reason = await refusal(authorization, row, await dependencies.readAudit(), dependencies, exited);
|
|
205
|
+
// Git inspection can await subprocesses. Recheck OS identity and
|
|
206
|
+
// ownership after that wait, immediately before the individual signal.
|
|
207
|
+
if (!reason)
|
|
208
|
+
reason = await refusal(authorization, row, await dependencies.readAudit(), dependencies, exited, false);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
reason = 'A fresh process and working-directory audit could not be completed.';
|
|
212
|
+
}
|
|
213
|
+
if (reason) {
|
|
214
|
+
result.reason = reason;
|
|
215
|
+
result.pids.push({ pid, status: 'refused', reason });
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
if (signaled.has(pid)) {
|
|
219
|
+
result.reason = `PID ${pid} was already selected in another item; it will not be signaled twice.`;
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
// This is the only signal site. It never targets a process group and never
|
|
223
|
+
// escalates to SIGKILL, including when a selected process stays alive.
|
|
224
|
+
try {
|
|
225
|
+
await dependencies.signal(pid, 'SIGTERM');
|
|
226
|
+
signaled.add(pid);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
const message = `SIGTERM could not be sent to PID ${pid}. No stronger signal was attempted.`;
|
|
230
|
+
result.pids.push({ pid, status: 'signal_failed', reason: message });
|
|
231
|
+
result.status = 'signal_failed';
|
|
232
|
+
result.reason = message;
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
const ended = await waitForExit(pid, dependencies);
|
|
236
|
+
const message = ended ? `PID ${pid} exited after SIGTERM.` : `PID ${pid} is still alive or its exit could not be verified. No stronger signal was sent.`;
|
|
237
|
+
result.pids.push({ pid, status: ended ? 'exited' : 'still_running', reason: message });
|
|
238
|
+
if (ended)
|
|
239
|
+
exited.add(pid);
|
|
240
|
+
else {
|
|
241
|
+
result.status = 'still_running';
|
|
242
|
+
result.reason = message;
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (result.pids.length === 0)
|
|
247
|
+
result.reason = row.kind === 'workspace'
|
|
248
|
+
? row.openHandles === true ? 'Workspace has open file handles; information only. Workspaces are never deleted or closed.'
|
|
249
|
+
: 'Workspaces are informational only and are never deleted or closed.' : 'No verified process identities were listed.';
|
|
250
|
+
else if (result.pids.length === row.pids.length && result.pids.every(item => item.status === 'exited')) {
|
|
251
|
+
result.status = 'terminated';
|
|
252
|
+
result.reason = 'Every selected process exited after SIGTERM.';
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return { cancelled: false, results };
|
|
256
|
+
}
|
|
257
|
+
/** The CLI prints the entire audit before calling this prompt. */
|
|
258
|
+
async function interactiveReap(snapshot, dependencies) {
|
|
259
|
+
interactive(dependencies);
|
|
260
|
+
const selection = await dependencies.question('Type displayed item numbers to close with SIGTERM, or press Enter to cancel: ');
|
|
261
|
+
return reapSelected(snapshot, selection, dependencies);
|
|
262
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { AuditReport } from './types';
|
|
2
|
+
export declare function memory(bytes: number | null): string;
|
|
3
|
+
export declare function duration(seconds: number | null): string;
|
|
4
|
+
export declare function rankAudit(report: AuditReport): AuditReport;
|
|
5
|
+
/** Raw command lines can contain prompts or credentials. They are never printed. */
|
|
6
|
+
export declare function auditForJson(report: AuditReport): unknown;
|
|
7
|
+
export declare function renderAudit(report: AuditReport, options?: {
|
|
8
|
+
colour?: boolean;
|
|
9
|
+
width?: number;
|
|
10
|
+
}): string;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.memory = memory;
|
|
4
|
+
exports.duration = duration;
|
|
5
|
+
exports.rankAudit = rankAudit;
|
|
6
|
+
exports.auditForJson = auditForJson;
|
|
7
|
+
exports.renderAudit = renderAudit;
|
|
8
|
+
/** Local resource tables use Burn's existing light frame and WARN vocabulary. */
|
|
9
|
+
const frames_1 = require("../frames");
|
|
10
|
+
const plain = (value) => value.replace(/[\x00-\x1f\x7f-\x9f]/g, '?');
|
|
11
|
+
const shorten = (value, width) => {
|
|
12
|
+
const chars = Array.from(plain(value));
|
|
13
|
+
return chars.length <= width ? chars.join('') : chars.slice(0, Math.max(0, width - 3)).join('') + '...';
|
|
14
|
+
};
|
|
15
|
+
const pad = (value, width) => {
|
|
16
|
+
const clean = shorten(value, width);
|
|
17
|
+
return clean + ' '.repeat(Math.max(0, width - Array.from(clean).length));
|
|
18
|
+
};
|
|
19
|
+
function memory(bytes) {
|
|
20
|
+
if (bytes === null)
|
|
21
|
+
return 'unknown';
|
|
22
|
+
return bytes >= 1e9 ? `${(bytes / 1e9).toFixed(2)} GB` : `${(bytes / 1e6).toFixed(1)} MB`;
|
|
23
|
+
}
|
|
24
|
+
function duration(seconds) {
|
|
25
|
+
if (seconds === null)
|
|
26
|
+
return '?';
|
|
27
|
+
if (seconds >= 86400)
|
|
28
|
+
return `${(seconds / 86400).toFixed(1)}d`;
|
|
29
|
+
if (seconds >= 3600)
|
|
30
|
+
return `${(seconds / 3600).toFixed(1)}h`;
|
|
31
|
+
return `${Math.max(0, Math.floor(seconds / 60))}m`;
|
|
32
|
+
}
|
|
33
|
+
function rankAudit(report) {
|
|
34
|
+
return { ...report, rows: [...report.rows].sort((a, b) => {
|
|
35
|
+
if ((a.kind === 'workspace') !== (b.kind === 'workspace'))
|
|
36
|
+
return a.kind === 'workspace' ? 1 : -1;
|
|
37
|
+
return (a.kind === 'workspace' ? (b.sizeBytes ?? 0) - (a.sizeBytes ?? 0) : b.rssBytes - a.rssBytes) || a.id.localeCompare(b.id);
|
|
38
|
+
}) };
|
|
39
|
+
}
|
|
40
|
+
/** Raw command lines can contain prompts or credentials. They are never printed. */
|
|
41
|
+
function auditForJson(report) {
|
|
42
|
+
const process = ({ command: ignored, ...metadata }) => metadata;
|
|
43
|
+
const owned = new Set(report.rows.flatMap(row => row.pids));
|
|
44
|
+
return { ...report, processes: report.processes.filter(row => owned.has(row.pid)).map(process), rows: report.rows.map(row => ({ ...row, processes: row.processes.map(process) })) };
|
|
45
|
+
}
|
|
46
|
+
function renderAudit(report, options = {}) {
|
|
47
|
+
if (report.platform === 'win32')
|
|
48
|
+
return 'not supported on this platform yet';
|
|
49
|
+
if (options.colour)
|
|
50
|
+
return (0, frames_1.renderFrame)((0, frames_1.auditFrame)('ps', report), { width: Math.min(104, options.width ?? process.stdout.columns ?? 104), colour: true }).render();
|
|
51
|
+
const width = Math.max(100, Math.min(160, options.width ?? 118)), inner = width - 4;
|
|
52
|
+
const lines = [];
|
|
53
|
+
const border = (left, middle, right) => lines.push(left + middle.repeat(width - 2) + right);
|
|
54
|
+
const line = (value = '') => {
|
|
55
|
+
let content = pad(value, inner);
|
|
56
|
+
if (options.colour)
|
|
57
|
+
content = content.replace(/\[WARN\]/g, '\x1b[33m[WARN]\x1b[0m');
|
|
58
|
+
lines.push(`│ ${content} │`);
|
|
59
|
+
};
|
|
60
|
+
const wrap = (value, prefix = '') => {
|
|
61
|
+
let rest = Array.from(plain(value));
|
|
62
|
+
do {
|
|
63
|
+
line(prefix + rest.splice(0, inner - prefix.length).join(''));
|
|
64
|
+
} while (rest.length);
|
|
65
|
+
};
|
|
66
|
+
border('╭', '─', '╮');
|
|
67
|
+
line('AGENTGUARD BURN | LOCAL COST EXPOSURE');
|
|
68
|
+
line(`Observed ${report.generatedAt} | ${report.platform} | memory first, then workspace disk`);
|
|
69
|
+
border('├', '─', '┤');
|
|
70
|
+
const pathWidth = inner - 79;
|
|
71
|
+
const columns = (values) => values.map((value, index) => pad(value, [4, 9, 8, pathWidth, 10, 7, 7, 11, 8, 6][index])).join(' ');
|
|
72
|
+
line(columns(['#', 'HOST/KIND', 'PID', 'WORKING DIRECTORY', 'MEMORY', 'UPTIME', 'IDLE', 'METHOD', 'WINDOWS', '']));
|
|
73
|
+
let hasWorkspaces = false;
|
|
74
|
+
report.rows.forEach((row, index) => {
|
|
75
|
+
if (row.kind === 'workspace') {
|
|
76
|
+
if (!hasWorkspaces) {
|
|
77
|
+
border('├', '─', '┤');
|
|
78
|
+
line('WORKSPACES | information only, never deleted');
|
|
79
|
+
hasWorkspaces = true;
|
|
80
|
+
}
|
|
81
|
+
wrap(`${index + 1}. ${row.warn ? '[WARN] ' : ''}${row.cwd ?? row.label}`);
|
|
82
|
+
line(` Disk ${memory(row.sizeBytes)} | modified ${row.modifiedAt ?? 'unknown'} | ${row.orphan ? 'orphan' : 'registered or in use'}`);
|
|
83
|
+
line(` Dirty: ${row.dirty === null ? 'unknown' : row.dirty ? 'yes, modified or untracked' : 'no'} | Open handles: ${row.openHandles === null ? 'unknown' : row.openHandles ? 'yes' : 'no'}`);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
line(columns([String(index + 1), row.host ?? row.kind, String(row.pid ?? '?'), row.cwd ?? '?', memory(row.rssBytes), duration(row.uptimeSeconds), duration(row.idleSeconds), row.idleMethod, row.kind === 'browser' ? String(row.windows ?? '?') : '', row.warn ? '[WARN]' : '']));
|
|
87
|
+
if (row.cwd && Array.from(row.cwd).length > pathWidth)
|
|
88
|
+
wrap(row.cwd, ' cwd: ');
|
|
89
|
+
if (row.pids.length > 1)
|
|
90
|
+
wrap(` ${row.pids.length} processes: ${row.pids.join(', ')}`);
|
|
91
|
+
}
|
|
92
|
+
for (const reason of row.reasons)
|
|
93
|
+
wrap(reason, ' ');
|
|
94
|
+
});
|
|
95
|
+
if (!report.rows.length)
|
|
96
|
+
line('No agent-owned resources found in the readable scope.');
|
|
97
|
+
border('├', '─', '┤');
|
|
98
|
+
line(`Idle agent held memory: ${memory(report.totals.idleAgentMemoryBytes)}`);
|
|
99
|
+
line(`Orphan workspace disk: ${memory(report.totals.orphanWorkspaceBytes)}`);
|
|
100
|
+
line(`Swap in use: ${memory(report.swapUsedBytes)}`);
|
|
101
|
+
wrap('Swap is only released by the operating system on reboot. Burn does not touch it.');
|
|
102
|
+
wrap('RSS is resident memory, not a bill. Shared pages can be counted in more than one process.');
|
|
103
|
+
wrap('Idle methods: terminal device access time; transcript file writes; profile file writes; unknown.');
|
|
104
|
+
for (const skipped of report.skipped)
|
|
105
|
+
wrap(`Skipped: ${skipped}`);
|
|
106
|
+
border('╰', '─', '╯');
|
|
107
|
+
return lines.join('\n');
|
|
108
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Local metadata only. Unknown evidence is explicit and never authorizes reaping. */
|
|
2
|
+
export interface AuditThresholds {
|
|
3
|
+
idle_session_warn_hours: number;
|
|
4
|
+
idle_browser_warn_minutes: number;
|
|
5
|
+
orphan_workspace_warn_days: number;
|
|
6
|
+
}
|
|
7
|
+
export interface ProcessSnapshot {
|
|
8
|
+
pid: number;
|
|
9
|
+
ppid: number;
|
|
10
|
+
command: string;
|
|
11
|
+
/** Stable OS start identity, checked again before signaling to avoid PID reuse. */
|
|
12
|
+
startedAt: string | null;
|
|
13
|
+
uptimeSeconds: number | null;
|
|
14
|
+
rssBytes: number;
|
|
15
|
+
cwd: string | null;
|
|
16
|
+
tty: string | null;
|
|
17
|
+
terminalIdleSeconds: number | null;
|
|
18
|
+
}
|
|
19
|
+
export type AuditKind = 'session' | 'daemon' | 'browser' | 'workspace';
|
|
20
|
+
export interface AuditRow {
|
|
21
|
+
id: string;
|
|
22
|
+
kind: AuditKind;
|
|
23
|
+
label: string;
|
|
24
|
+
host: 'claude' | 'codex' | null;
|
|
25
|
+
pid: number | null;
|
|
26
|
+
pids: number[];
|
|
27
|
+
processes: ProcessSnapshot[];
|
|
28
|
+
cwd: string | null;
|
|
29
|
+
uptimeSeconds: number | null;
|
|
30
|
+
idleSeconds: number | null;
|
|
31
|
+
idleMethod: 'terminal' | 'transcript' | 'profile' | 'unknown';
|
|
32
|
+
rssBytes: number;
|
|
33
|
+
sizeBytes: number | null;
|
|
34
|
+
modifiedAt: string | null;
|
|
35
|
+
dirty: boolean | null;
|
|
36
|
+
openHandles: boolean | null;
|
|
37
|
+
windows: number | null;
|
|
38
|
+
orphan: boolean;
|
|
39
|
+
warn: boolean;
|
|
40
|
+
reasons: string[];
|
|
41
|
+
}
|
|
42
|
+
export interface AuditReport {
|
|
43
|
+
version: 1;
|
|
44
|
+
generatedAt: string;
|
|
45
|
+
platform: string;
|
|
46
|
+
rows: AuditRow[];
|
|
47
|
+
processes: ProcessSnapshot[];
|
|
48
|
+
swapUsedBytes: number | null;
|
|
49
|
+
skipped: string[];
|
|
50
|
+
totals: {
|
|
51
|
+
idleAgentMemoryBytes: number;
|
|
52
|
+
orphanWorkspaceBytes: number;
|
|
53
|
+
};
|
|
54
|
+
thresholds: AuditThresholds;
|
|
55
|
+
}
|
|
56
|
+
export declare const DEFAULT_AUDIT_THRESHOLDS: AuditThresholds;
|
package/dist/src/install.js
CHANGED
|
@@ -110,11 +110,13 @@ function install(host, cliPath, home = (0, node_os_1.homedir)()) {
|
|
|
110
110
|
let changed;
|
|
111
111
|
if (host === 'claude') {
|
|
112
112
|
command = `node ${cliPath} hook`;
|
|
113
|
-
|
|
113
|
+
const startChanged = mergeMatcherStyle(cfg, 'SessionStart', '.*', command, 1);
|
|
114
|
+
changed = mergeMatcherStyle(cfg, 'PreToolUse', '.*', command, 15) || startChanged;
|
|
114
115
|
}
|
|
115
116
|
else if (host === 'codex') {
|
|
116
117
|
command = `node ${cliPath} codex-hook`;
|
|
117
|
-
|
|
118
|
+
const startChanged = mergeMatcherStyle(cfg, 'SessionStart', '.*', command, 1);
|
|
119
|
+
changed = mergeMatcherStyle(cfg, 'PreToolUse', '.*', command, 15) || startChanged;
|
|
118
120
|
}
|
|
119
121
|
else {
|
|
120
122
|
command = `node ${cliPath} cursor-hook`;
|