@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +69 -0
  3. package/dist/src/adapters/codex.js +6 -0
  4. package/dist/src/canvas.d.ts +36 -0
  5. package/dist/src/canvas.js +132 -0
  6. package/dist/src/cli.d.ts +2 -0
  7. package/dist/src/cli.js +111 -25
  8. package/dist/src/defaults.d.ts +1 -1
  9. package/dist/src/defaults.js +4 -1
  10. package/dist/src/frames.d.ts +38 -0
  11. package/dist/src/frames.js +98 -0
  12. package/dist/src/gateway.js +3 -0
  13. package/dist/src/hook/pre-tool-use.d.ts +1 -0
  14. package/dist/src/hook/pre-tool-use.js +12 -1
  15. package/dist/src/idle/cache.d.ts +8 -0
  16. package/dist/src/idle/cache.js +70 -0
  17. package/dist/src/idle/classify.d.ts +13 -0
  18. package/dist/src/idle/classify.js +133 -0
  19. package/dist/src/idle/cli.d.ts +2 -0
  20. package/dist/src/idle/cli.js +72 -0
  21. package/dist/src/idle/collect.d.ts +38 -0
  22. package/dist/src/idle/collect.js +543 -0
  23. package/dist/src/idle/hook.d.ts +17 -0
  24. package/dist/src/idle/hook.js +61 -0
  25. package/dist/src/idle/platform.d.ts +25 -0
  26. package/dist/src/idle/platform.js +154 -0
  27. package/dist/src/idle/reap.d.ts +41 -0
  28. package/dist/src/idle/reap.js +262 -0
  29. package/dist/src/idle/render.d.ts +10 -0
  30. package/dist/src/idle/render.js +108 -0
  31. package/dist/src/idle/types.d.ts +56 -0
  32. package/dist/src/idle/types.js +8 -0
  33. package/dist/src/install.js +4 -2
  34. package/dist/src/policy.js +47 -1
  35. package/dist/src/presentation.d.ts +5 -0
  36. package/dist/src/presentation.js +23 -0
  37. package/dist/src/recording.d.ts +22 -0
  38. package/dist/src/recording.js +80 -0
  39. package/dist/src/render-recording.d.ts +13 -0
  40. package/dist/src/render-recording.js +193 -0
  41. package/dist/src/replay/render.js +5 -0
  42. package/dist/src/types.d.ts +6 -0
  43. package/docs/burn-idle-audit.md +71 -0
  44. package/docs/burn-render.md +37 -0
  45. package/package.json +4 -2
