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/src/guard.js ADDED
@@ -0,0 +1,240 @@
1
+ // `agent-polygraph guard`: a Stop hook for Claude Code and Codex.
2
+ // When the agent's final message says tests pass but the session shows no passing run
3
+ // after its last code edit, the hook sends it back once to rerun the tests or correct itself.
4
+
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { execFileSync } from "node:child_process";
9
+ import * as claude from "./sources/claude.js";
10
+ import * as codex from "./sources/codex.js";
11
+ import { analyzeSession } from "./analyze.js";
12
+ import { findTestClaim, findTestClaims } from "./classify.js";
13
+ import { applyRecords } from "./record.js";
14
+ import { readSettings, writeSettings } from "./safe-config.js";
15
+
16
+
17
+ /** Hook entry point: read the Stop payload on stdin, maybe print a block decision. */
18
+ export async function runHook(stdinText, { plugin = false } = {}) {
19
+ let payload;
20
+ try {
21
+ payload = JSON.parse(stdinText || "{}");
22
+ } catch {
23
+ return null;
24
+ }
25
+ if (payload.stop_hook_active) return null; // we already sent the agent back once
26
+ // The Claude Code plugin's hook steps aside when `guard --install` put one in settings
27
+ // too, so the agent isn't sent back twice with the same message.
28
+ if (plugin && settingsHasGuard(payload.cwd)) return null;
29
+ const transcript = payload.transcript_path;
30
+ if (!transcript || !fs.existsSync(transcript)) return null;
31
+
32
+ const isCodex = sniffCodex(transcript);
33
+ const src = isCodex ? codex : claude;
34
+ const files = [transcript];
35
+ if (!isCodex) {
36
+ const subDir = path.join(transcript.replace(/\.jsonl$/, ""), "subagents");
37
+ try {
38
+ for (const f of fs.readdirSync(subDir)) if (f.endsWith(".jsonl")) files.push(path.join(subDir, f));
39
+ } catch {}
40
+ }
41
+ const session = await src.parse({ agent: src.name, id: "live", files });
42
+ if (payload.cwd && !session.cwd) session.cwd = payload.cwd;
43
+ // snapshots and terminal runs from `agent-polygraph record`, if installed
44
+ try {
45
+ session.file = transcript;
46
+ applyRecords(session);
47
+ } catch {}
48
+
49
+ // Judge only what the agent is actually ending with. Agents send that text with the Stop
50
+ // payload (it may not be in the transcript yet); an earlier claim it has since corrected
51
+ // ("Correction: I did not run the tests") must not trigger a block.
52
+ const events = session.events;
53
+ const lastUser = events.findLastIndex((e) => e.kind === "user");
54
+ let final;
55
+ const last = payload.last_assistant_message;
56
+ if (typeof last === "string" && last.trim()) {
57
+ // a loop, not Math.max(...events): long sessions exceed the argument limit
58
+ let latest = session.end ?? 0;
59
+ for (const e of events) if ((e.ts ?? 0) > latest) latest = e.ts;
60
+ final = { kind: "say", ts: latest + 1, text: last };
61
+ events.push(final);
62
+ } else {
63
+ const i = events.findLastIndex((e) => e.kind === "say");
64
+ final = i > lastUser ? events[i] : null;
65
+ }
66
+ if (!final || !findTestClaim(final.text)) return null;
67
+
68
+ const result = analyzeSession(session);
69
+ const detail = result.findings.find((f) => f.type === "claim" && f.ts === final.ts && final.verdict === f.detail.verdict)?.detail;
70
+ if (!detail) return null; // backed, or nothing we can judge
71
+ // quote the message's conclusion ("All tests pass."), not a step along the way
72
+ const quote = findTestClaims(final.text).at(-1) ?? detail.claim;
73
+ return { decision: "block", reason: reasonFor({ ...detail, claim: quote }) };
74
+ }
75
+
76
+ function reasonFor(d) {
77
+ const why = {
78
+ "no-run": "no test run appears in this session",
79
+ stale: `you edited ${d.editedAfter} after the last test run`,
80
+ failed: "the most recent test run in this session failed",
81
+ }[d.verdict];
82
+ return [
83
+ `agent-polygraph: your final message says "${d.claim}", but ${why}.`,
84
+ "Run the relevant tests now and report the actual result. If they can't be run, say so instead of claiming they pass.",
85
+ ].join(" ");
86
+ }
87
+
88
+ /** Is a guard hook installed in Claude Code settings that apply to `cwd` (user or project)? */
89
+ export function settingsHasGuard(cwd, home = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude")) {
90
+ const files = [path.join(home, "settings.json"), path.join(home, "settings.local.json")];
91
+ // Claude Code reads project settings from where it was started, which may be above cwd
92
+ for (let d = cwd ? path.resolve(cwd) : null; d; d = path.dirname(d) === d ? null : path.dirname(d)) {
93
+ files.push(path.join(d, ".claude", "settings.json"), path.join(d, ".claude", "settings.local.json"));
94
+ }
95
+ return files.some((file) => {
96
+ try {
97
+ const stop = JSON.parse(fs.readFileSync(file, "utf8")).hooks?.Stop;
98
+ return Array.isArray(stop) && stop.some((g) => (g.hooks || []).some((h) => isOurHook(h, "guard")));
99
+ } catch {
100
+ return false;
101
+ }
102
+ });
103
+ }
104
+
105
+ function sniffCodex(file) {
106
+ try {
107
+ const fd = fs.openSync(file, "r");
108
+ const buf = Buffer.alloc(4096);
109
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
110
+ fs.closeSync(fd);
111
+ return /"type"\s*:\s*"session_meta"/.test(buf.subarray(0, n).toString("utf8"));
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ // --- install / uninstall ------------------------------------------------------------
118
+
119
+ const IN_NPX_CACHE = /[\\/]_npx[\\/]/;
120
+
121
+ export function hookCommand(sub = "guard") {
122
+ // npx puts its temporary copy on PATH while it runs, so a bare `agent-polygraph`
123
+ // only counts when it's a real install that will still exist later.
124
+ try {
125
+ const found = execFileSync(process.platform === "win32" ? "where" : "which", ["agent-polygraph"], { encoding: "utf8" }).split(/\r?\n/)[0].trim();
126
+ if (found && !IN_NPX_CACHE.test(fs.realpathSync(found))) return `agent-polygraph ${sub}`;
127
+ } catch {}
128
+ const self = fs.realpathSync(process.argv[1]);
129
+ if (!IN_NPX_CACHE.test(self)) return `node ${JSON.stringify(self)} ${sub}`;
130
+ const { version } = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
131
+ return `npx -y agent-polygraph@${version} ${sub}`;
132
+ }
133
+
134
+ /** Hook config files to touch: project-level by default, user-level with `global`. */
135
+ export function targets({ agents = ["claude", "codex"], global = false, cwd = process.cwd() } = {}) {
136
+ const out = [];
137
+ if (agents.includes("claude")) {
138
+ out.push({
139
+ agent: "claude",
140
+ file: global
141
+ ? path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"), "settings.json")
142
+ : path.join(cwd, ".claude", "settings.json"),
143
+ });
144
+ }
145
+ if (agents.includes("codex")) {
146
+ out.push({
147
+ agent: "codex",
148
+ file: global
149
+ ? path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "hooks.json")
150
+ : path.join(cwd, ".codex", "hooks.json"),
151
+ });
152
+ }
153
+ return out;
154
+ }
155
+
156
+ /**
157
+ * One definition of "our hook", covering every command form we write: a global install
158
+ * (`agent-polygraph guard`), a version-pinned npx (`npx -y agent-polygraph@0.1.0 guard`)
159
+ * and a local checkout (`node "/path/bin/polygraph.js" guard`).
160
+ */
161
+ export function isOurHook(h, sub = "guard") {
162
+ const cmd = String(h?.command || "");
163
+ return new RegExp(String.raw`\bagent-polygraph(?:@\S+)?\s+${sub}\b`).test(cmd) || new RegExp(String.raw`polygraph\.js["']?\s+${sub}\b`).test(cmd);
164
+ }
165
+
166
+ // `guard` is a Stop hook; `record` runs after each shell command (PostToolUse). Codex gets
167
+ // no matcher: the recorder itself ignores tools that aren't shell commands.
168
+ const HOOKS = {
169
+ guard: { event: "Stop", timeout: 30, matcher: {} },
170
+ record: { event: "PostToolUse", timeout: 20, matcher: { claude: "Bash|PowerShell" } },
171
+ };
172
+
173
+ export function install(opts) {
174
+ const sub = opts.sub || "guard";
175
+ const spec = HOOKS[sub];
176
+ const command = opts.command || hookCommand(sub);
177
+ const isOurs = (h) => isOurHook(h, sub);
178
+ return targets(opts).map(({ agent, file }) => {
179
+ const config = readJson(file);
180
+ config.hooks ||= {};
181
+ const stop = (config.hooks[spec.event] ||= []);
182
+ const ours = stop.flatMap((g) => (g.hooks || []).filter(isOurs));
183
+ let changed = false;
184
+ if (!ours.length) {
185
+ const group = { hooks: [{ type: "command", command, timeout: spec.timeout }] };
186
+ if (spec.matcher[agent]) group.matcher = spec.matcher[agent];
187
+ stop.push(group);
188
+ changed = true;
189
+ } else {
190
+ // upgrade an older form (another version, npx vs global) in place; drop duplicates
191
+ if (ours[0].command !== command) {
192
+ ours[0].command = command;
193
+ changed = true;
194
+ }
195
+ for (const g of stop) {
196
+ const before = (g.hooks || []).length;
197
+ g.hooks = (g.hooks || []).filter((h) => !isOurs(h) || h === ours[0]);
198
+ if (g.hooks.length !== before) changed = true;
199
+ }
200
+ config.hooks[spec.event] = stop.filter((g) => g.hooks.length);
201
+ }
202
+ if (changed) {
203
+ fs.mkdirSync(path.dirname(file), { recursive: true });
204
+ const save = writeSettings(file, JSON.stringify(config, null, 2) + "\n");
205
+ return { agent, file, changed, command, backup: save.backup };
206
+ }
207
+ return { agent, file, changed, command };
208
+ });
209
+ }
210
+
211
+ export function uninstall(opts) {
212
+ const sub = opts.sub || "guard";
213
+ const { event } = HOOKS[sub];
214
+ const isOurs = (h) => isOurHook(h, sub);
215
+ return targets(opts).map(({ agent, file }) => {
216
+ if (!fs.existsSync(file)) return { agent, file, changed: false };
217
+ const config = readJson(file);
218
+ const stop = config.hooks?.[event];
219
+ if (!Array.isArray(stop)) return { agent, file, changed: false };
220
+ const removed = stop.reduce((n, g) => n + (g.hooks || []).filter(isOurs).length, 0);
221
+ if (!removed) return { agent, file, changed: false };
222
+ const kept = stop.map((g) => ({ ...g, hooks: (g.hooks || []).filter((h) => !isOurs(h)) })).filter((g) => g.hooks.length);
223
+ if (kept.length) config.hooks[event] = kept;
224
+ else delete config.hooks[event];
225
+ if (!Object.keys(config.hooks).length) delete config.hooks;
226
+ const save = writeSettings(file, JSON.stringify(config, null, 2) + "\n");
227
+ return { agent, file, changed: true, backup: save.backup };
228
+ });
229
+ }
230
+
231
+ function readJson(file) {
232
+ try {
233
+ const { text } = readSettings(file);
234
+ if (text === null) return {};
235
+ return JSON.parse(text);
236
+ } catch (e) {
237
+ if (e.code === "ENOENT") return {};
238
+ throw new Error(`${file} is not valid JSON; fix or remove it first (${e.message})`);
239
+ }
240
+ }
package/src/jsonl.js ADDED
@@ -0,0 +1,23 @@
1
+ import fs from "node:fs";
2
+ import readline from "node:readline";
3
+
4
+ /**
5
+ * Stream parsed records from a JSONL file. `keep(line)` is a cheap substring
6
+ * prefilter so we don't JSON.parse the many large records we never look at.
7
+ */
8
+ export async function* readJsonl(file, keep = () => true, stats = {}) {
9
+ const rl = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
10
+ stats.lines ??= 0;
11
+ stats.kept ??= 0;
12
+ for await (const line of rl) {
13
+ if (!line) continue;
14
+ stats.lines++;
15
+ if (!keep(line)) continue;
16
+ stats.kept++;
17
+ try {
18
+ yield JSON.parse(line);
19
+ } catch {
20
+ // partial line from a session that is still being written
21
+ }
22
+ }
23
+ }
package/src/record.js ADDED
@@ -0,0 +1,402 @@
1
+ // `agent-polygraph record`: optional evidence the session logs don't carry.
2
+ //
3
+ // - A PostToolUse hook (Claude Code and Codex). After a test or potential write,
4
+ // it records bounded, keyed source-file fingerprints and test outcomes.
5
+ // It does not store raw commands, test output, source contents or Git objects.
6
+ // Comparing snapshots can resolve changes not identifiable from shell text.
7
+ // - Shell integration (bash, zsh): test commands you run in your own terminals are logged
8
+ // with their folder, start and end time and exit code. No output is kept.
9
+ //
10
+ // Everything stays in ~/.agent-polygraph (or $AGENT_POLYGRAPH_HOME). Nothing is uploaded.
11
+
12
+ import fs from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import crypto from "node:crypto";
16
+ import { fileURLToPath } from "node:url";
17
+ import { readSettings, writeSettings } from "./safe-config.js";
18
+ import { execFileSync } from "node:child_process";
19
+ import { isTestCommand, isCodePath, mayWriteFiles, shellWrites, outcomeFromOutput, passedCount } from "./classify.js";
20
+
21
+ export const home = () => process.env.AGENT_POLYGRAPH_HOME || path.join(os.homedir(), ".agent-polygraph");
22
+ const hash = (s) => crypto.createHash("sha1").update(String(s)).digest("hex").slice(0, 16);
23
+ export const recordsFile = (transcript) => path.join(home(), "records", hash(path.resolve(transcript)) + ".jsonl");
24
+ export const terminalFile = () => path.join(home(), "records", "terminal.jsonl");
25
+
26
+ // Refuse redirected or shared record paths. These files are local evidence,
27
+ // not a place to follow arbitrary symlinks or overwrite somebody else's files.
28
+ const NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
29
+ function verifyPrivateFile(fd, file = null) {
30
+ const st = fs.fstatSync(fd);
31
+ if (file && process.platform === "win32") {
32
+ // Windows lacks O_NOFOLLOW; inspect the entry and opened file before I/O.
33
+ const entry = fs.lstatSync(file);
34
+ if (entry.isSymbolicLink() || !entry.isFile() || entry.dev !== st.dev || entry.ino !== st.ino)
35
+ throw new Error("Unsafe linked record file: " + file);
36
+ }
37
+ if (!st.isFile() || st.nlink !== 1) throw new Error("Unsafe record file (not regular or multiply linked)");
38
+ if (process.platform !== "win32") {
39
+ if (st.uid !== process.getuid()) throw new Error("Record file belongs to another user");
40
+ fs.fchmodSync(fd, 0o600);
41
+ }
42
+ }
43
+ function secureDirectory(dir) {
44
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
45
+ // Act on the opened directory, not on a pathname that may be replaced
46
+ // with a symlink between an lstat() check and chmod().
47
+ const entry = fs.lstatSync(dir);
48
+ if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("Unsafe linked record directory");
49
+ const fd = fs.openSync(dir, fs.constants.O_RDONLY | (fs.constants.O_DIRECTORY || 0) | NOFOLLOW);
50
+ try {
51
+ const st = fs.fstatSync(fd);
52
+ if (!st.isDirectory()) throw new Error("Unsafe record directory");
53
+ if (process.platform === "win32" && fs.lstatSync(dir).isSymbolicLink())
54
+ throw new Error("Unsafe linked record directory");
55
+ if (process.platform !== "win32") {
56
+ if (st.uid !== process.getuid()) throw new Error("Record directory belongs to another user");
57
+ fs.fchmodSync(fd, 0o700);
58
+ }
59
+ } finally {
60
+ fs.closeSync(fd);
61
+ }
62
+ }
63
+ function secureWrite(file, data, append = false) {
64
+ secureDirectory(path.dirname(file));
65
+ // Never truncate before validating an existing file. O_NOFOLLOW rejects final
66
+ // symlinks, and nlink/owner checks reject hard links and unrelated files.
67
+ try {
68
+ const entry = fs.lstatSync(file);
69
+ if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("Unsafe linked record file: " + file);
70
+ } catch (e) {
71
+ if (e.code !== "ENOENT") throw e;
72
+ }
73
+ const fd = fs.openSync(file, fs.constants.O_CREAT | fs.constants.O_WRONLY | (append ? fs.constants.O_APPEND : 0) | NOFOLLOW, 0o600);
74
+ try {
75
+ verifyPrivateFile(fd, file);
76
+ if (!append) fs.ftruncateSync(fd, 0);
77
+ fs.writeSync(fd, data);
78
+ } finally {
79
+ fs.closeSync(fd);
80
+ }
81
+ }
82
+
83
+ // The key stays on the local machine. A keyed digest lets us correlate commands
84
+ // without ever persisting their text (which may contain tokens/passwords).
85
+ let keyCache;
86
+ let keyCachePath;
87
+ function localKey() {
88
+ const file = path.join(home(), "record-key");
89
+ if (keyCachePath === file && keyCache) return keyCache;
90
+ secureDirectory(home());
91
+ let key;
92
+ const readKey = () => {
93
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | NOFOLLOW);
94
+ try {
95
+ verifyPrivateFile(fd, file);
96
+ return fs.readFileSync(fd);
97
+ } finally {
98
+ fs.closeSync(fd);
99
+ }
100
+ };
101
+ try {
102
+ key = readKey();
103
+ } catch (e) {
104
+ if (e.code !== "ENOENT") throw e;
105
+ const candidate = crypto.randomBytes(32);
106
+ try {
107
+ const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | NOFOLLOW, 0o600);
108
+ try { verifyPrivateFile(fd, file); fs.writeSync(fd, candidate); } finally { fs.closeSync(fd); }
109
+ key = candidate;
110
+ } catch (race) {
111
+ if (race.code !== "EEXIST") throw race;
112
+ key = readKey();
113
+ }
114
+ }
115
+ if (key.length !== 32) throw new Error("Invalid agent-polygraph local record key");
116
+ keyCache = key;
117
+ keyCachePath = file;
118
+ return key;
119
+ }
120
+ export const commandFingerprint = (cmd) => crypto.createHmac("sha256", localKey()).update(String(cmd)).digest("hex");
121
+
122
+ // --- the hook --------------------------------------------------------------------------
123
+
124
+ /** PostToolUse entry point. Never throws, never prints: a recorder must not get in the way. */
125
+ export function runRecordHook(stdinText) {
126
+ try {
127
+ const p = JSON.parse(stdinText || "{}");
128
+ const cmd = commandOf(p.tool_input);
129
+ if (!cmd || !p.transcript_path) return;
130
+ const test = isTestCommand(cmd);
131
+ if (!test && !mayWriteFiles(cmd) && !shellWrites(cmd).unresolved) return;
132
+ const cwd = p.cwd || process.cwd();
133
+ const rec = { v: 2, ts: Date.now(), id: p.tool_use_id || null, tool: p.tool_name || null, cmdKey: commandFingerprint(cmd), cwd };
134
+ try {
135
+ rec.tree = snapshot(cwd);
136
+ } catch {}
137
+ if (test) {
138
+ const out = outputOf(p.tool_response);
139
+ const outcome = outcomeFromOutput(out);
140
+ if (outcome) rec.outcome = outcome;
141
+ const passed = passedCount(out);
142
+ if (passed != null) rec.passed = passed;
143
+ }
144
+ if (!rec.tree && !rec.outcome) return;
145
+ const file = recordsFile(p.transcript_path);
146
+ secureWrite(file, JSON.stringify(rec) + "\n", true);
147
+ } catch {}
148
+ }
149
+
150
+ function commandOf(input) {
151
+ if (!input) return null;
152
+ if (typeof input === "string") return input;
153
+ const c = input.command ?? input.cmd;
154
+ if (typeof c === "string") return c;
155
+ // ["bash", "-lc", "npm test"]
156
+ if (Array.isArray(c)) return c.length >= 3 && /^-\w*c$/.test(c[c.length - 2]) ? String(c[c.length - 1]) : c.join(" ");
157
+ return null;
158
+ }
159
+
160
+ function outputOf(r) {
161
+ if (r == null) return "";
162
+ if (typeof r === "string") return r;
163
+ const parts = [r.stdout, r.stderr, r.output, r.aggregated_output, r.formatted_output, typeof r.content === "string" ? r.content : null];
164
+ return parts.filter((x) => typeof x === "string").join("\n");
165
+ }
166
+
167
+ // --- working-tree fingerprints -------------------------------------------------------------
168
+
169
+ // No git objects or copies of source files are written. Snapshots contain
170
+ // bounded HMACs, which cannot be used as a substitute for the source contents.
171
+ const MAX_SOURCE_BYTES = 8 * 1024 * 1024;
172
+ const MAX_SNAPSHOT_BYTES = 64 * 1024 * 1024;
173
+ const MAX_SNAPSHOT_FILES = 10000;
174
+
175
+ function safeParents(root, relative) {
176
+ let parent = root;
177
+ for (const part of relative.split(/[\\/]/).slice(0, -1)) {
178
+ if (!part || part === "." || part === "..") return false;
179
+ parent = path.join(parent, part);
180
+ const st = fs.lstatSync(parent);
181
+ if (!st.isDirectory() || st.isSymbolicLink()) return false;
182
+ }
183
+ return true;
184
+ }
185
+
186
+ function sourceFingerprint(root, name, key) {
187
+ const file = path.join(root, name);
188
+ // Refuse symlinks in the path. Open the leaf with O_NOFOLLOW too: a symlink
189
+ // substitution between lstat and open must not make us read its target.
190
+ if (path.isAbsolute(name) || name.split(/[\\/]/).includes("..") || !safeParents(root, name)) return null;
191
+ const initial = fs.lstatSync(file, { bigint: true });
192
+ if (!initial.isFile() || initial.nlink !== 1n || initial.size > BigInt(MAX_SOURCE_BYTES)) return null;
193
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | NOFOLLOW);
194
+ try {
195
+ const before = fs.fstatSync(fd, { bigint: true });
196
+ if (!before.isFile() || before.dev !== initial.dev || before.ino !== initial.ino ||
197
+ before.nlink !== 1n || before.size > BigInt(MAX_SOURCE_BYTES)) return null;
198
+ const bytes = fs.readFileSync(fd);
199
+ const after = fs.fstatSync(fd, { bigint: true });
200
+ if (before.size !== after.size || before.mtimeNs !== after.mtimeNs ||
201
+ before.ctimeNs !== after.ctimeNs || bytes.length !== Number(before.size)) return null;
202
+ // A path replacement during the read must remain inconclusive.
203
+ const current = fs.lstatSync(file, { bigint: true });
204
+ if (!current.isFile() || current.dev !== after.dev || current.ino !== after.ino ||
205
+ current.mtimeNs !== after.mtimeNs || current.ctimeNs !== after.ctimeNs) return null;
206
+ // Linux exposes the opened descriptor's actual path. Reject a race that
207
+ // redirected a parent directory outside the checkout after safeParents().
208
+ if (process.platform === "linux") {
209
+ const opened = fs.realpathSync(`/proc/self/fd/${fd}`);
210
+ const relative = path.relative(fs.realpathSync(root), opened);
211
+ if (relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) return null;
212
+ }
213
+ return { bytes: bytes.length, digest: crypto.createHmac("sha256", key).update(bytes).digest("hex") };
214
+ } finally {
215
+ fs.closeSync(fd);
216
+ }
217
+ }
218
+
219
+ export function snapshot(cwd) {
220
+ const git = (args, options = {}) => execFileSync("git", args, {
221
+ cwd, encoding: options.encoding ?? "utf8", stdio: ["ignore", "pipe", "ignore"],
222
+ timeout: 8000, maxBuffer: 16 << 20,
223
+ });
224
+ const root = git(["rev-parse", "--show-toplevel"]).trim();
225
+ const files = Object.create(null);
226
+ const paths = git(["ls-files", "--cached", "--others", "--exclude-standard", "-z"], { encoding: "buffer" }).toString("utf8").split("\0").filter(Boolean);
227
+ const key = localKey();
228
+ let complete = true;
229
+ let total = 0;
230
+ let count = 0;
231
+ for (const name of paths) {
232
+ if (!isCodePath(name)) continue;
233
+ if (++count > MAX_SNAPSHOT_FILES) { complete = false; break; }
234
+ try {
235
+ const data = sourceFingerprint(root, name, key);
236
+ if (!data) { complete = false; continue; }
237
+ if (total + data.bytes > MAX_SNAPSHOT_BYTES) { complete = false; break; }
238
+ total += data.bytes;
239
+ files[name] = data.digest;
240
+ } catch {
241
+ complete = false;
242
+ }
243
+ }
244
+ return { root, files, complete };
245
+ }
246
+
247
+ export function treeDiff(a, b) {
248
+ if (!a || !b || a.root !== b.root || !a.complete || !b.complete) return null;
249
+ const names = new Set([...Object.keys(a.files), ...Object.keys(b.files)]);
250
+ return [...names].filter((name) => a.files[name] !== b.files[name]);
251
+ }
252
+
253
+ // --- using the records -----------------------------------------------------------------------
254
+
255
+ const squash = (c) => String(c).replace(/\s+/g, " ").trim();
256
+
257
+ /**
258
+ * Attach recorded snapshots and outcomes to the session's commands, and add test runs from
259
+ * your terminals that happened during the session in its checkout. Mutates `session`.
260
+ */
261
+ export function applyRecords(session, { terminal = loadTerminal() } = {}) {
262
+ let recs = [];
263
+ try {
264
+ recs = readLines(recordsFile(session.file));
265
+ } catch {}
266
+ if (recs.length) {
267
+ session.treeDiff = treeDiff;
268
+ // a record belongs to the command with the same text, recorded after it started
269
+ const free = recs.map((r) => ({ ...r, key: r.cmdKey || (r.cmd ? squash(r.cmd) : null) }));
270
+ for (const ev of session.events) {
271
+ if (ev.kind !== "cmd" || ev.external) continue;
272
+ const key = commandFingerprint(ev.cmd);
273
+ const legacyKey = squash(ev.cmd);
274
+ const k = free.findIndex((r) => r && (ev.id && r.id ? r.id === ev.id : r.key != null && r.key === (r.cmdKey ? key : legacyKey) && r.ts >= (ev.ts ?? 0) - 2000));
275
+ if (k < 0) continue;
276
+ const r = free[k];
277
+ free[k] = null;
278
+ if (r.tree) ev.tree = r.tree;
279
+ if (r.outcome) ev.recorded = { outcome: r.outcome, passed: r.passed ?? null };
280
+ }
281
+ }
282
+ addTerminalRuns(session, terminal);
283
+ return session;
284
+ }
285
+
286
+ let terminalCache = null;
287
+ export function loadTerminal() {
288
+ if (terminalCache) return terminalCache;
289
+ try {
290
+ terminalCache = readLines(terminalFile());
291
+ } catch {
292
+ terminalCache = [];
293
+ }
294
+ return terminalCache;
295
+ }
296
+
297
+ // Shell hook receipts use the same no-follow, owner-only writer as agent hooks.
298
+ // Accept an allowlisted category, never an arbitrary command or untrusted output.
299
+ const SHELL_RUNNERS = new Set(["pytest", "npm test", "node --test", "go test", "cargo test", "mvn test", "gradle test", "dotnet test", "vitest run"]);
300
+ export function runShellRecord(input) {
301
+ try {
302
+ const r = JSON.parse(input);
303
+ if (!SHELL_RUNNERS.has(r.runner) || !isTestCommand(r.runner)) return false;
304
+ if (!Number.isFinite(r.start) || !Number.isFinite(r.end) || r.end < r.start ||
305
+ !Number.isInteger(r.exit) || typeof r.cwd !== "string" ||
306
+ !path.isAbsolute(r.cwd) || r.cwd.length > 4096) return false;
307
+ const rec = { v: 2, kind: "terminal", start: r.start, end: r.end, exit: r.exit, cwd: r.cwd, runner: r.runner };
308
+ secureWrite(terminalFile(), JSON.stringify(rec) + "\n", true);
309
+ return true;
310
+ } catch {
311
+ return false;
312
+ }
313
+ }
314
+
315
+ function addTerminalRuns(session, runs) {
316
+ if (!runs.length || !session.cwd || session.start == null || session.end == null) return;
317
+ const cwd = slash(session.cwd);
318
+ const related = (d) => {
319
+ d = slash(d);
320
+ return d === cwd || d.startsWith(cwd + "/") || cwd.startsWith(d + "/");
321
+ };
322
+ let added = 0;
323
+ for (const r of runs) {
324
+ const start = Math.round(r.start * 1000);
325
+ const end = Math.round((r.end ?? r.start) * 1000);
326
+ const runner = r.runner || r.cmd; // legacy raw-command records are still readable
327
+ if (!(start >= session.start && start <= session.end) || !r.cwd || !related(r.cwd) || !runner || !isTestCommand(runner)) continue;
328
+ const ev = { kind: "cmd", ts: start, seq: Number.MAX_SAFE_INTEGER, cmd: runner, workdir: r.cwd, external: "terminal", sub: false };
329
+ session.events.push(ev, { kind: "result", of: ev, ts: end, seq: Number.MAX_SAFE_INTEGER, exitCode: typeof r.exit === "number" ? r.exit : null, isError: null, output: "" });
330
+ added++;
331
+ }
332
+ if (added) session.events.sort((a, b) => (a.ts ?? 0) - (b.ts ?? 0) || (a.seq ?? 0) - (b.seq ?? 0));
333
+ }
334
+
335
+ const slash = (p) => String(p).replace(/\\/g, "/").replace(/\/$/, "");
336
+
337
+ function readLines(file) {
338
+ const out = [];
339
+ for (const line of fs.readFileSync(file, "utf8").split("\n")) {
340
+ if (!line.trim()) continue;
341
+ try {
342
+ out.push(JSON.parse(line));
343
+ } catch {}
344
+ }
345
+ return out;
346
+ }
347
+
348
+ // --- shell integration -----------------------------------------------------------------------
349
+
350
+ const MARK = "# agent-polygraph record";
351
+ export const shellScript = () => path.join(home(), "shell.sh");
352
+
353
+ const shQuote = (s) => "'" + String(s).replaceAll("'", "'\"'\"'") + "'";
354
+ const SHELL_SNIPPET = () => fs.readFileSync(new URL("./shell/record.sh", import.meta.url), "utf8")
355
+ .replace("__APG_NODE__", shQuote(process.execPath))
356
+ .replace("__APG_ENTRY__", shQuote(fs.realpathSync(fileURLToPath(new URL("../bin/polygraph.js", import.meta.url)))));
357
+
358
+ const RC_FILES = () => [".bashrc", ".zshrc"].map((f) => path.join(process.env.HOME || os.homedir(), f));
359
+
360
+ export function installShell() {
361
+ const script = shellScript();
362
+ secureWrite(script, SHELL_SNIPPET());
363
+ secureDirectory(path.join(home(), "records"));
364
+ const quoted = shQuote(script);
365
+ const line = `[ -f ${quoted} ] && . ${quoted} ${MARK}`;
366
+ const out = [];
367
+ for (const rc of RC_FILES()) {
368
+ const shell = rc.endsWith("zshrc") ? "zsh" : "bash";
369
+ if (!fs.existsSync(rc) && !onPath(shell)) continue;
370
+ const text = readSettings(rc).text ?? "";
371
+ if (text.includes(MARK)) {
372
+ out.push({ file: rc, changed: false });
373
+ continue;
374
+ }
375
+ const result = writeSettings(rc, text + (text && !text.endsWith("\n") ? "\n" : "") + line + "\n");
376
+ out.push({ file: rc, changed: result.changed, backup: result.backup });
377
+ }
378
+ return { script, rcs: out };
379
+ }
380
+
381
+ export function uninstallShell() {
382
+ const out = [];
383
+ for (const rc of RC_FILES()) {
384
+ if (!fs.existsSync(rc)) continue;
385
+ const text = readSettings(rc).text;
386
+ if (text == null) continue;
387
+ if (!text.includes(MARK)) continue;
388
+ const result = writeSettings(rc, text.split("\n").filter((l) => !l.includes(MARK)).join("\n"));
389
+ out.push({ file: rc, changed: result.changed, backup: result.backup });
390
+ }
391
+ fs.rmSync(shellScript(), { force: true });
392
+ return { rcs: out };
393
+ }
394
+
395
+ function onPath(cmd) {
396
+ try {
397
+ execFileSync("which", [cmd], { stdio: "ignore" });
398
+ return true;
399
+ } catch {
400
+ return false;
401
+ }
402
+ }