agentbox-flight-recorder 0.2.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/src/parse.js ADDED
@@ -0,0 +1,265 @@
1
+ 'use strict';
2
+ /**
3
+ * agentbox — parse.js
4
+ * Zero-magic heuristics that classify agent output lines and build a
5
+ * session summary. Bring-your-own-parser later; these rules ship on by
6
+ * default and intentionally err on the side of "plain".
7
+ */
8
+
9
+ // ANSI escape sequences (colors etc.) — stripped before classification
10
+ const ANSI_RE = /\x1b(?:\[[?0-9;:><]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)|[P^_].*?\x1b\\)/g;
11
+
12
+ function stripAnsi(s) {
13
+ return String(s || '').replace(ANSI_RE, '');
14
+ }
15
+
16
+ // A shell-ish command at line start (with or without $ / > prompt)
17
+ const CMD_RE = /^\s*(?:\$\s*|>\s*)?((?:sudo\s+)?(?:npm|npx|pnpm|yarn|bun|pip3?|python3?|node|deno|git|cargo|go|make|cmake|docker|kubectl|helm|brew|apt(?:-get)?|curl|wget|ssh|scp|rsync|terraform|aws|gcloud|gh|pytest|rails|ls|cp|mv|rm|mkdir|touch|chmod|chown|cat|sed|awk|grep)\b.+)$/i;
18
+
19
+ // Explicit tool-call convention: [TOOL] name("args") — agents can adopt it
20
+ const TOOL_RE = /^\[(?:TOOL|tool|Tool)\]\s*(.+)$/;
21
+
22
+ // File writes/edits/deletes mentioned in prose or tool output
23
+ const FILEOP_RE = /\b(wrote|created|edited|deleted|removed|modified|renamed|overwrote)\b\s+(?:the\s+)?(?:file\s+)?([\w./~\\-]+\.[A-Za-z0-9]{1,10})/i;
24
+ const FILE_GROUP_RE = /\b(wrote|created|edited|deleted|removed|modified|renamed|overwrote)\b\s+\d+\s+files?\b/i;
25
+ const FILE_GROUP_ITEM_RE = /[└├]\s*([\w./~\\-]+\.[A-Za-z0-9]{1,10})\s*\(/;
26
+
27
+ // File ops embedded in tool calls, e.g. write(src/deploy.sh — 42 lines)
28
+ const TOOL_FILE_RE = /\b(write|edit|create|delete|remove|overwrite|patch|update)\s*\(\s*[\"']?([\w./~\\-]+\.[A-Za-z0-9]{1,10})\b/i;
29
+ const VERB_MAP = { write: 'wrote', edit: 'edited', patch: 'edited', update: 'edited', create: 'created', delete: 'deleted', remove: 'removed', overwrite: 'overwrote' };
30
+
31
+ // Structured tool names (from the hook/MCP adapters) → file op, for receipts
32
+ const TOOLNAME_OP_MAP = {
33
+ Write: 'wrote', Edit: 'edited', MultiEdit: 'edited', NotebookEdit: 'edited',
34
+ Delete: 'removed', Remove: 'removed', Move: 'renamed', Rename: 'renamed',
35
+ };
36
+ function opForToolName(name) {
37
+ return TOOLNAME_OP_MAP[name] || 'touched';
38
+ }
39
+
40
+ /**
41
+ * Short, human preview of a tool input: known primary fields read better
42
+ * than raw JSON. Bash({command:'ls'}) → 'ls'; Write({file_path}) → the path.
43
+ */
44
+ function inputPreview(input, cap = 120) {
45
+ if (input == null) return '';
46
+ if (typeof input === 'string') return input.slice(0, cap);
47
+ if (typeof input === 'object') {
48
+ const primary = input.command || input.file_path || input.notebook_path || input.path
49
+ || input.url || input.query || input.pattern || input.text;
50
+ if (primary != null) return String(primary).slice(0, cap);
51
+ }
52
+ try { return JSON.stringify(input).slice(0, cap); } catch { return String(input).slice(0, cap); }
53
+ }
54
+
55
+ /** One-line label for a structured tool call: Bash(git status --short) */
56
+ function toolCallLabel(name, input) {
57
+ const preview = inputPreview(input, 120);
58
+ return `${name || 'tool'}${preview ? `(${preview})` : ''}`.slice(0, 200);
59
+ }
60
+
61
+ // URLs hit (network activity)
62
+ const URL_RE = /https?:\/\/[^\s"'<>)\]]+/;
63
+
64
+ /** Classify one line of output. Priority: tool > cmd > file > net > plain. */
65
+ function classifyLine(line) {
66
+ const clean = stripAnsi(line);
67
+ if (!clean || !clean.trim()) return { kind: 'plain' };
68
+ const tool = clean.match(TOOL_RE);
69
+ if (tool) {
70
+ return { kind: 'tool', detail: tool[1].trim().slice(0, 200) };
71
+ }
72
+ const cmd = clean.match(CMD_RE);
73
+ if (cmd) {
74
+ return { kind: 'cmd', detail: cmd[1].trim().slice(0, 200) };
75
+ }
76
+ const file = clean.match(FILEOP_RE);
77
+ if (file) {
78
+ return { kind: 'file', detail: { op: file[1].toLowerCase(), path: file[2] } };
79
+ }
80
+ const url = clean.match(URL_RE);
81
+ if (url) {
82
+ return { kind: 'net', detail: url[0].slice(0, 200) };
83
+ }
84
+ return { kind: 'plain' };
85
+ }
86
+
87
+ /**
88
+ * Build a human summary from a verified event list.
89
+ * Event types produced by wrap():
90
+ * meta {cmd, argv, cwd, user, host, platform, node, agentbox, name, adapter?}
91
+ * out {stream: 'stdout'|'stderr', kind, text?|detail?}
92
+ * in {text}
93
+ * exit {code, durationMs}
94
+ * signal{signal}
95
+ * Event types produced by the adapters (claude hooks / mcp wire tap):
96
+ * tool_call {phase:'start'|'end', name, input?, status?, durationMs?, source, id?}
97
+ * prompt {text, source}
98
+ * notification {message}
99
+ * turn_end {}
100
+ * mcp_msg {dir:'C2S'|'S2C', method, id, preview}
101
+ * note {message}
102
+ */
103
+ function summarize(events) {
104
+ const meta = events.find((e) => e.type === 'meta');
105
+ const exit = [...events].reverse().find((e) => e.type === 'exit');
106
+ const startT = events.length ? events[0].t : 0;
107
+ const endT = events.length ? events[events.length - 1].t : 0;
108
+
109
+ const stats = {
110
+ name: (meta && meta.data && meta.data.name) || 'session',
111
+ command: meta ? meta.data.cmd : '?',
112
+ cwd: meta ? meta.data.cwd : '?',
113
+ user: meta ? meta.data.user : '?',
114
+ adapter: (meta && meta.data && meta.data.adapter) || 'wrap',
115
+ started: startT ? new Date(startT) : null,
116
+ durationMs: exit && exit.data && exit.data.durationMs != null ? exit.data.durationMs : Math.max(0, endT - startT),
117
+ exitCode: exit && exit.data ? exit.data.code : null,
118
+ events: events.length,
119
+ byType: {},
120
+ byKind: {},
121
+ outputBytes: 0,
122
+ stderrLines: 0,
123
+ commands: [],
124
+ tools: [],
125
+ files: [], // { op, path }
126
+ urls: [],
127
+ stdinEvents: 0,
128
+ humansConsulted: 0,
129
+ signals: [],
130
+ prompts: [], // structured prompts (hook adapter)
131
+ turns: 0, // agent turn boundaries (Stop hooks)
132
+ notifications: 0,
133
+ mcpMessages: 0,
134
+ toolCallStarts: 0,
135
+ toolCallEnds: 0,
136
+ toolErrors: 0,
137
+ };
138
+ const endOnlyNames = [];
139
+ let groupedFileOp = null;
140
+ let groupedFileUntil = 0;
141
+
142
+ for (const ev of events) {
143
+ stats.byType[ev.type] = (stats.byType[ev.type] || 0) + 1;
144
+ if (ev.type === 'out') {
145
+ const d = ev.data || {};
146
+ const kind = d.kind || 'plain';
147
+ const clean = stripAnsi(d.text || '');
148
+ stats.byKind[kind] = (stats.byKind[kind] || 0) + 1;
149
+ if (d.stream === 'stderr') stats.stderrLines += 1;
150
+ stats.outputBytes += Buffer.byteLength(String(d.text != null ? d.text : (d.detail || '')), 'utf8');
151
+ if (kind === 'cmd') {
152
+ const m = clean.match(CMD_RE);
153
+ stats.commands.push(m ? m[1].trim() : clean);
154
+ }
155
+ if (kind === 'tool') {
156
+ const m = clean.match(TOOL_RE);
157
+ const toolDetail = m ? m[1].trim() : (d.detail || clean);
158
+ stats.tools.push(toolDetail);
159
+ // file ops hidden inside tool calls: write(src/deploy.sh — …)
160
+ const tf = toolDetail.match(TOOL_FILE_RE);
161
+ if (tf) stats.files.push({ op: VERB_MAP[tf[1].toLowerCase()] || tf[1].toLowerCase(), path: tf[2] });
162
+ }
163
+ if (kind === 'file') {
164
+ const detail = d.detail && d.detail.path ? d.detail : classifyLine(d.text || '').detail;
165
+ if (detail && detail.path) stats.files.push({ op: detail.op, path: detail.path });
166
+ }
167
+ const group = clean.match(FILE_GROUP_RE);
168
+ if (group) {
169
+ groupedFileOp = group[1].toLowerCase();
170
+ groupedFileUntil = ev.t + 10000;
171
+ } else if (ev.t > groupedFileUntil) {
172
+ groupedFileOp = null;
173
+ }
174
+ const groupItem = groupedFileOp && clean.match(FILE_GROUP_ITEM_RE);
175
+ if (groupItem) stats.files.push({ op: groupedFileOp, path: groupItem[1] });
176
+ // URLs observed anywhere in output (stripped of ANSI)
177
+ const scan = stripAnsi([typeof d.detail === 'string' ? d.detail : '', typeof d.text === 'string' ? d.text : ''].join(' '));
178
+ const u = scan.match(URL_RE);
179
+ if (u) stats.urls.push(u[0]);
180
+ } else if (ev.type === 'in') {
181
+ stats.stdinEvents += 1;
182
+ } else if (ev.type === 'signal') {
183
+ stats.signals.push(ev.data && ev.data.signal);
184
+ } else if (ev.type === 'tool_call') {
185
+ // structured tool calls from the claude-code hook / mcp adapters
186
+ const d = ev.data || {};
187
+ if (d.phase === 'end') {
188
+ stats.toolCallEnds += 1;
189
+ if (d.status === 'error') stats.toolErrors += 1;
190
+ endOnlyNames.push(String(d.name || 'tool'));
191
+ } else {
192
+ stats.toolCallStarts += 1;
193
+ stats.tools.push(toolCallLabel(d.name, d.input));
194
+ if (d.input && typeof d.input === 'object') {
195
+ const p = d.input.file_path || d.input.notebook_path || d.input.path;
196
+ if (p) stats.files.push({ op: opForToolName(String(d.name || '')), path: String(p) });
197
+ if (d.input.command) stats.commands.push(String(d.input.command).slice(0, 200));
198
+ if (d.input.url) stats.urls.push(String(d.input.url).slice(0, 200));
199
+ }
200
+ }
201
+ } else if (ev.type === 'prompt') {
202
+ stats.stdinEvents += 1;
203
+ if (ev.data && ev.data.text) stats.prompts.push(String(ev.data.text).slice(0, 200));
204
+ } else if (ev.type === 'turn_end') {
205
+ stats.turns += 1;
206
+ } else if (ev.type === 'notification') {
207
+ stats.notifications += 1;
208
+ } else if (ev.type === 'mcp_msg') {
209
+ stats.mcpMessages += 1;
210
+ }
211
+ }
212
+
213
+ // session joined mid-flight (hooks added after start): no PreToolUse starts,
214
+ // only PostToolUse ends — still name the tools from the ends
215
+ if (stats.toolCallStarts === 0 && endOnlyNames.length) {
216
+ stats.tools.push(...endOnlyNames.map((n) => `${n} ()`));
217
+ }
218
+
219
+ stats.humansConsulted = stats.stdinEvents > 0 || stats.prompts.length > 0 ? 1 : 0;
220
+
221
+ // de-dup helpers
222
+ const uniq = (arr) => [...new Set(arr)];
223
+ stats.commands = uniq(stats.commands);
224
+ stats.urls = uniq(stats.urls);
225
+
226
+ // collapse file ops per path
227
+ const fileMap = new Map();
228
+ for (const f of stats.files) {
229
+ const cur = fileMap.get(f.path) || new Set();
230
+ cur.add(f.op);
231
+ fileMap.set(f.path, cur);
232
+ }
233
+ stats.files = [...fileMap.entries()].map(([p, ops]) => ({ path: p, ops: [...ops] }));
234
+
235
+ return stats;
236
+ }
237
+
238
+ function fmtDuration(ms) {
239
+ if (ms == null) return '?';
240
+ const s = Math.round(ms / 100) / 10;
241
+ if (s < 60) return `${s}s`;
242
+ const m = Math.floor(s / 60);
243
+ const rem = Math.round(s % 60);
244
+ return `${m}m ${rem}s`;
245
+ }
246
+
247
+ function fmtBytes(b) {
248
+ if (b < 1024) return `${b} B`;
249
+ if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} kB`;
250
+ return `${(b / (1024 * 1024)).toFixed(1)} MB`;
251
+ }
252
+
253
+ /** A deadpan one-liner verdict, receipt style. */
254
+ function verdict(stats) {
255
+ const deleted = stats.files.filter((f) => f.ops.some((o) => o === 'deleted' || o === 'removed'));
256
+ if (stats.signals.includes('SIGINT')) return 'flight interrupted mid-air. black box recovered.';
257
+ if (stats.toolErrors > 0) return `${stats.toolErrors} tool call${stats.toolErrors > 1 ? 's' : ''} went sideways. tape tells you which.`;
258
+ if (stats.exitCode !== 0 && stats.exitCode != null) return 'agentbox received. wreckage mapped below.';
259
+ if (deleted.length > 0) return `${deleted.length} file${deleted.length > 1 ? 's' : ''} deleted. hope ${deleted.length > 1 ? 'they were' : 'it was'} not load-bearing.`;
260
+ if (stats.humansConsulted === 0 && stats.events > 20) return 'smooth flight. zero supervision. as requested.';
261
+ if (stats.exitCode === 0) return 'uneventful flight. the best kind.';
262
+ return 'black box recovered. details below.';
263
+ }
264
+
265
+ module.exports = { classifyLine, summarize, fmtDuration, fmtBytes, verdict, stripAnsi, toolCallLabel, inputPreview, opForToolName, CMD_RE, TOOL_RE, FILEOP_RE, FILE_GROUP_RE, FILE_GROUP_ITEM_RE, TOOL_FILE_RE, URL_RE };
package/src/receipt.js ADDED
@@ -0,0 +1,176 @@
1
+ 'use strict';
2
+ /**
3
+ * agentbox — receipt.js
4
+ * The one-page flight receipt: what happened, in numbers a human can scan
5
+ * in 5 seconds. Formats: text (POS-receipt aesthetic), markdown (PRs),
6
+ * json (machines).
7
+ */
8
+ const { verifyChain } = require('./chain');
9
+ const { summarize, fmtDuration, fmtBytes, verdict } = require('./parse');
10
+
11
+ const CYAN = '\x1b[36m';
12
+ const BOLD = '\x1b[1m';
13
+ const DIM = '\x1b[2m';
14
+ const GREEN = '\x1b[32m';
15
+ const RED = '\x1b[31m';
16
+ const RESET = '\x1b[0m';
17
+ const ORANGE = '\x1b[38;5;208m';
18
+
19
+ const W = 52; // inner width
20
+
21
+ function safeText(s) {
22
+ return String(s == null ? '' : s)
23
+ .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '')
24
+ .replace(/\x1b\[[?0-9;:><]*[ -/]*[@-~]/g, '')
25
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, '');
26
+ }
27
+
28
+ function mdEsc(s) {
29
+ return safeText(s).replace(/\\/g, '\\\\').replace(/([`*_[\]<>|])/g, '\\$1').replace(/\r?\n/g, ' ');
30
+ }
31
+
32
+ function visibleLen(s) {
33
+ return String(s || '').replace(/\x1b(?:\[[0-9;]*[A-HJKSTfmnsu]|\][^\x07]*(?:\x07|\x1b\\)|[P^_].*?\x1b\\)/g, '').length;
34
+ }
35
+
36
+ function pad(s, w) {
37
+ s = String(s);
38
+ const len = visibleLen(s);
39
+ if (len > w) return pad(s.slice(0, Math.max(0, w - 1)), w) + '…';
40
+ return s + ' '.repeat(Math.max(0, w - len));
41
+ }
42
+ function row(label, value) {
43
+ return `│ ${pad(safeText(label), 24)}${pad(safeText(value), W - 27)}│`;
44
+ }
45
+ /** multi-row value: wraps long values across continuation rows */
46
+ function rowsFor(label, value) {
47
+ const out = [];
48
+ const chunks = String(value == null ? '' : value).match(/.{1,24}(\s|$)|\S+/g) || [''];
49
+ out.push(row(label, chunks[0] || ''));
50
+ for (let i = 1; i < chunks.length; i++) out.push(row(i === chunks.length - 1 ? '' : ' ↳', chunks[i]));
51
+ return out;
52
+ }
53
+ function sep() {
54
+ return `├${'─'.repeat(W - 2)}┤`;
55
+ }
56
+
57
+ function textReceipt(stats, chainOk) {
58
+ const lines = [];
59
+ const title = '⬢ A G E N T B O X R E C E I P T';
60
+ lines.push('');
61
+ lines.push(`${CYAN}${BOLD}${title.padStart(Math.floor((W + title.length) / 2))}${RESET}`);
62
+ lines.push(`┌${'─'.repeat(W - 2)}┐`);
63
+ lines.push(...rowsFor('session', stats.name));
64
+ lines.push(...rowsFor('command', stats.command));
65
+ if (stats.adapter && stats.adapter !== 'wrap') {
66
+ lines.push(row('recorded via', stats.adapter === 'claude-code' ? 'claude code hooks' : stats.adapter === 'mcp' ? 'mcp wire tap' : stats.adapter));
67
+ }
68
+ lines.push(row('started', stats.started ? stats.started.toISOString().replace('T', ' ').slice(0, 19) : '?'));
69
+ lines.push(row('duration', fmtDuration(stats.durationMs)));
70
+ lines.push(row('exit code', stats.exitCode === 0 ? '0 (clean landing)' : stats.exitCode == null ? '?' : `${stats.exitCode} (see tape)`));
71
+ lines.push(sep());
72
+
73
+ const toolN = stats.tools.length;
74
+ const cmdN = stats.commands.length;
75
+ lines.push(row('tool calls', String(toolN)));
76
+ if (stats.toolErrors) lines.push(row('tool errors', `${RED}${String(stats.toolErrors)}${RESET}`));
77
+ if (stats.turns) lines.push(row('agent turns', String(stats.turns)));
78
+ for (const t of stats.tools.slice(0, 4)) lines.push(row(` · ${t.slice(0, 20)}`, ''));
79
+ if (toolN > 4) lines.push(row(` … +${toolN - 4} more`, ''));
80
+ lines.push(row('shell commands', String(cmdN)));
81
+ for (const c of stats.commands.slice(0, 3)) lines.push(row(` · ${c.slice(0, 20)}`, ''));
82
+ if (cmdN > 3) lines.push(row(` … +${cmdN - 3} more`, ''));
83
+
84
+ const touched = stats.files.length;
85
+ const writes = stats.files.filter((f) => f.ops.some((o) => /wrote|created|overwrote/i.test(o))).length;
86
+ const edits = stats.files.filter((f) => f.ops.some((o) => /edited|modified|renamed/i.test(o))).length;
87
+ const dels = stats.files.filter((f) => f.ops.some((o) => /deleted|removed/i.test(o))).length;
88
+ lines.push(row('files touched', `${touched}${writes ? ` · ${writes} written` : ''}${edits ? ` · ${edits} edited` : ''}${dels ? ` · ${RED}${dels} deleted${RESET}` : ''}`));
89
+ for (const f of stats.files.slice(0, 4)) lines.push(row(` · ${f.path.slice(0, 20)}`, f.ops.join(', ').slice(0, 20)));
90
+ if (touched > 4) lines.push(row(` … +${touched - 4} more`, ''));
91
+
92
+ lines.push(row('urls hit', String(stats.urls.length)));
93
+ lines.push(row('output volume', `${fmtBytes(stats.outputBytes)} across ${stats.byType.out || 0} lines`));
94
+ lines.push(row('stderr lines', String(stats.stderrLines)));
95
+ lines.push(row('humans consulted', stats.humansConsulted ? '1 (kept in the loop)' : '0 (unsupervised flight)'));
96
+ lines.push(sep());
97
+ lines.push(row('events recorded', String(stats.events)));
98
+ lines.push(row('tamper chain', chainOk ? `${GREEN}sha256 · intact${RESET}` : `${RED}BROKEN${RESET}`));
99
+ lines.push(`└${'─'.repeat(W - 2)}┘`);
100
+ lines.push(`${ORANGE}${verdict(stats)}${RESET}`);
101
+ lines.push('');
102
+ return lines.join('\n');
103
+ }
104
+
105
+ function markdownReceipt(stats, chainOk) {
106
+ const L = [];
107
+ L.push(`## ⬢ AGENTBOX flight receipt — \`${mdEsc(stats.name)}\``);
108
+ L.push('');
109
+ L.push(`> ${mdEsc(verdict(stats))}`);
110
+ L.push('');
111
+ L.push('| | |');
112
+ L.push('|---|---|');
113
+ L.push(`| **command** | \`${mdEsc(stats.command)}\` |`);
114
+ L.push(`| **started** | ${stats.started ? stats.started.toISOString() : '?'} |`);
115
+ L.push(`| **duration** | ${fmtDuration(stats.durationMs)} |`);
116
+ L.push(`| **exit code** | ${stats.exitCode == null ? '?' : stats.exitCode} |`);
117
+ L.push(`| **tool calls** | ${stats.tools.length} |`);
118
+ L.push(`| **shell commands** | ${stats.commands.length} |`);
119
+ L.push(`| **files touched** | ${stats.files.length}${stats.files.some((f) => f.ops.some((o) => /deleted|removed/i.test(o))) ? ' ⚠️ incl. deletions' : ''} |`);
120
+ L.push(`| **urls hit** | ${stats.urls.length} |`);
121
+ L.push(`| **humans consulted** | ${stats.humansConsulted} |`);
122
+ L.push(`| **events** | ${stats.events} (${fmtBytes(stats.outputBytes)} of output) |`);
123
+ L.push(`| **tamper chain** | ${chainOk ? '✅ sha256 intact' : '❌ BROKEN'} |`);
124
+ if (stats.humansConsulted) {
125
+ L.push('');
126
+ L.push('<details><summary>Prompts (the human did say things)</summary>');
127
+ L.push('');
128
+ for (const p of stats.prompts.slice(0, 10)) L.push(`- “${mdEsc(p).slice(0, 120)}”`);
129
+ L.push('');
130
+ L.push('</details>');
131
+ }
132
+ if (stats.files.length) {
133
+ L.push('');
134
+ L.push('<details><summary>Files touched</summary>');
135
+ L.push('');
136
+ for (const f of stats.files.slice(0, 20)) L.push(`- \`${mdEsc(f.path)}\` — ${mdEsc(f.ops.join(', '))}`);
137
+ L.push('');
138
+ L.push('</details>');
139
+ }
140
+ if (stats.tools.length) {
141
+ L.push('');
142
+ L.push('<details><summary>Tool calls</summary>');
143
+ L.push('');
144
+ for (const t of stats.tools.slice(0, 20)) L.push(`- \`${mdEsc(t)}\``);
145
+ L.push('');
146
+ L.push('</details>');
147
+ }
148
+ return L.join('\n');
149
+ }
150
+
151
+ function jsonReceipt(stats, chainOk, file) {
152
+ return JSON.stringify({ file, chainOk, ...stats, started: stats.started ? stats.started.toISOString() : null }, null, 2);
153
+ }
154
+
155
+ /**
156
+ * Print/render a receipt for a session file.
157
+ * opts: { format: 'text'|'markdown'|'json', force }
158
+ * Returns { ok, stats, chainOk }.
159
+ */
160
+ function receipt(file, opts = {}) {
161
+ const res = verifyChain(file);
162
+ const chainOk = res.ok && res.complete;
163
+ if ((!res.ok || !res.complete) && !opts.force) {
164
+ process.stderr.write(`\x1b[31m⬢ agentbox: chain verification FAILED — ${res.reason || 'session is incomplete (missing exit event)'}\x1b[0m\n`);
165
+ process.exitCode = 1;
166
+ return { ok: false, chainOk, stats: null };
167
+ }
168
+ const stats = summarize(res.events);
169
+ const format = opts.format || 'text';
170
+ if (format === 'json') process.stdout.write(jsonReceipt(stats, chainOk, file) + '\n');
171
+ else if (format === 'markdown' || format === 'md') process.stdout.write(markdownReceipt(stats, chainOk) + '\n');
172
+ else process.stdout.write(textReceipt(stats, chainOk) + '\n');
173
+ return { ok: true, chainOk, stats };
174
+ }
175
+
176
+ module.exports = { receipt, textReceipt, markdownReceipt };
package/src/redact.js ADDED
@@ -0,0 +1,231 @@
1
+ 'use strict';
2
+ /**
3
+ * agentbox — redact.js
4
+ * Default-on secret scrubbing for the flight tape.
5
+ *
6
+ * Every string that is about to be written to a session file passes through
7
+ * here. The hash chain commits to the *redacted* form, so the tape never
8
+ * contains the original secret and is still tamper-evident.
9
+ *
10
+ * Disable: AGENTBOX_REDACT=0
11
+ * Extra rules: AGENTBOX_REDACT_EXTRA=pattern1|pattern2 (JS regex sources)
12
+ * Project cfg: .agentbox/config.json → { "redact": true, "redactPatterns": ["…"] }
13
+ *
14
+ * Design rules:
15
+ * 1. Deterministic — same input always yields the same redacted output
16
+ * (required for a stable hash chain).
17
+ * 2. Conservative — prefer false positives over leaking a real secret.
18
+ * 3. Shape-preserving — objects stay objects; only string leaves change.
19
+ * 4. Never throw — a broken redactor must not abort a recording flight.
20
+ */
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+
25
+ const PLACEHOLDER = '[REDACTED]';
26
+ const SECRET_KEY_RE = /^(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth(?:orization)?|auth[_-]?token|session[_-]?key)$/i;
27
+
28
+ /**
29
+ * Built-in patterns. Order matters only for readability; each match is
30
+ * replaced independently. Keep sources free of the /g flag — we add it.
31
+ */
32
+ const BUILTIN = [
33
+ // OpenAI / compatible
34
+ { name: 'openai', re: /\bsk-[A-Za-z0-9]{20,}\b/g },
35
+ // Anthropic
36
+ { name: 'anthropic', re: /\bsk-ant-[A-Za-z0-9\-_]{20,}\b/g },
37
+ // GitHub tokens
38
+ { name: 'github', re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b/g },
39
+ // AWS access key id
40
+ { name: 'aws-key', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
41
+ // AWS secret access key (40 base64-ish chars next to aws/secret context is hard;
42
+ // catch the common "SecretAccessKey=…" / "aws_secret_access_key=…" form instead)
43
+ { name: 'aws-secret', re: /(?:aws_?secret_?access_?key|secretAccessKey)\s*[=:]\s*["']?[A-Za-z0-9/+=]{35,}["']?/gi },
44
+ // Slack
45
+ { name: 'slack', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
46
+ // Stripe
47
+ { name: 'stripe', re: /\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g },
48
+ // Google API key
49
+ { name: 'google', re: /\bAIza[0-9A-Za-z\-_]{20,}\b/g },
50
+ // Twilio
51
+ { name: 'twilio', re: /\bSK[0-9a-fA-F]{32}\b/g },
52
+ // Bearer / Authorization header values
53
+ { name: 'bearer', re: /(?:Bearer|Authorization)\s*[:=]?\s*["']?[A-Za-z0-9\-._~+/]+=*["']?/gi },
54
+ // JWT (three base64url segments)
55
+ { name: 'jwt', re: /\beyJ[A-Za-z0-9\-_]{10,}\.[A-Za-z0-9\-_]{10,}\.[A-Za-z0-9\-_]{10,}\b/g },
56
+ // PEM private keys (single-line or the header alone is enough signal)
57
+ { name: 'pem', re: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g },
58
+ { name: 'pem-hdr', re: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g },
59
+ // Generic KEY=value / "key": "value" for secret-ish names
60
+ // (password, secret, token, apikey, api_key, access_key, private_key, client_secret, …)
61
+ {
62
+ name: 'kv-secret',
63
+ re: /(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|session[_-]?key)\s*[=:]\s*["']?[^\s"'\\]{6,}["']?/gi,
64
+ },
65
+ // JSON-style "password": "…"
66
+ {
67
+ name: 'json-secret',
68
+ re: /"(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token)"\s*:\s*"[^"]{4,}"/gi,
69
+ },
70
+ // Connection strings with embedded credentials
71
+ {
72
+ name: 'connstr',
73
+ re: /\b(?:postgres|postgresql|mysql|mongodb|redis|amqp|https?):\/\/[^:\s]+:[^@\s]+@[^\s]+/gi,
74
+ },
75
+ ];
76
+
77
+ let _cache = null; // { enabled, patterns: [{name, re}] }
78
+
79
+ function loadConfig(cwd) {
80
+ const root = cwd || process.cwd();
81
+ try {
82
+ const cfgPath = path.join(root, '.agentbox', 'config.json');
83
+ if (fs.existsSync(cfgPath)) {
84
+ return JSON.parse(fs.readFileSync(cfgPath, 'utf8')) || {};
85
+ }
86
+ } catch { /* ignore malformed config */ }
87
+ return {};
88
+ }
89
+
90
+ /**
91
+ * Resolve enabled flag + pattern list. Result is cached for the process
92
+ * lifetime (config is expected to be stable during a session).
93
+ */
94
+ function resolve(cwd) {
95
+ if (_cache) return _cache;
96
+
97
+ const env = process.env.AGENTBOX_REDACT;
98
+ const cfg = loadConfig(cwd);
99
+ // explicit false / "0" / "off" / "false" disables; everything else is on
100
+ let enabled = true;
101
+ if (env != null && /^(0|false|off|no)$/i.test(String(env).trim())) enabled = false;
102
+ if (cfg.redact === false) enabled = false;
103
+ if (cfg.redact === true) enabled = true;
104
+ // AGENTBOX_REDACT=1 forces on even if config said off
105
+ if (env != null && /^(1|true|on|yes)$/i.test(String(env).trim())) enabled = true;
106
+
107
+ const patterns = BUILTIN.map((p) => ({ name: p.name, re: cloneRe(p.re) }));
108
+
109
+ // project-level extra patterns
110
+ const extra = [];
111
+ if (Array.isArray(cfg.redactPatterns)) extra.push(...cfg.redactPatterns);
112
+ if (process.env.AGENTBOX_REDACT_EXTRA) {
113
+ extra.push(...String(process.env.AGENTBOX_REDACT_EXTRA).split('|').map((s) => s.trim()).filter(Boolean));
114
+ }
115
+ for (const src of extra) {
116
+ try {
117
+ patterns.push({ name: 'custom', re: new RegExp(src, 'gi') });
118
+ } catch { /* skip invalid user regex */ }
119
+ }
120
+
121
+ _cache = { enabled, patterns };
122
+ return _cache;
123
+ }
124
+
125
+ /** Force-reload config (tests only). */
126
+ function resetCache() {
127
+ _cache = null;
128
+ }
129
+
130
+ function cloneRe(re) {
131
+ return new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g');
132
+ }
133
+
134
+ /**
135
+ * Redact a single string. Returns { text, count } where count is the number
136
+ * of pattern hits (not characters).
137
+ */
138
+ function redactString(s, opts = {}) {
139
+ if (s == null) return { text: s, count: 0 };
140
+ const str = String(s);
141
+ if (!str) return { text: str, count: 0 };
142
+
143
+ const { enabled, patterns } = resolve(opts.cwd);
144
+ if (!enabled) return { text: str, count: 0 };
145
+
146
+ let text = str;
147
+ let count = 0;
148
+ for (const p of patterns) {
149
+ // reset lastIndex — we may reuse the same RegExp instance
150
+ p.re.lastIndex = 0;
151
+ if (!p.re.test(text)) continue;
152
+ p.re.lastIndex = 0;
153
+ text = text.replace(p.re, () => {
154
+ count += 1;
155
+ return PLACEHOLDER;
156
+ });
157
+ }
158
+ return { text, count };
159
+ }
160
+
161
+ /**
162
+ * Deep-redact any JSON-serializable value.
163
+ * - strings → redacted strings
164
+ * - arrays / plain objects → walked
165
+ * - numbers, bools, null → unchanged
166
+ * Never mutates the input.
167
+ */
168
+ function redactDeep(value, opts = {}) {
169
+ const state = { count: 0 };
170
+ const out = walk(value, state, opts, 0);
171
+ return { value: out, count: state.count };
172
+ }
173
+
174
+ function walk(v, state, opts, depth, key) {
175
+ if (depth > 30) return v; // defensive against cycles / absurd nesting
176
+ if (v == null) return v;
177
+ const t = typeof v;
178
+ if (t === 'string') {
179
+ if (key && SECRET_KEY_RE.test(key)) { state.count += 1; return PLACEHOLDER; }
180
+ const r = redactString(v, opts);
181
+ state.count += r.count;
182
+ return r.text;
183
+ }
184
+ if (t === 'number' || t === 'boolean') return v;
185
+ if (Array.isArray(v)) {
186
+ return v.map((item) => walk(item, state, opts, depth + 1));
187
+ }
188
+ if (t === 'object') {
189
+ // plain object only — skip Buffer, Date, etc.
190
+ if (Object.getPrototypeOf(v) !== Object.prototype && Object.getPrototypeOf(v) !== null) {
191
+ return v;
192
+ }
193
+ const out = {};
194
+ for (const k of Object.keys(v)) {
195
+ // also redact secret-looking *keys*' values more aggressively is already
196
+ // handled by the kv/json patterns on stringified forms; walk the value.
197
+ if (SECRET_KEY_RE.test(k) && v[k] != null) {
198
+ out[k] = PLACEHOLDER;
199
+ state.count += 1;
200
+ } else out[k] = walk(v[k], state, opts, depth + 1, k);
201
+ }
202
+ return out;
203
+ }
204
+ return v;
205
+ }
206
+
207
+ /**
208
+ * Convenience used by the chain writer: redact event data in place-of
209
+ * and return the scrubbed copy plus a hit count.
210
+ */
211
+ function redactEventData(data, opts = {}) {
212
+ if (data == null) return { data, count: 0 };
213
+ if (typeof data !== 'object') {
214
+ const r = redactString(data, opts);
215
+ return { data: r.text, count: r.count };
216
+ }
217
+ const r = redactDeep(data, opts);
218
+ return { data: r.value, count: r.count };
219
+ }
220
+
221
+ module.exports = {
222
+ PLACEHOLDER,
223
+ BUILTIN,
224
+ redactString,
225
+ redactDeep,
226
+ redactEventData,
227
+ resolve,
228
+ resetCache,
229
+ loadConfig,
230
+ SECRET_KEY_RE,
231
+ };