agent-polygraph 0.1.0 → 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/README.md +73 -18
- package/bin/polygraph.js +15 -2
- package/package.json +6 -3
- package/src/analyze.js +12 -2
- package/src/guard.js +29 -5
- package/src/record.js +223 -59
- package/src/safe-config.js +85 -0
- package/src/shell/record.sh +30 -18
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
|
-
|
|
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
|
-
|
|
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 release status:** The [public npm registry](https://www.npmjs.com/package/agent-polygraph) still serves **0.1.0**, while GitHub `main` contains the security-hardened **0.2.0 release candidate**. The automated tarball test passes, but new npm code will not be available to `npx` until it has been published. For the exact manual publishing procedure (GitHub OIDC trusted publisher), 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
|
-
##
|
|
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
|
|
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
|
-
|
|
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
|
|
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`.
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
164
|
+
Two reproducible controlled experiments test narrower propositions:
|
|
134
165
|
|
|
135
|
-
|
|
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
|
-
|
|
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`
|
|
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
|
|
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
|
|
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)
|
|
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.
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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:
|
|
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
|
-
|
|
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
|
|
167
|
+
// --- working-tree fingerprints -------------------------------------------------------------
|
|
73
168
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
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.
|
|
215
|
+
fs.closeSync(fd);
|
|
98
216
|
}
|
|
99
217
|
}
|
|
100
218
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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 =
|
|
134
|
-
const
|
|
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
|
-
|
|
169
|
-
|
|
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
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
const
|
|
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 =
|
|
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
|
-
|
|
213
|
-
out.push({ file: rc, changed:
|
|
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 =
|
|
385
|
+
const text = readSettings(rc).text;
|
|
386
|
+
if (text == null) continue;
|
|
223
387
|
if (!text.includes(MARK)) continue;
|
|
224
|
-
|
|
225
|
-
out.push({ file: rc, changed:
|
|
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(); }
|
package/src/shell/record.sh
CHANGED
|
@@ -1,37 +1,51 @@
|
|
|
1
|
-
# agent-polygraph record:
|
|
2
|
-
#
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 "$
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|