agent-polygraph 0.1.0 → 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/README.md CHANGED
@@ -4,18 +4,18 @@
4
4
 
5
5
  Your coding agent said "all tests pass". Did a test pass after its last edit?
6
6
 
7
- agent-polygraph reads the Claude Code and Codex session logs already on your machine and checks every "tests pass" claim against what ran. No setup, no API key, nothing uploaded.
7
+ agent-polygraph is an audit of your past sessions. It reads the Claude Code and Codex session logs already on your machine and checks every "tests pass" claim against what ran: was there a passing test run after the agent's last code edit? It tells you how often your agents claimed something they hadn't verified, and shows the evidence for each case. No setup, no API key, nothing uploaded.
8
+
9
+ The audit and guard **read evidence; they do not execute tests**. A flagged claim is not proof the code is wrong: it means the available evidence does not support the claim at that point. If you want automatic test execution, use a separate test-running hook or CI.
8
10
 
9
11
  ```sh
10
12
  npx agent-polygraph # your own sessions
11
13
  npx agent-polygraph --demo # generated sample sessions
12
14
  ```
13
15
 
14
- <img src="docs/guard.gif" alt="guard stops a coding agent that claims tests pass without rerunning them" width="760">
15
-
16
- > This is a beta. It has been tested on my own logs and on public Claude Code sessions ([evaluation](docs/evaluation.md)). If a verdict looks wrong, please [open an issue](https://github.com/EleChon-pixel/agent-polygraph/issues/new/choose) with the redacted example from the report. Each one becomes a regression test.
16
+ > **Beta / evidence limitations:** The parser has been evaluated on the author's own sessions and public Claude Code traces ([evaluation](docs/evaluation.md)). Controlled replays and real executed tests exist, but **independent, blinded human-validated accuracy has not been established**. If you report a wrong verdict, [open an issue](https://github.com/EleChon-pixel/agent-polygraph/issues/new/choose) with a *manually redacted* example—never upload an unreviewed raw session log.
17
17
 
18
- My own sessions, July to October 2026:
18
+ Historical example from the author's sessions, July–October 2026 (not a representative benchmark or a current-user measurement):
19
19
 
20
20
  ```
21
21
  agent-polygraph 32 working sessions · Jul 30 – Oct 31, 2026 · 11.0s
@@ -45,6 +45,10 @@ Where the time went
45
45
 
46
46
  Under the tables it prints the latest examples: what the agent claimed, the last test command and its result, what was edited afterwards, and the session file, so you can check it yourself.
47
47
 
48
+ **What the benchmarks actually show:** agent-polygraph detects stale test claims more reliably than a *simple any-prior-pass rule* in [25 authored replay scenarios](docs/benchmark.md) (14/14 unbacked claims versus 7/14). In [12 instrumented projects using real test executions](docs/live-evaluation.md), its selective guard requested **5** additional tests and detected **3 of 7** failing final suites; rerunning the full suite every time required **12** additional tests and detected **all 7**. That is an efficiency-versus-coverage trade-off, **not** evidence that the guard improves overall defect detection. [Public-session workload projections](docs/benchmark.md) are separate from actual executed tests. The independently labeled human study is [still open](https://github.com/EleChon-pixel/agent-polygraph/issues/3).
49
+
50
+ **npm distribution:** Install the most recently *published* release with `npx --yes agent-polygraph --demo`, or pin an exact version (for example, `npx --yes agent-polygraph@0.2.0 --demo`). Check the actual public version with `npm view agent-polygraph version`: the GitHub `main` branch may be ahead of npm. Releases use GitHub OIDC trusted publishing and require maintainer 2FA approval before going live. The [automated public-install check](.github/workflows/verify-published-npm.yml) tests the download from npm, not just the repository source. See [Release instructions](docs/releasing.md).
51
+
48
52
  ## Share your numbers
49
53
 
50
54
  ```sh
@@ -55,7 +59,7 @@ This writes `polygraph.html`, a self-contained card with numbers only. Prompts,
55
59
 
56
60
  <img src="docs/card.png" alt="agent-polygraph report card from my own sessions" width="600">
57
61
 
58
- ## Stop unbacked claims as they happen
62
+ ## Catch unbacked claims as they happen (optional)
59
63
 
60
64
  ```sh
61
65
  npx agent-polygraph guard --install # this project (.claude/settings.json, .codex/hooks.json)
@@ -63,13 +67,38 @@ npx agent-polygraph guard --install --global # every project (~/.claude, ~/.co
63
67
  npx agent-polygraph guard --uninstall
64
68
  ```
65
69
 
70
+ <img src="docs/guard.gif" alt="staged demo: guard stops a coding agent that claims tests pass after editing code, and the rerun fails" width="760">
71
+
66
72
  `guard` is a Stop hook that runs the same checks on the live session. When the agent's final message says tests pass and the session doesn't back it up, the agent is sent back once:
67
73
 
68
74
  > agent-polygraph: your final message says "All tests pass.", but you edited calc.js after the last test run. Run the relevant tests now and report the actual result. If they can't be run, say so instead of claiming they pass.
69
75
 
70
76
  It blocks at most once per stop, looks only at the final message (so a claim the agent already corrected doesn't count), stays quiet when it can't tell, and never fails the turn because of its own errors. Install it globally (`npm i -g agent-polygraph`) so the hook doesn't start `npx` on every stop.
71
77
 
72
- I tested it with both agents by having them edit code after a test run and then claim the tests pass. Both were stopped, reran `npm test` and reported the failure. Codex needs one extra step: it only runs hooks you have approved, and only reads a project's `.codex/` folder if you trust it. After installing, open `codex` in the project once, trust the folder and approve the hook.
78
+ I tested it with both agents in a staged setup: they were told to edit code after a test run and then claim the tests pass (the GIF above is one of these runs). Both were stopped, reran `npm test` and reported the failure. Codex needs one extra step: it only runs hooks you have approved, and only reads a project's `.codex/` folder if you trust it. After installing, open `codex` in the project once, trust the folder and approve the hook.
79
+
80
+ ### Do you need it?
81
+
82
+ Maybe not. Two simpler setups also work, and they are more thorough:
83
+
84
+ - A Stop hook that runs your test suite after every turn and gives the agent the result. This also catches breakage when the agent doesn't claim anything.
85
+ - A Stop hook that always asks the agent to double-check its results. When I tried this on small tasks where the agent had edited code after its last run, it reran the tests every time (6 of 6, Claude Haiku).
86
+
87
+ The trade-off is cost. Both fire on every stop, so every turn ends with a test run, and the second also adds a model turn, even when nothing changed or nothing was claimed. A test-running hook also has to know each project's test command and setup (a virtualenv, environment variables, a subfolder, a database). `guard` runs nothing: it reads the session in well under a second and only sends the agent back when the final message claims a pass that no run after the last edit supports (about 6% of claims in my logs), naming the edit that made the run stale.
88
+
89
+ If your suite runs in a few seconds and needs no setup, running it on every stop is the better choice. `guard` is for suites that are slow or fiddly to run, and for when you don't want an extra turn on every stop.
90
+
91
+ ### As a Claude Code plugin
92
+
93
+ In Claude Code you can install the guard as a plugin instead:
94
+
95
+ ```
96
+ /plugin install agent-polygraph --marketplace EleChon-pixel/agent-polygraph
97
+ ```
98
+
99
+ On Claude Code older than 2.1.275, run `/plugin marketplace add EleChon-pixel/agent-polygraph` first, then `/plugin install agent-polygraph@agent-polygraph`.
100
+
101
+ The plugin runs the same Stop hook from its own copy, so nothing is downloaded when the agent stops, and it adds `/agent-polygraph:check` for the report (it takes the same options, for example `/agent-polygraph:check --since 30d`). If you also installed the hook with `guard --install`, the plugin's copy stays quiet so the agent isn't sent back twice. The plugin doesn't include `record`; turn that on separately if you want it.
73
102
 
74
103
  ## Evidence the logs don't have
75
104
 
@@ -77,13 +106,13 @@ Some things never show up in the logs: tests you ran yourself in another termina
77
106
 
78
107
  ```sh
79
108
  npx agent-polygraph record --install # this project; add --global for all of them
80
- npx agent-polygraph record --install --shell # your own terminals
109
+ agent-polygraph record --install --shell # your own terminals (requires a persistent install)
81
110
  npx agent-polygraph --ci # GitHub Actions (online)
82
111
  ```
83
112
 
84
- `record --install` adds a PostToolUse hook to Claude Code and Codex. After a test command, or a command that may write files, it saves a snapshot of the working tree and the test runner's full result. The snapshot is a git tree built with a temporary index and a separate object store, so your repository, its index and its history are never touched. Ignored files are left out. If the logs can't tell whether a script changed your code after the last test run, the two snapshots answer it, and a changed source file is named in the report. Everything stays in `~/.agent-polygraph` (or `$AGENT_POLYGRAPH_HOME`). It applies to sessions started after you install it.
113
+ `record --install` adds a PostToolUse hook to Claude Code and Codex. After a test or a command that may write files, it records the test outcome and an HMAC-fingerprinted source snapshot. The snapshot reads bounded source files without writing copies to Git or storing raw contents. Ignored and non-source files are excluded; symlinks, unreadable files and exceeded size limits make a snapshot inconclusive. Comparing snapshots can resolve scripts that modify dynamically chosen source files. All new records stay in `~/.agent-polygraph` (or `$AGENT_POLYGRAPH_HOME`). Recording starts with new sessions.
85
114
 
86
- `record --install --shell` adds one line to `~/.bashrc` and `~/.zshrc`. From then on, test commands you run in an interactive bash or zsh terminal are logged with their folder, start and end time and exit code. Output is never saved. If you ran the tests yourself in the session's folder after the agent's last edit, that run counts for the agent's claim. `record --uninstall --shell` removes the line.
115
+ `record --install --shell` adds one line to `~/.bashrc` and `~/.zshrc`. Supported test commands you run in interactive Bash/Zsh shells produce a minimal receipt: runner category, working directory, timestamps and exit code. Raw commands and output are never saved. To avoid unsafe shell redirection, the shell hook invokes the installed Node CLI, which validates and writes the receipt with private permissions and no-follow checks. **Use a persistent installation** (`npm i -g agent-polygraph` or a durable checkout) for shell integration; temporary `npx` cache paths may disappear. Shell mode recognizes a conservative subset of direct test commands. `record --uninstall --shell` removes its startup line.
87
116
 
88
117
  `--ci` goes online. Through your [`gh`](https://cli.github.com) login, it asks the GitHub API for the workflow runs of the repositories your sessions pushed to. The request contains the repository name and a date range, nothing from your sessions. A test workflow on a pushed commit counts as a test run of the code as it was at the push, so later edits still make it stale. Runs are matched to pushes by commit id, which has to appear in the session (in the push output, or from `git commit` or `git log` before it). Branch names and timing alone are not enough. With `--ci`, claims about CI itself ("CI is green", "all checks on the PR pass") are checked too. Workflows with no job or step named like a test, and runs GitHub never started, are ignored.
89
118
 
@@ -126,25 +155,29 @@ Everything is deterministic pattern matching over the logs. There is no LLM invo
126
155
 
127
156
  The rules are in [`src/classify.js`](src/classify.js) and the tests in [`test/`](test), including a labelled set of claim and non-claim sentences ([`test/claims.corpus.test.js`](test/claims.corpus.test.js)). A false positive or a missed claim makes a good [issue](https://github.com/EleChon-pixel/agent-polygraph/issues/new/choose); include the redacted sentence and it goes into that set.
128
157
 
129
- ## Accuracy
158
+ ## Validation and accuracy
159
+
160
+ The historical analysis of the author's own Claude Code and Codex sessions includes manual spot checks of claims and flagged evidence. In a small, author-selected review, approximately 95% of 25 detected claim candidates were genuine claims, and a separate sample suggested some real claims were missed. **These are preliminary, author-reviewed observations, not externally validated precision or recall.** See the [original evaluation protocol](docs/evaluation.md).
130
161
 
131
- I checked it by hand on my own logs, several hundred claims across Claude Code and Codex. Of 25 detected claims picked at random, about 95% were real claims. About 10% of real claims are missed, judging by a sample of sentences that mention tests passing but weren't detected; most of those also mention an unrelated failure ("passes alone, failed in the full run"). I traced every flag back through its session. Almost all were real unbacked claims, and the rest were borderline partial claims.
162
+ The public Claude Code evaluation reviewed 26 parsed sessions and identified 24 backed claims and three stale findings (one confirmed and two considered likely). It is useful for parser regression coverage but does **not** establish accuracy on an independent, representative population. A separately pinned, redacted holdout audit inspected 103 public sessions; under its conservative automated reference rules, **no eligible claims were found**, so **no holdout accuracy percentage was produced**. Additional dataset limitations and exact counts are documented in [live-evaluation.md](docs/live-evaluation.md).
132
163
 
133
- On other people's sessions (26 public Claude Code sessions, mostly from [Trace Commons](https://huggingface.co/datasets/trace-commons/agent-traces)) every verdict was reviewed by hand. The first run found parser gaps, such as Windows PowerShell sessions, custom test runners and Gradle, which are now fixed. The result is 89% backed. All 24 backed verdicts match the output the agent saw, and of the 3 stale flags, 1 is confirmed and 2 are likely. [docs/evaluation.md](docs/evaluation.md) has the method, every fix and a one-command reproduction.
164
+ Two reproducible controlled experiments test narrower propositions:
134
165
 
135
- An outside review found ten ways a verdict could go wrong (a later result backing an earlier claim, one output shared by several commands, failed edits counted as changes). They are fixed and kept as tests in [`test/review.test.js`](test/review.test.js).
166
+ - [Stop-hook replay](docs/benchmark.md): 25 authored Claude Code/Codex scenarios. Agent-polygraph correctly blocked 14/14 labeled unbacked claims versus 7/14 for a naive “any test passed earlier” hook, with zero false blocks in these selected cases.
167
+ - [Executed-test policy comparison](docs/live-evaluation.md): 12 authored scenarios with actual Node.js test executions. The selective guard required five extra test executions and detected three of seven failing final suites; running the full suite after every response required 12 executions and detected all seven.
136
168
 
137
- There was no public Codex data, so Codex has only been checked on my logs. Your phrasing and tools will differ from mine. Treat the numbers as a strong hint, and open the session file before relying on any one verdict.
169
+ The replay labels and scenarios were constructed by the project authors. The guard cannot discover failures the agent never claimed away, or infer whether a narrow passing test establishes a full-suite pass. **An independent, blinded human evaluation is still needed.** The [review issue](https://github.com/EleChon-pixel/agent-polygraph/issues/3) describes the protocol; `scripts/blind-review.mjs` prepares prediction-free review packets. No raw private session logs should be shared with reviewers or issues without permission and redaction.
138
170
 
139
171
  ## Limits
140
172
 
141
- These are the gaps that remain. `record` and `--ci` close most of them once you turn them on.
173
+ These are the gaps that remain. `record` and `--ci` can reduce some of them, but neither replaces running tests or provides independent proof of correctness.
142
174
 
143
175
  - Tests outside the logs. Tests the agent ran, test runs Claude Code recorded from hooks, commands you typed with `!` in Claude Code, and test output you pasted into the chat all count. So do your own bash and zsh runs with `record --shell`, and GitHub Actions runs on pushed commits with `--ci`. Other shells (fish, PowerShell, an IDE's test button), other CI services, and CI runs on commits the session never printed are still invisible.
144
176
  - "The full suite passes" is compared with the latest run about as large as the largest one in that folder, using the counts the runner printed. If no run printed a count, it's compared with the latest run, which may have been narrower. Claims that name no count, test file or full suite are compared with the latest run.
145
- - Output with no summary. Codex keeps each command's full output. Claude Code saves output that is too long for the transcript to a file, which is read as long as it exists, and `record` keeps the full result too. A run abandoned before printing a result leaves nothing to read.
177
+ - Output with no summary. Codex keeps each command's full output. Claude Code may save long output to a separate file that the analyzer reads if it still exists. New `record` entries retain **parsed test outcomes and pass counts, not raw test output**. A run abandoned before printing a result leaves nothing to read.
146
178
  - Files a script names at run time. Without `record` these make a claim "couldn't tell", never "backed". `record` settles them, but only in git checkouts and only for sessions after it was installed.
147
179
  - New agent versions can change the log format. Supported: Claude Code 2.x (Bash and PowerShell tools; Linux, macOS and Windows paths) and Codex CLI. The report lists the versions it read, warns about newer ones, and counts files it couldn't parse instead of dropping them silently.
180
+ - Unbacked is not the same as false. "Code edited after the last run" means the claim wasn't verified after the edit; the edit may have been harmless, such as a comment or a test file. The report can't tell whether the tests would have passed, because nobody ran them at that point.
148
181
  - The numbers describe your sessions, projects and prompts. They are not a benchmark of the models.
149
182
 
150
183
  ## Options
@@ -163,9 +196,31 @@ These are the gaps that remain. `record` and `--ci` close most of them once you
163
196
  --codex-dir <dir> Codex sessions dir (default ~/.codex/sessions, or $CODEX_HOME/sessions)
164
197
  ```
165
198
 
199
+ ## Running the repository version
200
+
201
+ The GitHub source and the npm-distributed package are different delivery paths. To try the **current `main` code** without assuming npm is up to date:
202
+
203
+ ```sh
204
+ git clone https://github.com/EleChon-pixel/agent-polygraph.git
205
+ cd agent-polygraph
206
+ node bin/polygraph.js --demo
207
+ npm test
208
+ npm run benchmark:replay
209
+ npm run benchmark:live
210
+ ```
211
+
212
+ Before distributing a new version, verify `npm view agent-polygraph version`, the published tarball (`npm pack --dry-run`), and that a clean install really contains the latest privacy and guard fixes. The repository declares version `0.2.0` as the **next release candidate**; until it is published, `npx agent-polygraph` still installs the older public registry version. A green GitHub Actions badge does **not** prove npm has been updated.
213
+
166
214
  ## Privacy
167
215
 
168
- It reads files and works offline. The exceptions are all opt-in:
216
+ New recordings intentionally minimize local data. The agent hook stores a keyed command fingerprint, the runner's result and optional fingerprints of source files. It does **not** save raw command text, test output or copies of repository files in its snapshot store. The shell integration saves only a recognized test-runner category, time, folder and exit code. Local record files, key and snapshot metadata are owner-only on Unix. Hook settings and shell startup files are replaced atomically, preserving their permissions and creating private recovery backups under `~/.agent-polygraph/backups` (see [recovery](docs/releasing.md#recovery-for-installed-hooks)). The secret fingerprint key is stored in `$AGENT_POLYGRAPH_HOME/record-key` (default `~/.agent-polygraph/record-key`).
217
+
218
+ **Existing installations:** records made before this update may still include full commands, and older versions may have created a local Git object store with duplicate source contents. Upgrading does **not** remove those files. After reviewing whether you still need the historical evidence, uninstall recording and manually remove the old `~/.agent-polygraph/records` and `~/.agent-polygraph/objects` directories. Removing historical records means those sessions will lose their supplemental evidence; the original Claude Code/Codex transcripts remain untouched. Do not delete `record-key` while retaining version 2 records that rely on matching fingerprints.
219
+
220
+ Stored fingerprints are **not** a guarantee of anonymity: recording metadata includes timestamps, working directories and relative source-file paths, and existing v1 records may still contain raw commands. The original Claude Code/Codex transcripts can also contain secrets and confidential data. The terminal report and `--json` may quote transcript text and paths; do not share them without review. The `--html` report intentionally excludes excerpts, prompts, commands and paths. The recorder only supports bounded source-file snapshots in Git checkouts and skips symbolic links; when evidence cannot be read it must stay inconclusive rather than certifying a pass. New source-file fingerprints include relative filenames and are not an anonymization guarantee.
221
+
222
+
223
+ It reads files and works offline. These operations are opt-in:
169
224
 
170
225
  - `guard` and `record` edit the hook config files they print when you install or uninstall them, and `record --shell` adds or removes one marked line in `~/.bashrc` and `~/.zshrc`.
171
226
  - `record` saves snapshots and logs in `~/.agent-polygraph`.
package/bin/polygraph.js CHANGED
@@ -67,11 +67,18 @@ async function record(argv) {
67
67
  uninstall: { type: "boolean" },
68
68
  global: { type: "boolean" },
69
69
  shell: { type: "boolean" },
70
+ "shell-event": { type: "boolean" },
70
71
  agent: { type: "string", multiple: true },
71
72
  command: { type: "string" },
72
73
  },
73
74
  });
74
75
  const rec = await import("../src/record.js");
76
+ if (values["shell-event"]) {
77
+ const chunks = [];
78
+ for await (const piece of process.stdin) chunks.push(piece);
79
+ rec.runShellRecord(Buffer.concat(chunks).toString("utf8"));
80
+ return;
81
+ }
75
82
  if (!values.install && !values.uninstall) {
76
83
  // invoked by the agent as a PostToolUse hook: quiet, and never in the way
77
84
  const chunks = [];
@@ -86,10 +93,14 @@ async function record(argv) {
86
93
  for (const r of hooks) {
87
94
  const verb = values.install ? (r.changed ? "added PostToolUse hook to" : "already installed in") : r.changed ? "removed PostToolUse hook from" : "nothing to remove in";
88
95
  console.log(`${r.agent === "claude" ? "Claude Code" : "Codex"}: ${verb} ${r.file}`);
96
+ if (r.backup) console.log(` Recovery backup: ${r.backup}`);
89
97
  }
90
98
  if (values.shell) {
91
99
  const out = values.install ? rec.installShell() : rec.uninstallShell();
92
- for (const r of out.rcs) console.log(`Shell: ${values.install ? (r.changed ? "added a line to" : "already in") : "removed the line from"} ${r.file}`);
100
+ for (const r of out.rcs) {
101
+ console.log(`Shell: ${values.install ? (r.changed ? "added a line to" : "already in") : "removed the line from"} ${r.file}`);
102
+ if (r.backup) console.log(` Recovery backup: ${r.backup}`);
103
+ }
93
104
  if (values.install) console.log(`Shell: ${out.rcs.length ? "open a new terminal to start logging" : "no ~/.bashrc or ~/.zshrc found; source " + out.script + " from your shell's startup file"}. Only interactive bash and zsh are supported.`);
94
105
  }
95
106
  if (values.install) {
@@ -111,6 +122,7 @@ async function guard(argv) {
111
122
  global: { type: "boolean" },
112
123
  agent: { type: "string", multiple: true },
113
124
  command: { type: "string" },
125
+ "from-plugin": { type: "boolean" },
114
126
  },
115
127
  });
116
128
  const { runHook, install, uninstall } = await import("../src/guard.js");
@@ -119,7 +131,7 @@ async function guard(argv) {
119
131
  try {
120
132
  const chunks = [];
121
133
  for await (const c of process.stdin) chunks.push(c);
122
- const out = await runHook(Buffer.concat(chunks).toString("utf8"));
134
+ const out = await runHook(Buffer.concat(chunks).toString("utf8"), { plugin: values["from-plugin"] });
123
135
  if (out) process.stdout.write(JSON.stringify(out));
124
136
  } catch (e) {
125
137
  if (process.env.POLYGRAPH_DEBUG) console.error(e);
@@ -131,6 +143,7 @@ async function guard(argv) {
131
143
  for (const r of results) {
132
144
  const verb = values.install ? (r.changed ? "added Stop hook to" : "already installed in") : r.changed ? "removed Stop hook from" : "nothing to remove in";
133
145
  console.log(`${r.agent === "claude" ? "Claude Code" : "Codex"}: ${verb} ${r.file}`);
146
+ if (r.backup) console.log(` Recovery backup: ${r.backup}`);
134
147
  }
135
148
  if (values.install) {
136
149
  console.log(`\nHook command: ${results[0].command}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-polygraph",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Check your coding agents' \"tests pass\" claims against what actually ran. Reads Claude Code and Codex session logs locally.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,8 +14,11 @@
14
14
  "LICENSE"
15
15
  ],
16
16
  "scripts": {
17
- "test": "node --test 'test/*.test.js'",
18
- "start": "node bin/polygraph.js"
17
+ "test": "node --test",
18
+ "start": "node bin/polygraph.js",
19
+ "benchmark:demo": "node scripts/benchmark.mjs --demo",
20
+ "benchmark:replay": "node scripts/benchmark-guard-replay.mjs",
21
+ "benchmark:live": "node scripts/benchmark-live.mjs"
19
22
  },
20
23
  "engines": {
21
24
  "node": ">=18.3"
package/src/analyze.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import os from "node:os";
2
+ import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import {
4
5
  isTestCommand,
@@ -211,7 +212,7 @@ export function analyzeSession(session) {
211
212
  /** Files that differ between two `record` snapshots of one checkout, or null if unknown. */
212
213
  const snapshotDiff = (a, b) => {
213
214
  if (!a || !b || !session.treeDiff || a.root !== b.root) return null;
214
- if (a.tree === b.tree) return [];
215
+ if (a.tree && b.tree && a.tree === b.tree) return [];
215
216
  try {
216
217
  const out = session.treeDiff(a, b);
217
218
  return Array.isArray(out) ? out : null;
@@ -364,7 +365,16 @@ export function analyzeSession(session) {
364
365
  uncertain = false;
365
366
  s.evidence.snapshot = (s.evidence.snapshot || 0) + 1;
366
367
  // the whole checkout is compared; only changes where the test ran count, as for edits
367
- const code = changed.filter((p) => isCodePath(p) && !SCRATCH_NAME.test(p.split("/").pop()) && within(path.posix.join(norm(ref.tree.root), p), ref.dir));
368
+ // Git may report the canonical checkout path even when the transcript
369
+ // used a symlinked directory (/var vs /private/var on macOS), and
370
+ // Windows requires native path joining for drive-letter roots.
371
+ const canonical = (p) => {
372
+ try { return fs.realpathSync.native(p); } catch { return p; }
373
+ };
374
+ const canonicalRoot = canonical(ref.tree.root);
375
+ const canonicalDir = canonical(ref.dir || session.cwd || ref.tree.root);
376
+ const code = changed.filter((p) => isCodePath(p) && !SCRATCH_NAME.test(p.split("/").pop()) &&
377
+ within(path.resolve(canonicalRoot, ...p.split("/")), canonicalDir));
368
378
  if (code.length) staleEdit = { path: code[0], snapshot: true };
369
379
  }
370
380
  }
package/src/guard.js CHANGED
@@ -11,10 +11,11 @@ import * as codex from "./sources/codex.js";
11
11
  import { analyzeSession } from "./analyze.js";
12
12
  import { findTestClaim, findTestClaims } from "./classify.js";
13
13
  import { applyRecords } from "./record.js";
14
+ import { readSettings, writeSettings } from "./safe-config.js";
14
15
 
15
16
 
16
17
  /** Hook entry point: read the Stop payload on stdin, maybe print a block decision. */
17
- export async function runHook(stdinText) {
18
+ export async function runHook(stdinText, { plugin = false } = {}) {
18
19
  let payload;
19
20
  try {
20
21
  payload = JSON.parse(stdinText || "{}");
@@ -22,6 +23,9 @@ export async function runHook(stdinText) {
22
23
  return null;
23
24
  }
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;
25
29
  const transcript = payload.transcript_path;
26
30
  if (!transcript || !fs.existsSync(transcript)) return null;
27
31
 
@@ -81,6 +85,23 @@ function reasonFor(d) {
81
85
  ].join(" ");
82
86
  }
83
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
+
84
105
  function sniffCodex(file) {
85
106
  try {
86
107
  const fd = fs.openSync(file, "r");
@@ -180,7 +201,8 @@ export function install(opts) {
180
201
  }
181
202
  if (changed) {
182
203
  fs.mkdirSync(path.dirname(file), { recursive: true });
183
- fs.writeFileSync(file, JSON.stringify(config, null, 2) + "\n");
204
+ const save = writeSettings(file, JSON.stringify(config, null, 2) + "\n");
205
+ return { agent, file, changed, command, backup: save.backup };
184
206
  }
185
207
  return { agent, file, changed, command };
186
208
  });
@@ -201,14 +223,16 @@ export function uninstall(opts) {
201
223
  if (kept.length) config.hooks[event] = kept;
202
224
  else delete config.hooks[event];
203
225
  if (!Object.keys(config.hooks).length) delete config.hooks;
204
- fs.writeFileSync(file, JSON.stringify(config, null, 2) + "\n");
205
- return { agent, file, changed: true };
226
+ const save = writeSettings(file, JSON.stringify(config, null, 2) + "\n");
227
+ return { agent, file, changed: true, backup: save.backup };
206
228
  });
207
229
  }
208
230
 
209
231
  function readJson(file) {
210
232
  try {
211
- return JSON.parse(fs.readFileSync(file, "utf8"));
233
+ const { text } = readSettings(file);
234
+ if (text === null) return {};
235
+ return JSON.parse(text);
212
236
  } catch (e) {
213
237
  if (e.code === "ENOENT") return {};
214
238
  throw new Error(`${file} is not valid JSON; fix or remove it first (${e.message})`);
package/src/record.js CHANGED
@@ -1,11 +1,9 @@
1
1
  // `agent-polygraph record`: optional evidence the session logs don't carry.
2
2
  //
3
- // - A PostToolUse hook (Claude Code and Codex). After a test command or a command that may
4
- // write files, it saves a snapshot of the working tree (a git tree id, stored in
5
- // agent-polygraph's own object store so your repository is never written to) and, for
6
- // test commands, the outcome the runner printed in full. With snapshots, a change the
7
- // logs can't follow ("a script that computes its file names") is settled by comparing the
8
- // tree after the test run with the tree before the claim.
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.
9
7
  // - Shell integration (bash, zsh): test commands you run in your own terminals are logged
10
8
  // with their folder, start and end time and exit code. No output is kept.
11
9
  //
@@ -15,14 +13,112 @@ import fs from "node:fs";
15
13
  import os from "node:os";
16
14
  import path from "node:path";
17
15
  import crypto from "node:crypto";
16
+ import { fileURLToPath } from "node:url";
17
+ import { readSettings, writeSettings } from "./safe-config.js";
18
18
  import { execFileSync } from "node:child_process";
19
- import { isTestCommand, mayWriteFiles, shellWrites, outcomeFromOutput, passedCount } from "./classify.js";
19
+ import { isTestCommand, isCodePath, mayWriteFiles, shellWrites, outcomeFromOutput, passedCount } from "./classify.js";
20
20
 
21
21
  export const home = () => process.env.AGENT_POLYGRAPH_HOME || path.join(os.homedir(), ".agent-polygraph");
22
22
  const hash = (s) => crypto.createHash("sha1").update(String(s)).digest("hex").slice(0, 16);
23
23
  export const recordsFile = (transcript) => path.join(home(), "records", hash(path.resolve(transcript)) + ".jsonl");
24
24
  export const terminalFile = () => path.join(home(), "records", "terminal.jsonl");
25
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
+
26
122
  // --- the hook --------------------------------------------------------------------------
27
123
 
28
124
  /** PostToolUse entry point. Never throws, never prints: a recorder must not get in the way. */
@@ -34,7 +130,7 @@ export function runRecordHook(stdinText) {
34
130
  const test = isTestCommand(cmd);
35
131
  if (!test && !mayWriteFiles(cmd) && !shellWrites(cmd).unresolved) return;
36
132
  const cwd = p.cwd || process.cwd();
37
- const rec = { v: 1, ts: Date.now(), id: p.tool_use_id || null, tool: p.tool_name || null, cmd, cwd };
133
+ const rec = { v: 2, ts: Date.now(), id: p.tool_use_id || null, tool: p.tool_name || null, cmdKey: commandFingerprint(cmd), cwd };
38
134
  try {
39
135
  rec.tree = snapshot(cwd);
40
136
  } catch {}
@@ -47,8 +143,7 @@ export function runRecordHook(stdinText) {
47
143
  }
48
144
  if (!rec.tree && !rec.outcome) return;
49
145
  const file = recordsFile(p.transcript_path);
50
- fs.mkdirSync(path.dirname(file), { recursive: true });
51
- fs.appendFileSync(file, JSON.stringify(rec) + "\n");
146
+ secureWrite(file, JSON.stringify(rec) + "\n", true);
52
147
  } catch {}
53
148
  }
54
149
 
@@ -69,46 +164,90 @@ function outputOf(r) {
69
164
  return parts.filter((x) => typeof x === "string").join("\n");
70
165
  }
71
166
 
72
- // --- working-tree snapshots ---------------------------------------------------------------
167
+ // --- working-tree fingerprints -------------------------------------------------------------
73
168
 
74
- /**
75
- * The working tree as a git tree id, untracked files included, ignored files left out.
76
- * Built with a temporary index and a private object store (the repository's own objects
77
- * are read through git's alternates), so nothing in the repository changes.
78
- */
79
- export function snapshot(cwd) {
80
- const git = (args, env = {}, dir = cwd) =>
81
- execFileSync("git", args, { cwd: dir, env: { ...process.env, ...env }, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 8000, maxBuffer: 8 << 20 }).trim();
82
- const root = git(["rev-parse", "--show-toplevel"]);
83
- const [index, objects] = git(["rev-parse", "--git-path", "index", "--git-path", "objects"])
84
- .split("\n")
85
- .map((p) => path.resolve(cwd, p));
86
- const store = path.join(home(), "objects", hash(objects));
87
- fs.mkdirSync(store, { recursive: true });
88
- const tmp = path.join(os.tmpdir(), `agent-polygraph-${process.pid}-${Date.now()}.index`);
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);
89
194
  try {
90
- // starting from the real index keeps git's stat cache: only changed files are hashed
91
- if (fs.existsSync(index)) fs.copyFileSync(index, tmp);
92
- const env = { GIT_INDEX_FILE: tmp, GIT_OBJECT_DIRECTORY: store, GIT_ALTERNATE_OBJECT_DIRECTORIES: objects };
93
- git(["add", "-A", "--ignore-errors", "--", "."], env, root);
94
- const tree = git(["write-tree"], env, root);
95
- return { root, tree, objects, store };
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") };
96
214
  } finally {
97
- fs.rmSync(tmp, { force: true });
215
+ fs.closeSync(fd);
98
216
  }
99
217
  }
100
218
 
101
- /** Paths (relative to the checkout) that differ between two snapshots of it. */
102
- export function treeDiff(a, b) {
103
- const gitDir = path.dirname(a.objects);
104
- const out = execFileSync("git", ["diff", "--name-only", "-z", "--no-renames", a.tree, b.tree], {
105
- env: { ...process.env, GIT_DIR: gitDir, GIT_OBJECT_DIRECTORY: a.store, GIT_ALTERNATE_OBJECT_DIRECTORIES: [a.objects, b.store].join(path.delimiter) },
106
- encoding: "utf8",
107
- stdio: ["ignore", "pipe", "ignore"],
108
- timeout: 10000,
109
- maxBuffer: 16 << 20,
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,
110
223
  });
111
- return out.split("\0").filter(Boolean);
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]);
112
251
  }
113
252
 
114
253
  // --- using the records -----------------------------------------------------------------------
@@ -127,11 +266,12 @@ export function applyRecords(session, { terminal = loadTerminal() } = {}) {
127
266
  if (recs.length) {
128
267
  session.treeDiff = treeDiff;
129
268
  // a record belongs to the command with the same text, recorded after it started
130
- const free = recs.map((r) => ({ ...r, key: squash(r.cmd) }));
269
+ const free = recs.map((r) => ({ ...r, key: r.cmdKey || (r.cmd ? squash(r.cmd) : null) }));
131
270
  for (const ev of session.events) {
132
271
  if (ev.kind !== "cmd" || ev.external) continue;
133
- const key = squash(ev.cmd);
134
- const k = free.findIndex((r) => r && (ev.id && r.id ? r.id === ev.id : r.key === key && r.ts >= (ev.ts ?? 0) - 2000));
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));
135
275
  if (k < 0) continue;
136
276
  const r = free[k];
137
277
  free[k] = null;
@@ -154,6 +294,24 @@ export function loadTerminal() {
154
294
  return terminalCache;
155
295
  }
156
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
+
157
315
  function addTerminalRuns(session, runs) {
158
316
  if (!runs.length || !session.cwd || session.start == null || session.end == null) return;
159
317
  const cwd = slash(session.cwd);
@@ -165,8 +323,9 @@ function addTerminalRuns(session, runs) {
165
323
  for (const r of runs) {
166
324
  const start = Math.round(r.start * 1000);
167
325
  const end = Math.round((r.end ?? r.start) * 1000);
168
- if (!(start >= session.start && start <= session.end) || !r.cwd || !related(r.cwd) || !isTestCommand(r.cmd)) continue;
169
- const ev = { kind: "cmd", ts: start, seq: Number.MAX_SAFE_INTEGER, cmd: r.cmd, workdir: r.cwd, external: "terminal", sub: false };
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 };
170
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: "" });
171
330
  added++;
172
331
  }
@@ -191,26 +350,30 @@ function readLines(file) {
191
350
  const MARK = "# agent-polygraph record";
192
351
  export const shellScript = () => path.join(home(), "shell.sh");
193
352
 
194
- const SHELL_SNIPPET = () => fs.readFileSync(new URL("./shell/record.sh", import.meta.url), "utf8");
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)))));
195
357
 
196
- const RC_FILES = () => [".bashrc", ".zshrc"].map((f) => path.join(os.homedir(), f));
358
+ const RC_FILES = () => [".bashrc", ".zshrc"].map((f) => path.join(process.env.HOME || os.homedir(), f));
197
359
 
198
360
  export function installShell() {
199
361
  const script = shellScript();
200
- fs.mkdirSync(path.join(home(), "records"), { recursive: true });
201
- fs.writeFileSync(script, SHELL_SNIPPET());
202
- const line = `[ -f "${script}" ] && . "${script}" ${MARK}`;
362
+ secureWrite(script, SHELL_SNIPPET());
363
+ secureDirectory(path.join(home(), "records"));
364
+ const quoted = shQuote(script);
365
+ const line = `[ -f ${quoted} ] && . ${quoted} ${MARK}`;
203
366
  const out = [];
204
367
  for (const rc of RC_FILES()) {
205
368
  const shell = rc.endsWith("zshrc") ? "zsh" : "bash";
206
369
  if (!fs.existsSync(rc) && !onPath(shell)) continue;
207
- const text = fs.existsSync(rc) ? fs.readFileSync(rc, "utf8") : "";
370
+ const text = readSettings(rc).text ?? "";
208
371
  if (text.includes(MARK)) {
209
372
  out.push({ file: rc, changed: false });
210
373
  continue;
211
374
  }
212
- fs.appendFileSync(rc, (text && !text.endsWith("\n") ? "\n" : "") + line + "\n");
213
- out.push({ file: rc, changed: true });
375
+ const result = writeSettings(rc, text + (text && !text.endsWith("\n") ? "\n" : "") + line + "\n");
376
+ out.push({ file: rc, changed: result.changed, backup: result.backup });
214
377
  }
215
378
  return { script, rcs: out };
216
379
  }
@@ -219,10 +382,11 @@ export function uninstallShell() {
219
382
  const out = [];
220
383
  for (const rc of RC_FILES()) {
221
384
  if (!fs.existsSync(rc)) continue;
222
- const text = fs.readFileSync(rc, "utf8");
385
+ const text = readSettings(rc).text;
386
+ if (text == null) continue;
223
387
  if (!text.includes(MARK)) continue;
224
- fs.writeFileSync(rc, text.split("\n").filter((l) => !l.includes(MARK)).join("\n"));
225
- out.push({ file: rc, changed: true });
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 });
226
390
  }
227
391
  fs.rmSync(shellScript(), { force: true });
228
392
  return { rcs: out };
@@ -0,0 +1,85 @@
1
+ // Atomic, permission-preserving edits for user-owned hook and shell settings.
2
+ // Original content is backed up outside the repository before every change.
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import crypto from "node:crypto";
7
+
8
+ const NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
9
+ const statKey = (s) => [s.dev, s.ino, s.size, s.mtimeNs, s.ctimeNs].map(String).join(":");
10
+ const backupRoot = () => path.join(process.env.AGENT_POLYGRAPH_HOME || path.join(os.homedir(), ".agent-polygraph"), "backups");
11
+
12
+ function checkDirectory(dir, create = false) {
13
+ if (create) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
14
+ const st = fs.lstatSync(dir);
15
+ if (!st.isDirectory() || st.isSymbolicLink()) throw new Error("Unsafe settings directory: " + dir);
16
+ // A directory owned by someone else could redirect the settings file.
17
+ if (process.platform !== "win32" && st.uid !== process.getuid()) throw new Error("Settings directory is not owned by this user: " + dir);
18
+ }
19
+
20
+ export function readSettings(file) {
21
+ let fd;
22
+ try {
23
+ const st = fs.lstatSync(file, { bigint: true });
24
+ if (!st.isFile() || st.nlink !== 1n) throw new Error("Unsafe settings file: " + file);
25
+ fd = fs.openSync(file, fs.constants.O_RDONLY | NOFOLLOW);
26
+ const opened = fs.fstatSync(fd, { bigint: true });
27
+ if (!opened.isFile() || opened.nlink !== 1n || opened.dev !== st.dev || opened.ino !== st.ino)
28
+ throw new Error("Settings file changed while opening: " + file);
29
+ if (process.platform !== "win32" && opened.uid !== BigInt(process.getuid()))
30
+ throw new Error("Settings file belongs to another user: " + file);
31
+ const data = fs.readFileSync(fd);
32
+ const after = fs.fstatSync(fd, { bigint: true });
33
+ if (statKey(after) !== statKey(opened)) throw new Error("Settings file changed during read: " + file);
34
+ return { text: data.toString("utf8"), stat: after, mode: Number(opened.mode & 0o777n) };
35
+ } catch (e) {
36
+ if (e.code === "ENOENT") return { text: null, stat: null, mode: 0o600 };
37
+ throw e;
38
+ } finally {
39
+ if (fd !== undefined) fs.closeSync(fd);
40
+ }
41
+ }
42
+
43
+ function backupOriginal(file, previous) {
44
+ if (previous.text === null) return null;
45
+ const dir = backupRoot();
46
+ checkDirectory(dir, true);
47
+ if (process.platform !== "win32") fs.chmodSync(dir, 0o700);
48
+ const id = crypto.createHash("sha256").update(path.resolve(file)).digest("hex").slice(0, 16);
49
+ const target = path.join(dir, id + "-" + Date.now() + "-" + crypto.randomBytes(5).toString("hex") + ".bak");
50
+ fs.writeFileSync(target, previous.text, { flag: "wx", mode: 0o600 });
51
+ return target;
52
+ }
53
+
54
+ // Never truncate the user's existing config. Write a sibling temp file, fsync,
55
+ // and atomically rename it only after confirming the source is still unchanged.
56
+ export function writeSettings(file, text) {
57
+ const parent = path.dirname(file);
58
+ checkDirectory(parent, true);
59
+ const previous = readSettings(file);
60
+ if (previous.text === text) return { changed: false, backup: null };
61
+ const backup = backupOriginal(file, previous);
62
+ const tmp = path.join(parent, "." + path.basename(file) + ".apg-" + crypto.randomBytes(10).toString("hex"));
63
+ let created = false;
64
+ try {
65
+ const fd = fs.openSync(tmp, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | NOFOLLOW, previous.mode);
66
+ created = true;
67
+ try {
68
+ fs.writeFileSync(fd, text);
69
+ fs.fsyncSync(fd);
70
+ } finally {
71
+ fs.closeSync(fd);
72
+ }
73
+ const current = readSettings(file);
74
+ if ((previous.stat === null) !== (current.stat === null) ||
75
+ (previous.stat && statKey(previous.stat) !== statKey(current.stat)))
76
+ throw new Error("Settings changed concurrently; original not overwritten: " + file);
77
+ fs.renameSync(tmp, file);
78
+ created = false;
79
+ return { changed: true, backup };
80
+ } finally {
81
+ if (created) fs.rmSync(tmp, { force: true });
82
+ }
83
+ }
84
+
85
+ export function backupDirectory() { return backupRoot(); }
@@ -1,37 +1,51 @@
1
- # agent-polygraph record: logs test commands you run in this terminal (folder, start and end
2
- # time, exit code; never their output) to ~/.agent-polygraph/records/terminal.jsonl, so a
3
- # test you ran yourself counts as evidence. Stays on this machine.
1
+ # agent-polygraph record: store only a recognized test-runner label, timestamps,
2
+ # working directory and exit code. Never persist the shell command or its output.
4
3
  # Remove with: agent-polygraph record --uninstall --shell
5
4
  case $- in *i*) ;; *) return 0 2>/dev/null ;; esac