@@ -0,0 +1,543 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.gitDirty = void 0;
4
+ exports.checkWorkingDirectory = checkWorkingDirectory;
5
+ exports.collectAudit = collectAudit;
6
+ const node_fs_1 = require("node:fs");
7
+ const node_os_1 = require("node:os");
8
+ const node_path_1 = require("node:path");
9
+ const classify_1 = require("./classify");
10
+ const platform_1 = require("./platform");
11
+ const types_1 = require("./types");
12
+ const localMetadata = {
13
+ lstat: path => node_fs_1.promises.lstat(path), readdir: path => node_fs_1.promises.readdir(path), realpath: path => node_fs_1.promises.realpath(path),
14
+ async readFirstLine(path, bytes, signal) {
15
+ const file = await node_fs_1.promises.open(path, 'r');
16
+ try {
17
+ const buffer = Buffer.alloc(bytes);
18
+ let count = 0;
19
+ while (count < bytes && !signal?.aborted) {
20
+ const read = await file.read(buffer, count, 1, count);
21
+ if (!read.bytesRead)
22
+ return '';
23
+ if (buffer[count] === 10)
24
+ return buffer.subarray(0, count).toString('utf8');
25
+ count++;
26
+ }
27
+ return '';
28
+ }
29
+ finally {
30
+ await file.close();
31
+ }
32
+ },
33
+ async readPrefix(path, bytes) {
34
+ const file = await node_fs_1.promises.open(path, 'r');
35
+ try {
36
+ const buffer = Buffer.alloc(bytes);
37
+ const result = await file.read(buffer, 0, bytes, 0);
38
+ return buffer.subarray(0, result.bytesRead).toString('utf8');
39
+ }
40
+ finally {
41
+ await file.close();
42
+ }
43
+ },
44
+ };
45
+ async function checkWorkingDirectory(cwd, options = {}) {
46
+ if (!cwd || options.signal?.aborted)
47
+ return null;
48
+ const runner = options.runner || platform_1.runLocal;
49
+ const result = await runner('git', ['-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false', '-C', cwd, 'status', '--porcelain=v1', '--untracked-files=normal'], { signal: options.signal, timeoutMs: options.commandTimeoutMs ?? 1500, maxBuffer: 512 * 1024 });
50
+ if (result.code === 0)
51
+ return Boolean(result.stdout.trim());
52
+ if (/not a git repository/i.test(result.stderr || '') && !/permission denied|operation not permitted/i.test(result.stderr || '')) {
53
+ // A broken worktree's .git pointer is not proof that its contents are clean.
54
+ let directory = (0, node_path_1.resolve)(cwd);
55
+ for (let depth = 0; depth < 24; depth++) {
56
+ if (options.signal?.aborted)
57
+ return null;
58
+ try {
59
+ await node_fs_1.promises.lstat((0, node_path_1.join)(directory, '.git'));
60
+ return null;
61
+ }
62
+ catch (error) {
63
+ if (!['ENOENT', 'ENOTDIR'].includes(error.code || ''))
64
+ return null;
65
+ }
66
+ const parent = (0, node_path_1.dirname)(directory);
67
+ if (parent === directory)
68
+ return false;
69
+ directory = parent;
70
+ }
71
+ }
72
+ return null;
73
+ }
74
+ exports.gitDirty = checkWorkingDirectory;
75
+ const blankRow = (kind, id, label) => ({ id, kind, label, host: null, pid: null, pids: [], processes: [], cwd: null,
76
+ uptimeSeconds: null, idleSeconds: null, idleMethod: 'unknown', rssBytes: 0, sizeBytes: null, modifiedAt: null, dirty: null, openHandles: null,
77
+ windows: null, orphan: false, warn: false, reasons: [] });
78
+ const cleanThresholds = (input) => {
79
+ const result = { ...types_1.DEFAULT_AUDIT_THRESHOLDS };
80
+ for (const key of Object.keys(result)) {
81
+ const value = input?.[key];
82
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0)
83
+ result[key] = value;
84
+ }
85
+ return result;
86
+ };
87
+ /** Bounded local OS and filesystem metadata; no network client or transcript parser is imported. */
88
+ async function collectAudit(options = {}) {
89
+ const now = options.now ?? Date.now(), platform = options.platform || process.platform, home = options.homeDir || (0, node_os_1.homedir)();
90
+ const thresholds = cleanThresholds(options.thresholds), runner = options.runner || platform_1.runLocal, metadata = options.metadata || localMetadata;
91
+ const skipped = [], rows = [], started = performance.now();
92
+ const limit = options.maxScanEntries ?? 8000, maxDepth = options.maxDepth ?? 5, maxProcesses = options.maxProcesses ?? 10000;
93
+ let scanned = 0, stopped = false;
94
+ const alive = () => {
95
+ if (stopped)
96
+ return false;
97
+ if (options.signal?.aborted || performance.now() - started > (options.maxDurationMs ?? 30000)) {
98
+ stopped = true;
99
+ skipped.push(options.signal?.aborted ? 'Audit canceled; remaining evidence is unknown.' : 'Audit time budget reached; remaining evidence is unknown.');
100
+ return false;
101
+ }
102
+ return true;
103
+ };
104
+ const commandOptions = () => ({ signal: options.signal, timeoutMs: options.commandTimeoutMs ?? 1200 });
105
+ const run = async (command, args, extra = {}) => alive() ? runner(command, args, { ...commandOptions(), ...extra }) : { code: -1, stdout: '', stderr: 'aborted' };
106
+ const stat = async (path) => { if (!alive())
107
+ return null; try {
108
+ return await metadata.lstat(path);
109
+ }
110
+ catch {
111
+ return null;
112
+ } };
113
+ const canonical = async (path) => { if (!alive())
114
+ return (0, node_path_1.resolve)(path); try {
115
+ return await metadata.realpath(path);
116
+ }
117
+ catch {
118
+ return (0, node_path_1.resolve)(path);
119
+ } };
120
+ async function entries(path) {
121
+ if (!alive())
122
+ return null;
123
+ try {
124
+ const list = (await metadata.readdir(path)).sort();
125
+ scanned += list.length;
126
+ if (scanned > limit) {
127
+ skipped.push('Filesystem scan entry limit reached; directory evidence is incomplete.');
128
+ return null;
129
+ }
130
+ return list;
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ }
136
+ async function newest(path, depth = 0) {
137
+ const info = await stat(path);
138
+ if (!info || info.isSymbolicLink())
139
+ return null;
140
+ let value = info.mtimeMs;
141
+ if (info.isDirectory()) {
142
+ if (depth >= maxDepth) {
143
+ skipped.push('Directory activity scan reached its depth bound.');
144
+ return null;
145
+ }
146
+ const names = await entries(path);
147
+ if (!names)
148
+ return null;
149
+ for (const name of names) {
150
+ if (!alive())
151
+ return null;
152
+ const childPath = (0, node_path_1.join)(path, name), childInfo = await stat(childPath);
153
+ if (childInfo?.isSymbolicLink())
154
+ continue;
155
+ const child = await newest(childPath, depth + 1);
156
+ if (child === null)
157
+ return null;
158
+ value = Math.max(value, child);
159
+ }
160
+ }
161
+ return value;
162
+ }
163
+ async function directoryFacts(path, depth = 0) {
164
+ const info = await stat(path);
165
+ if (!info || info.isSymbolicLink())
166
+ return null;
167
+ if (!info.isDirectory())
168
+ return { size: info.size, newest: info.mtimeMs };
169
+ if (depth >= maxDepth)
170
+ return null;
171
+ const names = await entries(path);
172
+ if (!names)
173
+ return null;
174
+ let size = 0, modified = info.mtimeMs;
175
+ for (const name of names) {
176
+ if (!alive())
177
+ return null;
178
+ const childPath = (0, node_path_1.join)(path, name), childInfo = await stat(childPath);
179
+ if (childInfo?.isSymbolicLink())
180
+ continue; // Never follow workspace symlinks.
181
+ const child = await directoryFacts(childPath, depth + 1);
182
+ if (!child)
183
+ return null;
184
+ size += child.size;
185
+ modified = Math.max(modified, child.newest);
186
+ }
187
+ return { size, newest: modified };
188
+ }
189
+ const ps = await run('ps', ['-axo', 'pid=,ppid=,rss=,tty=,lstart=,etime=,command='], { maxBuffer: 8 * 1024 * 1024 });
190
+ let processes = ps.code === 0 ? (0, platform_1.parsePs)(ps.stdout) : [];
191
+ if (!processes.length && platform === 'linux' && alive())
192
+ processes = await (0, platform_1.procProcesses)(options.procRoot || '/proc', runner, options.signal, maxProcesses, skipped);
193
+ if (!processes.length)
194
+ skipped.push('Process table is unavailable; process ownership and memory are unknown.');
195
+ if (processes.length > maxProcesses) {
196
+ processes = [];
197
+ skipped.push('Process table exceeds its safety bound; process ownership is unknown.');
198
+ }
199
+ const processByPid = new Map(processes.map(value => [value.pid, value]));
200
+ const sessionCandidates = processes.filter(value => (0, classify_1.agentHost)(value.command)), browserCandidates = processes.filter(value => (0, classify_1.isAutomationBrowser)(value.command));
201
+ const daemonCandidates = processes.filter(value => (0, classify_1.isPluginDaemon)(value.command));
202
+ const relevant = new Map();
203
+ for (const candidate of [...sessionCandidates, ...browserCandidates, ...daemonCandidates])
204
+ for (const child of (0, classify_1.descendants)(candidate.pid, processes))
205
+ relevant.set(child.pid, child);
206
+ if (relevant.size && alive()) {
207
+ if (platform === 'darwin' || processes.some(value => value.startedAt?.startsWith('ps:'))) {
208
+ const result = await run('lsof', ['-nP', '-a', '-d', 'cwd', '-p', [...relevant.keys()].join(','), '-Fpn'], { timeoutMs: options.commandTimeoutMs ?? 5000 });
209
+ if (result.code === 0 || result.code === 1)
210
+ for (const [pid, cwd] of (0, platform_1.parseLsofCwds)(result.stdout)) {
211
+ const process = processByPid.get(pid);
212
+ if (process)
213
+ process.cwd = cwd;
214
+ }
215
+ else
216
+ skipped.push('Some process working directories are unavailable from lsof.');
217
+ }
218
+ for (const process of relevant.values()) {
219
+ if (!alive())
220
+ break;
221
+ if (platform === 'linux' && !process.cwd) {
222
+ try {
223
+ process.cwd = await node_fs_1.promises.readlink((0, node_path_1.join)(options.procRoot || '/proc', String(process.pid), 'cwd'));
224
+ }
225
+ catch { /* Unknown, never inferred. */ }
226
+ }
227
+ if (process.cwd)
228
+ process.cwd = await canonical(process.cwd);
229
+ if (process.tty) {
230
+ const ttyPath = process.tty.startsWith('/dev/') ? process.tty : (0, node_path_1.join)('/dev', process.tty);
231
+ const tty = await stat(ttyPath);
232
+ if (tty && tty.atimeMs > 0 && tty.atimeMs <= now)
233
+ process.terminalIdleSeconds = Math.max(0, (now - tty.atimeMs) / 1000);
234
+ }
235
+ }
236
+ }
237
+ const dirtyCache = new Map();
238
+ async function dirty(cwd) {
239
+ if (!cwd || !alive())
240
+ return null;
241
+ if (!dirtyCache.has(cwd))
242
+ dirtyCache.set(cwd, await checkWorkingDirectory(cwd, { runner, signal: options.signal, commandTimeoutMs: options.commandTimeoutMs }));
243
+ return dirtyCache.get(cwd) ?? null;
244
+ }
245
+ async function existence(path) {
246
+ if (!alive())
247
+ return null;
248
+ try {
249
+ await metadata.lstat(path);
250
+ return true;
251
+ }
252
+ catch (error) {
253
+ const code = error.code;
254
+ return code === 'ENOENT' || code === 'ENOTDIR' ? false : null;
255
+ }
256
+ }
257
+ const registrations = new Map();
258
+ async function registered(path) {
259
+ if (registrations.has(path))
260
+ return registrations.get(path);
261
+ const top = await run('git', ['-C', path, 'rev-parse', '--show-toplevel']);
262
+ let value = null;
263
+ if (top.code === 0 && top.stdout.trim()) {
264
+ const result = await run('git', ['-C', path, 'worktree', 'list', '--porcelain']);
265
+ if (result.code === 0) {
266
+ const listed = await Promise.all((0, platform_1.parseWorktrees)(result.stdout).map(canonical));
267
+ value = listed.includes(await canonical(top.stdout.trim()));
268
+ }
269
+ }
270
+ else if (/not a git repository/i.test(top.stderr || ''))
271
+ value = false;
272
+ registrations.set(path, value);
273
+ return value;
274
+ }
275
+ async function openHandles(path) {
276
+ if (processes.some(process => process.cwd && (0, classify_1.inside)(process.cwd, path)))
277
+ return true;
278
+ if (!processes.length)
279
+ return null;
280
+ const result = await run('lsof', ['-nP', '-Fpn', '+D', path]);
281
+ if (result.code === 0)
282
+ return /^p\d+$/m.test(result.stdout);
283
+ if (result.code === 1 && !result.stdout.trim() && !result.stderr?.trim())
284
+ return false;
285
+ return null;
286
+ }
287
+ let swapUsedBytes = null;
288
+ if (alive() && platform === 'darwin') {
289
+ const result = await run('sysctl', ['vm.swapusage']);
290
+ if (result.code === 0)
291
+ swapUsedBytes = (0, platform_1.parseSwap)(result.stdout, platform);
292
+ }
293
+ else if (alive() && platform === 'linux') {
294
+ try {
295
+ swapUsedBytes = (0, platform_1.parseSwap)(await metadata.readPrefix((0, node_path_1.join)(options.procRoot || '/proc', 'meminfo'), 16384), platform);
296
+ }
297
+ catch { /* Explicit unknown below. */ }
298
+ }
299
+ if (swapUsedBytes === null)
300
+ skipped.push('Swap usage is unavailable.');
301
+ const workspacePaths = new Set(), roots = options.workspaceRoots || ['/private/tmp', '/tmp', (0, node_path_1.join)(home, '.claude', 'scratchpads')];
302
+ async function findWorkspaces(path, depth = 0) {
303
+ if (!alive())
304
+ return;
305
+ if (depth > Math.min(maxDepth, 4)) {
306
+ skipped.push('Workspace discovery depth limit reached; deeper directories were skipped.');
307
+ return;
308
+ }
309
+ if (workspacePaths.size >= (options.maxWorkspaces ?? 1000)) {
310
+ skipped.push('Workspace count limit reached; additional candidates were skipped.');
311
+ return;
312
+ }
313
+ const info = await stat(path);
314
+ if (!info) {
315
+ if (await existence(path) === null)
316
+ skipped.push('Some workspace directories were inaccessible; discovery is incomplete.');
317
+ return;
318
+ }
319
+ if (!info.isDirectory() || info.isSymbolicLink())
320
+ return;
321
+ const names = await entries(path);
322
+ if (!names)
323
+ return;
324
+ if (names.includes('.git') || names.includes('package.json') || (depth > 0 && /scratchpads?$/i.test((0, node_path_1.basename)(path)))) {
325
+ workspacePaths.add(await canonical(path));
326
+ return;
327
+ }
328
+ for (const name of names) {
329
+ if (!alive())
330
+ return;
331
+ if (!['node_modules', '.git', '.cache'].includes(name))
332
+ await findWorkspaces((0, node_path_1.join)(path, name), depth + 1);
333
+ }
334
+ }
335
+ for (const root of new Set(await Promise.all(roots.map(canonical))))
336
+ await findWorkspaces(root);
337
+ scanned = 0;
338
+ const codexLocators = [];
339
+ let codexLocated = false;
340
+ async function locateCodex(path, depth = 0) {
341
+ if (!alive() || depth > 5)
342
+ return;
343
+ const names = await entries(path);
344
+ if (!names)
345
+ return;
346
+ for (const name of names) {
347
+ if (!alive())
348
+ return;
349
+ const location = (0, node_path_1.join)(path, name), info = await stat(location);
350
+ if (!info || info.isSymbolicLink())
351
+ continue;
352
+ if (info.isDirectory())
353
+ await locateCodex(location, depth + 1);
354
+ else if (name.endsWith('.jsonl') && info.isFile()) {
355
+ // Only the leading session_meta locator is read. No message is parsed,
356
+ // retained, printed, or sent; later transcript lines are never read.
357
+ try {
358
+ const first = await (metadata.readFirstLine || localMetadata.readFirstLine)(location, 8192, options.signal);
359
+ if (!first)
360
+ continue;
361
+ const header = JSON.parse(first);
362
+ if (header.type === 'session_meta' && typeof header.payload?.cwd === 'string')
363
+ codexLocators.push({ cwd: await canonical(header.payload.cwd), path: location, modified: info.mtimeMs });
364
+ }
365
+ catch { /* Not a readable locator. */ }
366
+ }
367
+ }
368
+ }
369
+ async function idle(row) {
370
+ const terminal = row.processes.map(value => value.terminalIdleSeconds).filter((value) => value !== null);
371
+ if (terminal.length) {
372
+ row.idleSeconds = Math.min(...terminal);
373
+ row.idleMethod = 'terminal';
374
+ row.reasons.push('Idle time uses the most recent terminal device access among this process group.');
375
+ return;
376
+ }
377
+ if (!row.cwd || !row.host)
378
+ return;
379
+ let modified = null;
380
+ if (row.host === 'claude') {
381
+ const encoded = row.cwd.replace(/[^A-Za-z0-9]/g, '-');
382
+ modified = await newest((0, node_path_1.join)(options.claudeProjectsRoot || (0, node_path_1.join)(home, '.claude', 'projects'), encoded));
383
+ if (modified !== null)
384
+ row.reasons.push('Idle is a workspace transcript-directory estimate; another session in the same workspace can make it newer.');
385
+ }
386
+ else {
387
+ if (!codexLocated) {
388
+ codexLocated = true;
389
+ const previousScanned = scanned;
390
+ scanned = 0;
391
+ await locateCodex(options.codexSessionsRoot || (0, node_path_1.join)(home, '.codex', 'sessions'));
392
+ scanned = previousScanned;
393
+ }
394
+ const matching = codexLocators.filter(value => value.cwd === row.cwd);
395
+ if (matching.length) {
396
+ modified = Math.max(...matching.map(value => value.modified));
397
+ row.reasons.push('Idle is a same-workspace Codex transcript metadata estimate; no message content is retained.');
398
+ }
399
+ }
400
+ if (modified !== null && modified <= now) {
401
+ row.idleSeconds = (now - modified) / 1000;
402
+ row.idleMethod = 'transcript';
403
+ }
404
+ else
405
+ row.reasons.push('No terminal activity or matching transcript metadata was available.');
406
+ }
407
+ const browserRoots = browserCandidates.filter(value => !(0, classify_1.hasAncestor)(value.pid, new Set(browserCandidates.map(item => item.pid)), processByPid));
408
+ const browserPids = new Set(browserRoots.flatMap(root => (0, classify_1.descendants)(root.pid, processes).map(value => value.pid)));
409
+ const normalBrowserPids = new Set(processes.filter(value => (0, classify_1.isChromeProcess)(value.command) && !browserPids.has(value.pid))
410
+ .flatMap(root => (0, classify_1.descendants)(root.pid, processes).map(value => value.pid)));
411
+ const qualifiedDaemons = new Map();
412
+ for (const candidate of daemonCandidates) {
413
+ if (!alive())
414
+ break;
415
+ const source = (0, classify_1.daemonPath)(candidate.command), sourceExists = source ? await existence(source) : null;
416
+ const cwdExists = candidate.cwd ? await existence(candidate.cwd) : null;
417
+ let marker = false;
418
+ if (source) {
419
+ let directory = (0, node_path_1.dirname)(source);
420
+ for (let depth = 0; depth < 8 && alive(); depth++) {
421
+ if (await stat((0, node_path_1.join)(directory, '.git'))) {
422
+ marker = true;
423
+ break;
424
+ }
425
+ const parent = (0, node_path_1.dirname)(directory);
426
+ if (parent === directory)
427
+ break;
428
+ directory = parent;
429
+ }
430
+ }
431
+ const sourceRegistration = source && sourceExists ? await registered((0, node_path_1.dirname)(source)) : null;
432
+ const temporary = (0, classify_1.isTemporary)(source) || (0, classify_1.isTemporary)(candidate.cwd);
433
+ const missing = sourceExists === false || cwdExists === false;
434
+ const unregistered = sourceRegistration === false && (marker || temporary);
435
+ if (temporary || missing || unregistered)
436
+ qualifiedDaemons.set(candidate.pid, { orphan: missing || unregistered,
437
+ reason: missing ? 'Daemon script or working directory no longer exists.' : unregistered ? 'Daemon script is outside the registered git worktrees.' : 'Daemon script or working directory is temporary; registration was not disproven.' });
438
+ }
439
+ const daemonRoots = daemonCandidates.filter(value => qualifiedDaemons.has(value.pid) && !(0, classify_1.hasAncestor)(value.pid, new Set(qualifiedDaemons.keys()), processByPid));
440
+ const daemonPids = new Set(daemonRoots.flatMap(root => (0, classify_1.descendants)(root.pid, processes).map(value => value.pid)));
441
+ const rootCandidates = [...browserRoots, ...daemonRoots,
442
+ ...sessionCandidates.filter(value => !(0, classify_1.hasAncestor)(value.pid, new Set(sessionCandidates.map(item => item.pid)), processByPid))];
443
+ const claimed = new Set();
444
+ const windows = platform === 'darwin' && browserPids.size && alive() ? await (0, platform_1.browserWindows)([...browserPids], runner, { ...commandOptions(), timeoutMs: options.commandTimeoutMs ?? 5000 }) : null;
445
+ if (browserPids.size && windows === null)
446
+ skipped.push('Automation browser window counts are unavailable; no accessibility or remote debugging connection was attempted.');
447
+ for (const process of rootCandidates) {
448
+ if (!alive())
449
+ break;
450
+ if (claimed.has(process.pid) || normalBrowserPids.has(process.pid))
451
+ continue;
452
+ const browser = browserRoots.some(value => value.pid === process.pid), host = (0, classify_1.agentHost)(process.command);
453
+ const kind = browser ? 'browser' : host ? 'session' : 'daemon';
454
+ const group = (0, classify_1.descendants)(process.pid, processes).filter(value => !normalBrowserPids.has(value.pid) && !claimed.has(value.pid) && (browser || (!browserPids.has(value.pid) && (daemonRoots.some(root => root.pid === process.pid) || !daemonPids.has(value.pid)))));
455
+ group.forEach(value => claimed.add(value.pid));
456
+ const row = blankRow(kind, `${kind}:${process.pid}:${process.startedAt || 'unknown'}`, browser ? 'Automation browser' : host ? `${host} session` : 'Plugin or hook daemon');
457
+ row.pid = process.pid;
458
+ row.pids = group.map(value => value.pid);
459
+ row.processes = group;
460
+ row.host = host;
461
+ row.cwd = process.cwd;
462
+ row.uptimeSeconds = process.uptimeSeconds;
463
+ row.rssBytes = group.reduce((sum, value) => sum + value.rssBytes, 0);
464
+ const groupDirty = await Promise.all([...new Set(group.map(value => value.cwd))].map(dirty));
465
+ row.dirty = groupDirty.some(value => value === true) ? true : groupDirty.some(value => value === null) ? null : false;
466
+ await idle(row);
467
+ if (browser) {
468
+ row.windows = windows ? group.reduce((sum, value) => sum + (windows.get(value.pid) || 0), 0) : null;
469
+ const profile = (0, classify_1.browserProfile)(process.command);
470
+ if (row.idleSeconds === null && profile) {
471
+ const modified = await newest(profile);
472
+ if (modified !== null && modified <= now) {
473
+ row.idleSeconds = (now - modified) / 1000;
474
+ row.idleMethod = 'profile';
475
+ row.reasons.push('Idle uses automation profile modification time, not browser interaction.');
476
+ }
477
+ }
478
+ row.warn = row.idleSeconds !== null && row.idleSeconds >= thresholds.idle_browser_warn_minutes * 60;
479
+ }
480
+ else {
481
+ if (kind === 'daemon') {
482
+ const evidence = qualifiedDaemons.get(process.pid);
483
+ row.orphan = evidence?.orphan || false;
484
+ if (evidence)
485
+ row.reasons.push(evidence.reason);
486
+ }
487
+ row.warn = row.orphan || (row.idleSeconds !== null && row.idleSeconds >= thresholds.idle_session_warn_hours * 3600);
488
+ }
489
+ if (row.dirty === null)
490
+ row.reasons.push('Git working-directory state is unknown.');
491
+ if (!process.startedAt)
492
+ row.reasons.push('Stable process start identity is unavailable.');
493
+ if (row.idleSeconds === null)
494
+ row.reasons.push('Activity is unknown; elapsed process lifetime is not evidence of idleness.');
495
+ rows.push(row);
496
+ }
497
+ for (const workspace of workspacePaths) {
498
+ if (!alive()) {
499
+ const row = blankRow('workspace', `workspace:${workspace}`, (0, node_path_1.basename)(workspace));
500
+ row.cwd = workspace;
501
+ row.reasons.push('Audit budget ended before workspace evidence could be collected.');
502
+ rows.push(row);
503
+ continue;
504
+ }
505
+ scanned = 0;
506
+ const row = blankRow('workspace', `workspace:${workspace}`, (0, node_path_1.basename)(workspace));
507
+ row.cwd = workspace;
508
+ const facts = await directoryFacts(workspace);
509
+ if (facts) {
510
+ row.sizeBytes = facts.size;
511
+ row.modifiedAt = new Date(facts.newest).toISOString();
512
+ row.idleSeconds = facts.newest <= now ? (now - facts.newest) / 1000 : null;
513
+ }
514
+ else {
515
+ const size = await run('du', ['-sk', workspace]);
516
+ const match = /^(\d+)\s/.exec(size.stdout);
517
+ if (size.code === 0 && match)
518
+ row.sizeBytes = Number(match[1]) * 1024;
519
+ const directory = await stat(workspace);
520
+ if (directory)
521
+ row.modifiedAt = new Date(directory.mtimeMs).toISOString();
522
+ row.reasons.push('Workspace modification time is directory metadata only because the bounded scan could not finish; it is not idle evidence.');
523
+ if (row.sizeBytes === null)
524
+ row.reasons.push('Workspace size is unavailable.');
525
+ }
526
+ row.dirty = await dirty(workspace);
527
+ row.openHandles = await openHandles(workspace);
528
+ const registration = await registered(workspace), liveOwner = processes.some(process => process.cwd && (0, classify_1.inside)(process.cwd, workspace));
529
+ row.orphan = registration === false && !liveOwner && row.openHandles === false;
530
+ row.reasons.push(registration === true ? 'Workspace is present in git worktree registrations.' : registration === false ? 'Workspace is absent from git worktree registrations.' : 'Git worktree registration could not be verified.');
531
+ if (row.openHandles === null)
532
+ row.reasons.push('Open handles could not be verified.');
533
+ row.warn = row.orphan && row.idleSeconds !== null && row.idleSeconds >= thresholds.orphan_workspace_warn_days * 86400;
534
+ rows.push(row);
535
+ }
536
+ if (roots.length && (rows.some(row => row.kind === 'workspace' && (row.sizeBytes === null || row.openHandles === null || row.reasons.some(reason => reason.includes('could not be verified'))))
537
+ || skipped.some(reason => /limit|budget|canceled|discovery is incomplete/.test(reason)))) {
538
+ skipped.push('Workspace totals are lower bounds over measured, proven orphans; incomplete or inaccessible directories are not counted as zero-sized.');
539
+ }
540
+ return { version: 1, generatedAt: new Date(now).toISOString(), platform, rows, processes, swapUsedBytes, skipped: [...new Set(skipped)], thresholds,
541
+ totals: { idleAgentMemoryBytes: rows.filter(row => row.kind === 'session' && row.warn).reduce((sum, row) => sum + row.rssBytes, 0),
542
+ orphanWorkspaceBytes: rows.filter(row => row.kind === 'workspace' && row.orphan).reduce((sum, row) => sum + (row.sizeBytes || 0), 0) } };
543
+ }
@@ -0,0 +1,17 @@
1
+ /** An advisory SessionStart check. It never denies or signals an audited PID. */
2
+ import type { Policy } from '../types';
3
+ import type { AuditReport, AuditRow, AuditThresholds } from './types';
4
+ export declare const SESSION_START_AUDIT_BUDGET_MS = 150;
5
+ export type CollectIdleAudit = (options: {
6
+ signal: AbortSignal;
7
+ thresholds: AuditThresholds;
8
+ }) => Promise<AuditReport>;
9
+ export declare function rowPastThreshold(row: AuditRow, thresholds: AuditThresholds, now: number): boolean;
10
+ export declare function warningForAudit(report: AuditReport, policy: Policy, now?: number): string | null;
11
+ export declare function sessionStartWarning(home: string, policy: Policy, now?: number): string | null;
12
+ export declare function sessionStartCheck(home: string, options: {
13
+ collect: CollectIdleAudit;
14
+ policy?: Policy;
15
+ now?: number;
16
+ budgetMs?: number;
17
+ }): Promise<string | null>;
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SESSION_START_AUDIT_BUDGET_MS = void 0;
4
+ exports.rowPastThreshold = rowPastThreshold;
5
+ exports.warningForAudit = warningForAudit;
6
+ exports.sessionStartWarning = sessionStartWarning;
7
+ exports.sessionStartCheck = sessionStartCheck;
8
+ const policy_1 = require("../policy");
9
+ const cache_1 = require("./cache");
10
+ exports.SESSION_START_AUDIT_BUDGET_MS = 150;
11
+ function rowPastThreshold(row, thresholds, now) {
12
+ if (row.kind === 'workspace') {
13
+ // A root directory timestamp can be shown when deeper scans were skipped.
14
+ // Only completed activity evidence may warn; a displayed date is not enough.
15
+ return row.orphan && row.idleSeconds !== null && Number.isFinite(row.idleSeconds)
16
+ && row.idleSeconds >= thresholds.orphan_workspace_warn_days * 86400;
17
+ }
18
+ if (row.idleSeconds === null || !Number.isFinite(row.idleSeconds))
19
+ return false;
20
+ return row.idleSeconds >= (row.kind === 'browser' ? thresholds.idle_browser_warn_minutes * 60 : thresholds.idle_session_warn_hours * 3600);
21
+ }
22
+ function warningForAudit(report, policy, now = Date.now()) {
23
+ const thresholds = (0, cache_1.auditThresholds)(policy);
24
+ const warnings = report.rows.filter(row => rowPastThreshold(row, thresholds, Date.parse(report.generatedAt)));
25
+ if (!warnings.length)
26
+ return null;
27
+ const sessions = warnings.filter(row => row.kind === 'session');
28
+ const gb = sessions.reduce((sum, row) => sum + row.rssBytes, 0) / 1_000_000_000;
29
+ return `${sessions.length} idle agent sessions holding ${gb.toFixed(1)} GB, run agentguard-burn ps`;
30
+ }
31
+ function sessionStartWarning(home, policy, now = Date.now()) {
32
+ const report = (0, cache_1.readAuditCache)(home, now);
33
+ return report ? warningForAudit(report, policy, now) : null;
34
+ }
35
+ async function sessionStartCheck(home, options) {
36
+ const started = performance.now(), now = options.now ?? Date.now(), policy = options.policy ?? (0, policy_1.loadPolicy)(home);
37
+ const cached = (0, cache_1.readAuditCache)(home, now);
38
+ if (cached)
39
+ return warningForAudit(cached, policy, now);
40
+ const budget = Math.max(1, Math.min(exports.SESSION_START_AUDIT_BUDGET_MS, options.budgetMs ?? exports.SESSION_START_AUDIT_BUDGET_MS));
41
+ const remaining = budget - (performance.now() - started);
42
+ if (remaining <= 0)
43
+ return null;
44
+ const controller = new AbortController();
45
+ let timer;
46
+ const timeout = new Promise(resolve => { timer = setTimeout(() => { controller.abort(); resolve(null); }, remaining); });
47
+ try {
48
+ const report = await Promise.race([Promise.resolve().then(() => options.collect({ signal: controller.signal, thresholds: (0, cache_1.auditThresholds)(policy) })), timeout]);
49
+ if (!report || controller.signal.aborted || performance.now() - started >= budget)
50
+ return null;
51
+ (0, cache_1.writeAuditCache)(home, report);
52
+ return performance.now() - started < budget ? warningForAudit(report, policy, now) : null;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ finally {
58
+ clearTimeout(timer);
59
+ controller.abort();
60
+ }
61
+ }
@@ -0,0 +1,25 @@
1
+ import type { ProcessSnapshot } from './types';
2
+ export interface CommandResult {
3
+ stdout: string;
4
+ stderr?: string;
5
+ code: number;
6
+ }
7
+ export interface CommandOptions {
8
+ cwd?: string;
9
+ timeoutMs?: number;
10
+ signal?: AbortSignal;
11
+ maxBuffer?: number;
12
+ }
13
+ export type CommandRunner = (command: string, args: string[], options?: CommandOptions) => Promise<CommandResult>;
14
+ export declare const runLocal: CommandRunner;
15
+ export declare function elapsedSeconds(text: string): number | null;
16
+ /** lstart is retained verbatim as an OS identity, never reconstructed from elapsed time. */
17
+ export declare function parsePs(text: string): ProcessSnapshot[];
18
+ export declare function parseLsofCwds(text: string): Map<number, string>;
19
+ export declare function parseWorktrees(text: string): string[];
20
+ export declare function parseSwap(text: string, platform: string): number | null;
21
+ export declare function parseProcStat(text: string, command: string, bootId: string, ticks: number, now: number, uptime: number): ProcessSnapshot | null;
22
+ /** Linux fallback reads proc metadata only. cmdline is process identity, never transcript content. */
23
+ export declare function procProcesses(root: string, runner: CommandRunner, signal: AbortSignal | undefined, limit: number, skipped: string[]): Promise<ProcessSnapshot[]>;
24
+ /** CoreGraphics reads local window metadata without Apple Events or accessibility control. */
25
+ export declare function browserWindows(pids: number[], runner: CommandRunner, options: CommandOptions): Promise<Map<number, number> | null>;