agent-polygraph 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +232 -2
- package/bin/polygraph.js +274 -0
- package/package.json +41 -4
- package/src/analyze.js +621 -0
- package/src/ci.js +204 -0
- package/src/classify.js +506 -0
- package/src/demo.js +120 -0
- package/src/guard.js +240 -0
- package/src/jsonl.js +23 -0
- package/src/record.js +402 -0
- package/src/report/format.js +87 -0
- package/src/report/html.js +101 -0
- package/src/report/terminal.js +160 -0
- package/src/safe-config.js +85 -0
- package/src/scan.js +60 -0
- package/src/shell/record.sh +73 -0
- package/src/sources/claude.js +300 -0
- package/src/sources/codex.js +476 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// Claude Code transcripts: ~/.claude/projects/<project>/<session>.jsonl
|
|
2
|
+
// Subagent transcripts live in <session>/subagents/*.jsonl and are merged into the parent.
|
|
3
|
+
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import { readJsonl } from "../jsonl.js";
|
|
8
|
+
|
|
9
|
+
export const name = "claude";
|
|
10
|
+
|
|
11
|
+
export function defaultRoot() {
|
|
12
|
+
return path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"), "projects");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** List sessions as { id, files: [main, ...subagents], mtime }. */
|
|
16
|
+
export function discover(root = defaultRoot()) {
|
|
17
|
+
const out = [];
|
|
18
|
+
let projects = [];
|
|
19
|
+
try {
|
|
20
|
+
projects = fs.readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory());
|
|
21
|
+
} catch {
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
for (const proj of projects) {
|
|
25
|
+
const dir = path.join(root, proj.name);
|
|
26
|
+
for (const f of safeReaddir(dir)) {
|
|
27
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
28
|
+
const id = f.slice(0, -6);
|
|
29
|
+
const main = path.join(dir, f);
|
|
30
|
+
const subDir = path.join(dir, id, "subagents");
|
|
31
|
+
const subs = safeReaddir(subDir)
|
|
32
|
+
.filter((s) => s.endsWith(".jsonl"))
|
|
33
|
+
.map((s) => path.join(subDir, s));
|
|
34
|
+
out.push({ agent: name, id, files: [main, ...subs], mtime: mtime(main) });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Cheap prefilter before JSON.parse. Whitespace-tolerant: anonymizers and exporters often
|
|
41
|
+
// re-serialize transcripts as `"type": "user"`.
|
|
42
|
+
// Hook runs are recorded as attachments carrying the command, its output and exit code.
|
|
43
|
+
const WANTED = /"type"\s*:\s*"(?:user|assistant|hook_success|hook_non_blocking_error|hook_blocking_error)"/;
|
|
44
|
+
|
|
45
|
+
// Event model (shared with codex.js): a command's start (`cmd`) and what was learned about
|
|
46
|
+
// it (`result`) are separate events, placed where they were observed. Edits are only
|
|
47
|
+
// emitted once their tool result confirms them. Analysis relies on event order.
|
|
48
|
+
export async function parse(entry) {
|
|
49
|
+
const session = { agent: name, id: entry.id, file: entry.files[0], cwd: null, models: new Set(), versions: new Set(), events: [], start: null, end: null };
|
|
50
|
+
const stats = { lines: 0, kept: 0 };
|
|
51
|
+
let userCmd = null; // a command you typed with `!`, waiting for its output
|
|
52
|
+
const pendingCmds = new Map(); // tool_use id -> cmd event awaiting its result
|
|
53
|
+
const pendingEdits = new Map(); // tool_use id -> edit events awaiting confirmation
|
|
54
|
+
const background = new Map(); // tool_use id -> backgrounded cmd event
|
|
55
|
+
const outputFiles = new Map(); // background output file -> cmd event
|
|
56
|
+
const reads = new Map(); // Read tool_use id -> cmd event whose output file it reads
|
|
57
|
+
// seq breaks timestamp ties in the order things were requested or observed
|
|
58
|
+
let seq = 0;
|
|
59
|
+
const push = (ev) => {
|
|
60
|
+
ev.seq ??= seq++;
|
|
61
|
+
session.events.push(ev);
|
|
62
|
+
};
|
|
63
|
+
const result = (of, ts, fields) => push({ kind: "result", of, ts, ...fields });
|
|
64
|
+
|
|
65
|
+
for (const [i, file] of entry.files.entries()) {
|
|
66
|
+
const isSub = i > 0;
|
|
67
|
+
for await (const rec of readJsonl(file, (line) => WANTED.test(line), stats)) {
|
|
68
|
+
const ts = Date.parse(rec.timestamp) || null;
|
|
69
|
+
if (rec.version && !isSub) session.versions.add(rec.version);
|
|
70
|
+
if (ts) {
|
|
71
|
+
session.start = Math.min(session.start ?? ts, ts);
|
|
72
|
+
session.end = Math.max(session.end ?? ts, ts);
|
|
73
|
+
}
|
|
74
|
+
if (!session.cwd && rec.cwd && !isSub) session.cwd = rec.cwd;
|
|
75
|
+
if (rec.gitBranch && rec.gitBranch !== "HEAD" && !isSub) session.branch = rec.gitBranch;
|
|
76
|
+
const msg = rec.message || {};
|
|
77
|
+
const content = msg.content;
|
|
78
|
+
|
|
79
|
+
// a hook (PostToolUse, Stop, …) that ran a command: tests run by hooks count too
|
|
80
|
+
if (rec.type === "attachment") {
|
|
81
|
+
const a = rec.attachment || {};
|
|
82
|
+
if (a.command) {
|
|
83
|
+
const ev = { kind: "cmd", ts, cmd: String(a.command), sub: isSub, hook: a.hookEvent || true };
|
|
84
|
+
push(ev);
|
|
85
|
+
result(ev, ts, { exitCode: typeof a.exitCode === "number" ? a.exitCode : null, isError: null, output: (String(a.stdout ?? "") + "\n" + String(a.stderr ?? "")).slice(-6000), ms: a.durationMs ?? null });
|
|
86
|
+
}
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (rec.type === "assistant") {
|
|
91
|
+
if (msg.model && !msg.model.startsWith("<")) session.models.add(msg.model);
|
|
92
|
+
if (!Array.isArray(content)) continue;
|
|
93
|
+
for (const block of content) {
|
|
94
|
+
if (block.type === "text" && !isSub) {
|
|
95
|
+
push({ kind: "say", ts, text: block.text });
|
|
96
|
+
} else if (block.type === "tool_use") {
|
|
97
|
+
if (block.name === "Read" && outputFiles.has(block.input?.file_path)) reads.set(block.id, outputFiles.get(block.input.file_path));
|
|
98
|
+
const evs = toolEvents(block, ts, isSub);
|
|
99
|
+
if (evs.length && evs[0].kind === "cmd") {
|
|
100
|
+
evs[0].id = block.id;
|
|
101
|
+
if (rec.gitBranch && rec.gitBranch !== "HEAD") evs[0].branch = rec.gitBranch;
|
|
102
|
+
// `tail -5 …/tasks/x.output` through the shell reads a background run's output too
|
|
103
|
+
const read = [...outputFiles.keys()].find((f) => evs[0].cmd.includes(f));
|
|
104
|
+
if (read) reads.set(block.id, outputFiles.get(read));
|
|
105
|
+
push(evs[0]);
|
|
106
|
+
pendingCmds.set(block.id, evs[0]);
|
|
107
|
+
} else if (evs.length) {
|
|
108
|
+
// an edit takes effect in request order, before any later tool call runs;
|
|
109
|
+
// it is only emitted if its result confirms it
|
|
110
|
+
for (const e of evs) e.seq = seq++;
|
|
111
|
+
pendingEdits.set(block.id, evs);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
} else if (rec.type === "user") {
|
|
116
|
+
if (Array.isArray(content)) {
|
|
117
|
+
for (const block of content) {
|
|
118
|
+
if (block.type === "tool_result") {
|
|
119
|
+
const text = fullText(resultText(block.content));
|
|
120
|
+
const readOf = reads.get(block.tool_use_id);
|
|
121
|
+
if (readOf) {
|
|
122
|
+
// the agent reading a background run's output file: evidence about that run
|
|
123
|
+
reads.delete(block.tool_use_id);
|
|
124
|
+
result(readOf, ts, { exitCode: null, isError: null, output: text.slice(-6000), partial: true });
|
|
125
|
+
if (!pendingCmds.has(block.tool_use_id)) continue; // a Read; a shell read also gets its own result
|
|
126
|
+
}
|
|
127
|
+
const edits = pendingEdits.get(block.tool_use_id);
|
|
128
|
+
if (edits) {
|
|
129
|
+
pendingEdits.delete(block.tool_use_id);
|
|
130
|
+
if (block.is_error === true) continue; // rejected or failed: nothing changed
|
|
131
|
+
for (const e of edits) {
|
|
132
|
+
// Write overwrites as happily as it creates; only its result says which
|
|
133
|
+
if (e.write) e.op = /created successfully/i.test(text) ? "add" : /updated|overwr/i.test(text) ? "update" : "unknown";
|
|
134
|
+
push(e);
|
|
135
|
+
}
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const ev = pendingCmds.get(block.tool_use_id);
|
|
139
|
+
if (!ev) continue;
|
|
140
|
+
pendingCmds.delete(block.tool_use_id);
|
|
141
|
+
// refused by a permission prompt, the auto-mode classifier or a hook: it never ran
|
|
142
|
+
if (block.is_error === true && DENIED.test(text)) {
|
|
143
|
+
result(ev, ts, { denied: true, exitCode: null, isError: true, output: "" });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (/running in background|backgrounded command/i.test(text) && !/^Exit code/.test(text)) {
|
|
147
|
+
// result arrives later through a <task-notification> or a Read of the output file
|
|
148
|
+
background.set(block.tool_use_id, ev);
|
|
149
|
+
const file = /(\/\S+\.output)\b/.exec(text)?.[1];
|
|
150
|
+
if (file) outputFiles.set(file, ev);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const m = /^Exit code (-?\d+)/.exec(text);
|
|
154
|
+
const isError = block.is_error === true;
|
|
155
|
+
result(ev, ts, { exitCode: m ? Number(m[1]) : isError ? null : 0, isError, output: text.slice(-6000), ms: ts && ev.ts ? ts - ev.ts : null });
|
|
156
|
+
} else if (block.type === "text" && !isSub && /^\[Request interrupted by user/.test(block.text || "")) {
|
|
157
|
+
push({ kind: "interrupt", ts });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (typeof content === "string" && content.startsWith("<task-notification")) {
|
|
162
|
+
const id = /<tool-use-id>([^<]+)</.exec(content)?.[1];
|
|
163
|
+
const ev = background.get(id);
|
|
164
|
+
if (ev) {
|
|
165
|
+
const code = /exit code (-?\d+)/i.exec(content);
|
|
166
|
+
const status = /<status>(\w+)</.exec(content)?.[1];
|
|
167
|
+
const file = /<output-file>([^<]+)</.exec(content)?.[1];
|
|
168
|
+
if (file) outputFiles.set(file, ev);
|
|
169
|
+
result(ev, ts, {
|
|
170
|
+
exitCode: code ? Number(code[1]) : status === "completed" ? 0 : status === "failed" ? 1 : null,
|
|
171
|
+
isError: null,
|
|
172
|
+
output: "",
|
|
173
|
+
ms: ts && ev.ts ? ts - ev.ts : null,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// commands you typed yourself with `!`: <bash-input>, then <bash-stdout>/<bash-stderr>
|
|
178
|
+
if (typeof content === "string" && /^\s*<bash-input>/.test(content)) {
|
|
179
|
+
const cmd = unescape(/<bash-input>([\s\S]*?)<\/bash-input>/.exec(content)?.[1] ?? "").trim();
|
|
180
|
+
if (cmd) {
|
|
181
|
+
userCmd = { kind: "cmd", ts, cmd, sub: false, byUser: true };
|
|
182
|
+
push(userCmd);
|
|
183
|
+
}
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (typeof content === "string" && /^\s*<bash-std(?:out|err)>/.test(content) && userCmd) {
|
|
187
|
+
const out = [...content.matchAll(/<bash-std(?:out|err)>([\s\S]*?)<\/bash-std(?:out|err)>/g)].map((m) => unescape(m[1])).join("\n");
|
|
188
|
+
result(userCmd, ts, { exitCode: null, isError: null, output: out.slice(-6000), ms: ts && userCmd.ts ? ts - userCmd.ts : null });
|
|
189
|
+
userCmd = null;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (!isSub && isHumanPrompt(rec)) {
|
|
193
|
+
push({ kind: "user", ts, text: typeof content === "string" ? content : textOf(content) });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// Edits whose outcome never arrived may or may not have happened.
|
|
199
|
+
for (const evs of pendingEdits.values()) push({ kind: "uncertain", ts: evs[0].ts, seq: evs[0].seq, reason: "edit with no result" });
|
|
200
|
+
session.versions = [...session.versions];
|
|
201
|
+
// lines in the file but none we recognize: probably a log format we don't know yet
|
|
202
|
+
session.unrecognized = stats.lines > 5 && stats.kept === 0;
|
|
203
|
+
// stable: events with equal timestamps keep the order they were observed in
|
|
204
|
+
session.events.sort((a, b) => (a.ts ?? 0) - (b.ts ?? 0) || a.seq - b.seq);
|
|
205
|
+
session.models = [...session.models];
|
|
206
|
+
return session;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Answers the agent gets instead of a command's output when it wasn't allowed to run
|
|
210
|
+
const DENIED = /^(?:Permission for this action was denied|Permission to use \S+ (?:with command .* )?has been denied|The user doesn't want to proceed with this tool use|\S+ operation blocked by hook|.{0,40}hook (?:blocked|denied)|User rejected)/is;
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Output too large for the transcript is saved to a file, and the transcript keeps only
|
|
214
|
+
* a preview (`<persisted-output>… Full output saved to: /path …`). The runner's summary is
|
|
215
|
+
* at the end, so read the end of the saved file when it still exists.
|
|
216
|
+
*/
|
|
217
|
+
function fullText(text) {
|
|
218
|
+
if (!text.includes("<persisted-output>")) return text;
|
|
219
|
+
const file = /Full output saved to: (\S+?\.txt)\b/.exec(text)?.[1];
|
|
220
|
+
if (!file) return text;
|
|
221
|
+
try {
|
|
222
|
+
const fd = fs.openSync(file, "r");
|
|
223
|
+
try {
|
|
224
|
+
const size = fs.fstatSync(fd).size;
|
|
225
|
+
const n = Math.min(size, 16384);
|
|
226
|
+
const buf = Buffer.alloc(n);
|
|
227
|
+
fs.readSync(fd, buf, 0, n, size - n);
|
|
228
|
+
return text + "\n" + buf.toString("utf8");
|
|
229
|
+
} finally {
|
|
230
|
+
fs.closeSync(fd);
|
|
231
|
+
}
|
|
232
|
+
} catch {
|
|
233
|
+
return text;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function isHumanPrompt(rec) {
|
|
238
|
+
if (rec.isMeta || rec.isSidechain) return false;
|
|
239
|
+
const kind = rec.origin?.kind;
|
|
240
|
+
if (kind) return kind === "human";
|
|
241
|
+
const c = rec.message?.content;
|
|
242
|
+
const text = typeof c === "string" ? c : Array.isArray(c) && c.every((b) => b.type === "text") ? textOf(c) : null;
|
|
243
|
+
if (!text) return false;
|
|
244
|
+
return !/^\s*(?:<[a-z_-]+[\s>]|\[(?:Request interrupted|Image)|Caveat:)/.test(text);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function toolEvents(block, ts, isSub) {
|
|
248
|
+
const input = block.input || {};
|
|
249
|
+
switch (block.name) {
|
|
250
|
+
case "Bash":
|
|
251
|
+
case "bash":
|
|
252
|
+
case "PowerShell": // Claude Code on Windows; same result format as Bash
|
|
253
|
+
case "powershell":
|
|
254
|
+
return input.command ? [{ kind: "cmd", ts, cmd: String(input.command), sub: isSub }] : [];
|
|
255
|
+
case "Edit":
|
|
256
|
+
return [edit(input.file_path, input.old_string, input.new_string, ts)];
|
|
257
|
+
case "MultiEdit":
|
|
258
|
+
return (input.edits || []).map((e) => edit(input.file_path, e.old_string, e.new_string, ts));
|
|
259
|
+
case "Write":
|
|
260
|
+
return [{ kind: "edit", ts, path: input.file_path || "", removed: null, added: lines(input.content), write: true }];
|
|
261
|
+
case "NotebookEdit":
|
|
262
|
+
return [{ kind: "edit", ts, path: input.notebook_path || "", removed: [], added: lines(input.new_source) }];
|
|
263
|
+
default:
|
|
264
|
+
return [];
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function edit(p, oldS, newS, ts) {
|
|
269
|
+
return { kind: "edit", ts, path: p || "", removed: lines(oldS), added: lines(newS) };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const lines = (s) => (typeof s === "string" && s.length ? s.split("\n") : []);
|
|
273
|
+
|
|
274
|
+
function resultText(c) {
|
|
275
|
+
if (typeof c === "string") return c;
|
|
276
|
+
if (Array.isArray(c)) return c.map((b) => (typeof b === "string" ? b : b.text || "")).join("\n");
|
|
277
|
+
return "";
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const unescape = (s) => s.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&");
|
|
281
|
+
|
|
282
|
+
function textOf(content) {
|
|
283
|
+
return Array.isArray(content) ? content.filter((b) => b.type === "text").map((b) => b.text).join("\n") : "";
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function safeReaddir(d) {
|
|
287
|
+
try {
|
|
288
|
+
return fs.readdirSync(d);
|
|
289
|
+
} catch {
|
|
290
|
+
return [];
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function mtime(f) {
|
|
295
|
+
try {
|
|
296
|
+
return fs.statSync(f).mtimeMs;
|
|
297
|
+
} catch {
|
|
298
|
+
return 0;
|
|
299
|
+
}
|
|
300
|
+
}
|