6
- # not inside coding agents' own shells: their commands are in their logs already
7
5
  [ -n "${CLAUDECODE:-}${CODEX_SANDBOX:-}${CODEX_THREAD_ID:-}${CODEX_MANAGED_BY_NPM:-}" ] && return 0 2>/dev/null
8
6
 
9
- __apg_file="${AGENT_POLYGRAPH_HOME:-$HOME/.agent-polygraph}/records/terminal.jsonl"
10
- # a cheap prefilter; agent-polygraph applies its real test-command rules when it reads the log
11
- __apg_re='(test|spec|pytest|jest|vitest|mocha|rspec|phpunit|ctest|tox|nox|check|verify)'
12
- __apg_cmd=
7
+ # Substituted with an installed, persistent CLI path by record --install --shell.
8
+ __apg_node=__APG_NODE__
9
+ __apg_entry=__APG_ENTRY__
10
+ __apg_runner=
13
11
 
14
12
  __apg_now() {
15
13
  if [ -n "${EPOCHREALTIME:-}" ]; then printf '%s' "${EPOCHREALTIME/,/.}"; else date +%s; fi
16
14
  }
17
15
  __apg_esc() {
18
16
  local s=$1
19
- s=${s//\\/\\\\}; s=${s//\"/\\\"}; s=${s//$'\n'/\\n}; s=${s//$'\t'/\\t}; s=${s//$'\r'/}
17
+ s=${s//\\/\\\\}; s=${s//\"/\\\"}; s=${s//$'\n'/\\n}; s=${s//$'\t'/\\t}; s=${s//$'\r'/\\r}
20
18
  printf '%s' "$s"
21
19
  }
22
20
  __apg_pre() {
23
- __apg_cmd=
24
- [[ $1 =~ $__apg_re ]] || return 0
25
- __apg_cmd=$1
21
+ __apg_runner=
22
+ # Conservative: do not treat compound commands (e.g. npm test; true) as proof.
23
+ case "$1" in *';'*|*'|'*|*'&'*|*'`'*|*'$('*|*$'\n'*) return 0 ;; esac
24
+ local c="${1#${1%%[![:space:]]*}}"
25
+ case "$c" in
26
+ "pytest"|"pytest "*|"python -m pytest"*|"python3 -m pytest"*) __apg_runner="pytest" ;;
27
+ "npm test"|"npm test "*|"npm run test"|"npm run test "*|"npm run test:"*) __apg_runner="npm test" ;;
28
+ "node --test"|"node --test "*) __apg_runner="node --test" ;;
29
+ "go test"|"go test "*) __apg_runner="go test" ;;
30
+ "cargo test"|"cargo test "*) __apg_runner="cargo test" ;;
31
+ "mvn test"|"mvn test "*) __apg_runner="mvn test" ;;
32
+ "gradle test"|"gradle test "*|"./gradlew test"|"./gradlew test "*) __apg_runner="gradle test" ;;
33
+ "dotnet test"|"dotnet test "*) __apg_runner="dotnet test" ;;
34
+ "vitest"|"vitest "*|"jest"|"jest "*) __apg_runner="vitest run" ;;
35
+ esac
36
+ [ -n "$__apg_runner" ] || return 0
26
37
  __apg_dir=$PWD
