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/LICENSE +21 -0
- package/README.md +306 -0
- package/action.yml +52 -0
- package/bin/agentbox.js +3 -0
- package/docs/index.html +308 -0
- package/examples/fake-agent.js +56 -0
- package/examples/fake-mcp-server.js +92 -0
- package/package.json +53 -0
- package/scripts/preflight.sh +324 -0
- package/src/adapters/claude.js +308 -0
- package/src/adapters/mcp.js +346 -0
- package/src/chain.js +286 -0
- package/src/cli.js +262 -0
- package/src/clip.js +196 -0
- package/src/parse.js +265 -0
- package/src/receipt.js +176 -0
- package/src/redact.js +231 -0
- package/src/replay.js +412 -0
- package/src/wrap.js +310 -0
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
|
+
};
|