27
38
  __apg_t0=$(__apg_now)
28
39
  }
29
40
  __apg_post() {
30
41
  local s=$?
31
- if [ -n "$__apg_cmd" ]; then
32
- printf '{"v":1,"kind":"terminal","start":%s,"end":%s,"exit":%s,"cwd":"%s","cmd":"%s"}\n' \
33
- "$__apg_t0" "$(__apg_now)" "$s" "$(__apg_esc "$__apg_dir")" "$(__apg_esc "$__apg_cmd")" >>"$__apg_file" 2>/dev/null
34
- __apg_cmd=
42
+ if [ -n "$__apg_runner" ]; then
43
+ # The CLI validates input and writes with O_NOFOLLOW; shell redirection
44
+ # cannot be used here because it would follow an attacker-created symlink.
45
+ printf '{"v":2,"kind":"terminal","start":%s,"end":%s,"exit":%s,"cwd":"%s","runner":"%s"}\n' \
46
+ "$__apg_t0" "$(__apg_now)" "$s" "$(__apg_esc "$__apg_dir")" "$__apg_runner" |
47
+ "$__apg_node" "$__apg_entry" record --shell-event >/dev/null 2>&1
48
+ __apg_runner=
35
49
  fi
36
50
  return $s
37
51
  }
@@ -40,11 +54,9 @@ if [ -n "${ZSH_VERSION:-}" ]; then
40
54
  autoload -Uz add-zsh-hook && add-zsh-hook preexec __apg_pre && add-zsh-hook precmd __apg_post
41
55
  elif [ -n "${BASH_VERSION:-}" ]; then
42
56
  if [ -n "${bash_preexec_imported:-}${__bp_imported:-}" ]; then
43
- # bash-preexec is loaded: use its hooks instead of a DEBUG trap of our own
44
57
  preexec_functions+=(__apg_pre)
45
58
  precmd_functions=(__apg_post "${precmd_functions[@]}")
46
59
  elif [ -z "$(trap -p DEBUG)" ]; then
47
- # one DEBUG trap call per command line: $HISTCMD changes only when a new line is entered
48
60
  __apg_last=$HISTCMD
49
61
  __apg_debug() {
50
62
  [[ ${BASH_COMMAND:-} == __apg_* ]] && return