agent-polygraph 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +232 -2
- package/bin/polygraph.js +274 -0
- package/package.json +41 -4
- package/src/analyze.js +621 -0
- package/src/ci.js +204 -0
- package/src/classify.js +506 -0
- package/src/demo.js +120 -0
- package/src/guard.js +240 -0
- package/src/jsonl.js +23 -0
- package/src/record.js +402 -0
- package/src/report/format.js +87 -0
- package/src/report/html.js +101 -0
- package/src/report/terminal.js +160 -0
- package/src/safe-config.js +85 -0
- package/src/scan.js +60 -0
- package/src/shell/record.sh +73 -0
- package/src/sources/claude.js +300 -0
- package/src/sources/codex.js +476 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export const AGENT_NAMES = { claude: "Claude Code", codex: "Codex" };
|
|
2
|
+
|
|
3
|
+
export function duration(ms) {
|
|
4
|
+
if (!ms) return "0m";
|
|
5
|
+
const m = ms / 60000;
|
|
6
|
+
if (m < 1) return `${Math.round(ms / 1000)}s`;
|
|
7
|
+
if (m < 60) return `${Math.round(m)}m`;
|
|
8
|
+
const h = m / 60;
|
|
9
|
+
return h < 10 ? `${h.toFixed(1)}h` : `${Math.round(h)}h`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function pct(x) {
|
|
13
|
+
return x == null ? "–" : `${Math.round(x * 100)}%`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function day(ts, opts = { month: "short", day: "numeric" }) {
|
|
17
|
+
return ts ? new Date(ts).toLocaleDateString("en-US", opts) : "?";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function range(g) {
|
|
21
|
+
if (!g.start || !isFinite(g.start)) return "";
|
|
22
|
+
const y = { month: "short", day: "numeric", year: "numeric" };
|
|
23
|
+
return `${day(g.start)} – ${day(g.end, y)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Columns to show: one per agent (or model) that has sessions, plus "All" when there are several. */
|
|
27
|
+
export function columns(groups, by = "agent") {
|
|
28
|
+
const cols = Object.entries(groups)
|
|
29
|
+
.filter(([k, g]) => k.startsWith(by + ":") && g.activeSessions > 0)
|
|
30
|
+
.sort((a, b) => b[1].activeSessions - a[1].activeSessions)
|
|
31
|
+
.map(([k, g]) => ({ key: k, label: by === "agent" ? AGENT_NAMES[k.slice(6)] || k.slice(6) : k.slice(6), g }));
|
|
32
|
+
if (cols.length !== 1) cols.push({ key: "all", label: "All", g: groups.all });
|
|
33
|
+
return cols;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const ROWS = {
|
|
37
|
+
claims: [
|
|
38
|
+
["Backed by a fresh passing run", (g) => g.claims.backed],
|
|
39
|
+
["No test run in the session", (g) => g.claims["no-run"]],
|
|
40
|
+
["Code edited after the last run", (g) => g.claims.stale],
|
|
41
|
+
["Last run had failed", (g) => g.claims.failed],
|
|
42
|
+
["Couldn't tell", (g) => g.claims.unknown],
|
|
43
|
+
],
|
|
44
|
+
unclear: [
|
|
45
|
+
["…test result unreadable", (g) => g.unclear["test result unreadable"] || 0],
|
|
46
|
+
["…unresolved file change after the run", (g) => g.unclear["unresolved file change after the run"] || 0],
|
|
47
|
+
["…test still running", (g) => g.unclear["test still running"] || 0],
|
|
48
|
+
["…other", (g) => Object.entries(g.unclear).filter(([k]) => !/unreadable|unresolved|still running/.test(k)).reduce((a, [, v]) => a + v, 0)],
|
|
49
|
+
],
|
|
50
|
+
integrity: [
|
|
51
|
+
['Failing test "fixed" by editing only tests', (g) => g.findingCounts["test-only-fix"] || 0],
|
|
52
|
+
["Edits that weakened an existing test", (g) => g.findingCounts["weakened-test"] || 0],
|
|
53
|
+
],
|
|
54
|
+
time: [
|
|
55
|
+
["Test runs", (g) => g.testRuns],
|
|
56
|
+
["…that failed", (g) => g.failingRuns],
|
|
57
|
+
["Time spent in tests", (g) => duration(g.testMs)],
|
|
58
|
+
["Full-suite runs", (g) => g.fullSuiteRuns],
|
|
59
|
+
["Time in full-suite runs", (g) => duration(g.fullSuiteMs)],
|
|
60
|
+
["Same failing command retried 3+ times", (g) => g.findingCounts["retry-loop"] || 0],
|
|
61
|
+
],
|
|
62
|
+
you: [
|
|
63
|
+
["Your messages", (g) => g.userMessages],
|
|
64
|
+
['Pushback ("no", "I told you", "revert"…)', (g) => g.corrections],
|
|
65
|
+
["Times you interrupted the agent", (g) => g.interrupts],
|
|
66
|
+
["Risky git commands", (g) => Object.values(g.risky).reduce((a, b) => a + b, 0)],
|
|
67
|
+
],
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Newest agent versions this release was checked against. Logs from newer versions are
|
|
71
|
+
// still read, but formats change: the report says so instead of failing quietly.
|
|
72
|
+
export const TESTED = { claude: "2.1.314", codex: "0.166.0" };
|
|
73
|
+
|
|
74
|
+
const parts = (v) => String(v).split(/[^\d]+/).filter(Boolean).map(Number);
|
|
75
|
+
export function compareVersions(a, b) {
|
|
76
|
+
const x = parts(a), y = parts(b);
|
|
77
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) - (y[i] ?? 0);
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** "2.1.220–2.1.314" for the versions seen, and whether any is newer than tested. */
|
|
82
|
+
export function versionRange(agent, versions) {
|
|
83
|
+
if (!versions?.length) return null;
|
|
84
|
+
const sorted = [...versions].sort(compareVersions);
|
|
85
|
+
const lo = sorted[0], hi = sorted[sorted.length - 1];
|
|
86
|
+
return { text: lo === hi ? lo : `${lo}–${hi}`, newer: compareVersions(hi, TESTED[agent] || "0") > 0 };
|
|
87
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// A self-contained, numbers-only report meant to be screenshotted or shared.
|
|
2
|
+
// Never put prompts, file paths, commands or claim text in here.
|
|
3
|
+
|
|
4
|
+
import { ROWS, columns, pct, range } from "./format.js";
|
|
5
|
+
|
|
6
|
+
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
7
|
+
|
|
8
|
+
export function renderHtml({ groups, by = "agent", version, demo = false, skipped = 0, unrecognized = 0 }) {
|
|
9
|
+
const cols = columns(groups, by);
|
|
10
|
+
const all = groups.all;
|
|
11
|
+
const head = cols.map((c) => `<th>${esc(c.label)}</th>`).join("");
|
|
12
|
+
// "…" rows break down the row above them; like the terminal, hide them when all zero
|
|
13
|
+
const table = (rows) =>
|
|
14
|
+
rows
|
|
15
|
+
.filter(([label, fn]) => !label.startsWith("…") || cols.some((c) => Number(fn(c.g)) > 0))
|
|
16
|
+
.map(([label, fn]) => {
|
|
17
|
+
const cls = label.startsWith("…") ? ' class="sub"' : "";
|
|
18
|
+
return `<tr${cls}><td>${esc(label)}</td>${cols.map((c) => `<td>${esc(fn(c.g))}</td>`).join("")}</tr>`;
|
|
19
|
+
})
|
|
20
|
+
.join("");
|
|
21
|
+
const rate = `<tr class="total"><td>Receipt rate</td>${cols.map((c) => `<td>${esc(pct(c.g.receiptRate))}</td>`).join("")}</tr>`;
|
|
22
|
+
|
|
23
|
+
const tiles = cols
|
|
24
|
+
.filter((c) => c.key !== "all" || cols.length === 1)
|
|
25
|
+
.map((c) => {
|
|
26
|
+
const r = c.g.receiptRate;
|
|
27
|
+
const judged = c.g.claimsTotal - c.g.claims.unknown;
|
|
28
|
+
const unclear = c.g.claims.unknown;
|
|
29
|
+
const tone = r == null ? "" : r >= 0.95 ? "good" : r >= 0.8 ? "warn" : "bad";
|
|
30
|
+
return `<div class="tile ${tone}">
|
|
31
|
+
<div class="tile-label">${esc(c.label)}</div>
|
|
32
|
+
<div class="tile-value">${esc(pct(r))}</div>
|
|
33
|
+
<div class="tile-sub">${c.g.claims.backed} of ${judged} “tests pass” claims had a fresh passing run${unclear ? `; ${unclear} more couldn't be judged` : ""}</div>
|
|
34
|
+
<div class="bar"><span style="width:${r == null ? 0 : Math.round(r * 100)}%"></span></div>
|
|
35
|
+
</div>`;
|
|
36
|
+
})
|
|
37
|
+
.join("\n");
|
|
38
|
+
|
|
39
|
+
return `<!doctype html>
|
|
40
|
+
<html lang="en">
|
|
41
|
+
<head>
|
|
42
|
+
<meta charset="utf-8">
|
|
43
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
44
|
+
<title>Agent Polygraph Report</title>
|
|
45
|
+
<style>
|
|
46
|
+
:root{--bg:#f6f5f1;--card:#fff;--ink:#16181d;--muted:#6b6f78;--line:#e4e2dc;--good:#1f8a4c;--warn:#b7791f;--bad:#c0392b;--accent:#3949ab}
|
|
47
|
+
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){--bg:#111317;--card:#1a1d23;--ink:#eceef2;--muted:#9aa0ab;--line:#2a2e36;--good:#4cc38a;--warn:#e0a84a;--bad:#ef6b5e;--accent:#8c9eff}}
|
|
48
|
+
:root[data-theme=dark]{--bg:#111317;--card:#1a1d23;--ink:#eceef2;--muted:#9aa0ab;--line:#2a2e36;--good:#4cc38a;--warn:#e0a84a;--bad:#ef6b5e;--accent:#8c9eff}
|
|
49
|
+
*{box-sizing:border-box}
|
|
50
|
+
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
51
|
+
main{max-width:860px;margin:0 auto;padding:40px 16px 56px}
|
|
52
|
+
h1{font-size:28px;letter-spacing:-.02em;margin:0}
|
|
53
|
+
.meta{color:var(--muted);margin:4px 0 28px}
|
|
54
|
+
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px;margin-bottom:28px}
|
|
55
|
+
.tile{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:18px 18px 16px}
|
|
56
|
+
.tile-label{color:var(--muted);font-size:13px;text-transform:uppercase;letter-spacing:.06em}
|
|
57
|
+
.tile-value{font-size:52px;font-weight:700;letter-spacing:-.03em;line-height:1.1;margin:6px 0 2px;font-variant-numeric:tabular-nums}
|
|
58
|
+
.tile.good .tile-value{color:var(--good)}.tile.warn .tile-value{color:var(--warn)}.tile.bad .tile-value{color:var(--bad)}
|
|
59
|
+
.tile-sub{color:var(--muted);font-size:13px}
|
|
60
|
+
.bar{height:6px;background:var(--line);border-radius:3px;margin-top:12px;overflow:hidden}
|
|
61
|
+
.bar span{display:block;height:100%;background:currentColor}
|
|
62
|
+
.tile.good .bar{color:var(--good)}.tile.warn .bar{color:var(--warn)}.tile.bad .bar{color:var(--bad)}
|
|
63
|
+
section{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:6px 18px 10px;margin-bottom:14px;overflow-x:auto}
|
|
64
|
+
h2{font-size:15px;margin:14px 0 4px}
|
|
65
|
+
h2 small{color:var(--muted);font-weight:400}
|
|
66
|
+
table{width:100%;border-collapse:collapse;font-variant-numeric:tabular-nums}
|
|
67
|
+
th,td{padding:7px 0;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap}
|
|
68
|
+
th{color:var(--muted);font-weight:500;font-size:13px}
|
|
69
|
+
th:first-child,td:first-child{text-align:left;white-space:normal;padding-right:12px}
|
|
70
|
+
td+td,th+th{padding-left:16px}
|
|
71
|
+
tr:last-child td{border-bottom:0}
|
|
72
|
+
tr.sub td{color:var(--muted);font-size:13px;padding-top:4px;padding-bottom:4px}
|
|
73
|
+
tr.sub td:first-child{padding-left:14px}
|
|
74
|
+
tr.total td{font-weight:600}
|
|
75
|
+
.note{color:var(--warn);font-size:13px;margin:-16px 0 24px}
|
|
76
|
+
.demo{display:inline-block;margin:10px 0 0;padding:4px 10px;border-radius:999px;background:var(--warn);color:var(--card);font-size:13px;font-weight:600}
|
|
77
|
+
footer{color:var(--muted);font-size:13px;margin-top:22px}
|
|
78
|
+
footer a{color:var(--accent)}
|
|
79
|
+
</style>
|
|
80
|
+
</head>
|
|
81
|
+
<body>
|
|
82
|
+
<main>
|
|
83
|
+
<h1>Did my coding agents show their work?</h1>${demo ? '\n<p class="demo">Demo data: generated sessions with invented numbers, not a benchmark of any agent.</p>' : ""}
|
|
84
|
+
<p class="meta">${all.activeSessions} working sessions · ${esc(range(all))} · ${all.testRuns.toLocaleString("en-US")} test runs checked</p>${skipped ? `\n<p class="note">${skipped} session file${skipped === 1 ? "" : "s"} couldn't be read and ${skipped === 1 ? "is" : "are"} not included.</p>` : ""}${unrecognized ? `\n<p class="note">${unrecognized} session file${unrecognized === 1 ? " is" : "s are"} in a log format this version doesn't recognize and not included.</p>` : ""}
|
|
85
|
+
<div class="tiles">
|
|
86
|
+
${tiles}
|
|
87
|
+
</div>
|
|
88
|
+
<section><h2>“Tests pass” claims <small>was there a passing run after the last code edit?</small></h2>
|
|
89
|
+
<table><tr><th></th>${head}</tr>${table(ROWS.claims)}${table(ROWS.unclear)}${rate}</table></section>
|
|
90
|
+
<section><h2>Test integrity <small>worth a second look, not proof of cheating</small></h2>
|
|
91
|
+
<table><tr><th></th>${head}</tr>${table(ROWS.integrity)}</table></section>
|
|
92
|
+
<section><h2>Where the time went</h2>
|
|
93
|
+
<table><tr><th></th>${head}</tr>${table(ROWS.time)}</table></section>
|
|
94
|
+
<section><h2>You and the agent</h2>
|
|
95
|
+
<table><tr><th></th>${head}</tr>${table(ROWS.you)}</table></section>
|
|
96
|
+
<footer>Generated locally by <a href="https://github.com/EleChon-pixel/agent-polygraph">agent-polygraph</a>${version ? " " + esc(version) : ""} from Claude Code and Codex session logs. Numbers only: no prompts, code or file paths are included. Run your own: <code>npx agent-polygraph</code></footer>
|
|
97
|
+
</main>
|
|
98
|
+
</body>
|
|
99
|
+
</html>
|
|
100
|
+
`;
|
|
101
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { AGENT_NAMES, ROWS, TESTED, columns, day, duration, pct, range, versionRange } from "./format.js";
|
|
3
|
+
|
|
4
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
5
|
+
const paint = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : String(s));
|
|
6
|
+
const bold = paint("1");
|
|
7
|
+
const dim = paint("2");
|
|
8
|
+
const red = paint("31");
|
|
9
|
+
const green = paint("32");
|
|
10
|
+
const yellow = paint("33");
|
|
11
|
+
const cyan = paint("36");
|
|
12
|
+
|
|
13
|
+
const VERDICT_LABEL = {
|
|
14
|
+
"no-run": "no test run in the session",
|
|
15
|
+
stale: "code edited after the last run",
|
|
16
|
+
failed: "the last test run had failed",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function renderTerminal({ sessions, groups, seconds, by = "agent", examples = 5, demo = false }) {
|
|
20
|
+
const out = [];
|
|
21
|
+
const cols = columns(groups, by);
|
|
22
|
+
const all = groups.all;
|
|
23
|
+
const W = 44;
|
|
24
|
+
const CW = Math.max(12, ...cols.map((c) => c.label.length + 2));
|
|
25
|
+
|
|
26
|
+
const header = (title, sub) => {
|
|
27
|
+
out.push("");
|
|
28
|
+
out.push(bold(title) + (sub ? dim(" " + sub) : ""));
|
|
29
|
+
out.push(dim(" ".repeat(W) + cols.map((c) => c.label.padStart(CW)).join("")));
|
|
30
|
+
};
|
|
31
|
+
const row = (label, fn, colorize) => {
|
|
32
|
+
const cells = cols.map((c) => {
|
|
33
|
+
const v = fn(c.g);
|
|
34
|
+
const s = String(v).padStart(CW);
|
|
35
|
+
return colorize ? colorize(v, s) : s;
|
|
36
|
+
});
|
|
37
|
+
const padded = label.padEnd(W - 2);
|
|
38
|
+
out.push(" " + (label.startsWith("…") ? dim(padded) : padded) + cells.join(""));
|
|
39
|
+
};
|
|
40
|
+
const warnIfPositive = (v, s) => (typeof v === "number" && v > 0 ? yellow(s) : s);
|
|
41
|
+
|
|
42
|
+
out.push("");
|
|
43
|
+
if (!all.activeSessions) {
|
|
44
|
+
out.push(bold("agent-polygraph"));
|
|
45
|
+
out.push("");
|
|
46
|
+
out.push("No Claude Code or Codex sessions with commands or edits found.");
|
|
47
|
+
out.push(dim("Looked in ~/.claude/projects and ~/.codex/sessions (override with --claude-dir / --codex-dir)."));
|
|
48
|
+
return out.join("\n");
|
|
49
|
+
}
|
|
50
|
+
out.push(bold("agent-polygraph") + dim(` ${all.activeSessions} working sessions · ${range(all)} · ${seconds.toFixed(1)}s`));
|
|
51
|
+
if (demo) out.push(yellow("DEMO DATA: generated sessions, invented numbers. Run without --demo to check your own."));
|
|
52
|
+
|
|
53
|
+
header('"Tests pass" claims', "was there a passing run after the last code edit?");
|
|
54
|
+
ROWS.claims.forEach(([label, fn], i) => row(label, fn, i === 0 ? (v, s) => green(s) : i < 4 ? warnIfPositive : null));
|
|
55
|
+
// say why, instead of hiding unclear claims behind the receipt rate
|
|
56
|
+
ROWS.unclear.forEach(([label, fn]) => cols.some((c) => fn(c.g) > 0) && row(label, fn, (v, s) => dim(s)));
|
|
57
|
+
row(
|
|
58
|
+
bold("Receipt rate"),
|
|
59
|
+
(g) => pct(g.receiptRate),
|
|
60
|
+
(v, s) => bold(v === "–" ? s : parseInt(v) >= 95 ? green(s) : parseInt(v) >= 80 ? yellow(s) : red(s)),
|
|
61
|
+
);
|
|
62
|
+
// evidence that came from outside the agents' logs (record, --ci)
|
|
63
|
+
const ev = all.evidence || {};
|
|
64
|
+
const used = [
|
|
65
|
+
[ev.snapshot, "settled by working-tree snapshots"],
|
|
66
|
+
[ev.recorded, "read from recorded test output"],
|
|
67
|
+
[ev.terminal, "judged by test runs in your terminal"],
|
|
68
|
+
[ev.ci, "judged with GitHub Actions"],
|
|
69
|
+
].filter(([n]) => n > 0);
|
|
70
|
+
if (used.length) out.push(dim(" Beyond the logs: " + used.map(([n, what]) => `${n} ${what}`).join(" · ")));
|
|
71
|
+
|
|
72
|
+
header("Test integrity", "worth a second look, not proof of cheating");
|
|
73
|
+
ROWS.integrity.forEach(([label, fn]) => row(label, fn, warnIfPositive));
|
|
74
|
+
|
|
75
|
+
header("Where the time went");
|
|
76
|
+
ROWS.time.forEach(([label, fn]) => row(label, fn));
|
|
77
|
+
|
|
78
|
+
header("You and the agent");
|
|
79
|
+
ROWS.you.forEach(([label, fn]) => row(label, fn));
|
|
80
|
+
|
|
81
|
+
if (sessions.unrecognized?.length) {
|
|
82
|
+
out.push("");
|
|
83
|
+
out.push(yellow(`${sessions.unrecognized.length} session file(s) are in a log format this version doesn't recognize and are not included.`) + dim(" Please report it, with your agent's version."));
|
|
84
|
+
}
|
|
85
|
+
if (sessions.skipped?.length) {
|
|
86
|
+
out.push("");
|
|
87
|
+
out.push(yellow(`${sessions.skipped.length} session file(s) couldn't be read and are not included.`) + dim(" Set POLYGRAPH_DEBUG=1 to see why."));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (examples > 0) {
|
|
91
|
+
const all = sessions.flatMap((s) => s.findings);
|
|
92
|
+
const pick = (type) => all.filter((f) => f.type === type).sort((a, b) => b.ts - a.ts).slice(0, examples);
|
|
93
|
+
const claims = pick("claim");
|
|
94
|
+
const fixes = pick("test-only-fix");
|
|
95
|
+
const weak = pick("weakened-test");
|
|
96
|
+
const loops = pick("retry-loop");
|
|
97
|
+
const risky = pick("risky");
|
|
98
|
+
if (claims.length + fixes.length + weak.length + loops.length + risky.length) {
|
|
99
|
+
out.push("");
|
|
100
|
+
out.push(bold("Most recent examples") + dim(" (stays on your machine; --html never includes these)"));
|
|
101
|
+
}
|
|
102
|
+
for (const f of claims) {
|
|
103
|
+
out.push("");
|
|
104
|
+
out.push(` ${red("✗")} ${bold(VERDICT_LABEL[f.detail.verdict] + (f.detail.ci ? " (CI claim)" : ""))} ${where(f)}`);
|
|
105
|
+
out.push(` ${cyan("“" + f.detail.claim + "”")}`);
|
|
106
|
+
const lt = f.detail.lastTest;
|
|
107
|
+
if (lt) out.push(dim(` last test: ${lt.cmd} → ${lt.outcome ?? "?"}, ${duration(lt.ago)} earlier`));
|
|
108
|
+
if (f.detail.editedAfter) out.push(dim(` then edited: ${f.detail.editedAfter}`));
|
|
109
|
+
out.push(dim(` ${tilde(f.file)}`));
|
|
110
|
+
}
|
|
111
|
+
for (const f of fixes) {
|
|
112
|
+
out.push("");
|
|
113
|
+
out.push(` ${yellow("!")} ${bold("failing test made to pass by editing only tests")} ${where(f)}`);
|
|
114
|
+
out.push(dim(` edited: ${f.detail.tests.join(", ")}`));
|
|
115
|
+
out.push(dim(` then passed: ${f.detail.cmd}`));
|
|
116
|
+
out.push(dim(` ${tilde(f.file)}`));
|
|
117
|
+
}
|
|
118
|
+
for (const f of weak) {
|
|
119
|
+
out.push("");
|
|
120
|
+
out.push(` ${yellow("!")} ${bold(f.detail.reasons.join(", "))}: ${f.detail.path} ${where(f)}`);
|
|
121
|
+
if (f.detail.removed?.length) out.push(dim(` - ${f.detail.removed.slice(0, 2).join("\n - ")}`));
|
|
122
|
+
out.push(dim(` ${tilde(f.file)}`));
|
|
123
|
+
}
|
|
124
|
+
for (const f of loops) {
|
|
125
|
+
out.push("");
|
|
126
|
+
out.push(` ${yellow("↻")} ${bold("same failing command, 3+ times, nothing changed in between")} ${where(f)}`);
|
|
127
|
+
out.push(dim(` ${f.detail.cmd}`));
|
|
128
|
+
}
|
|
129
|
+
for (const f of risky) {
|
|
130
|
+
out.push("");
|
|
131
|
+
out.push(` ${yellow("!")} ${bold(f.detail.label)} ${where(f)}`);
|
|
132
|
+
out.push(dim(` ${f.detail.cmd}`));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const seen = ["claude", "codex"].map((a) => [a, versionRange(a, groups[`agent:${a}`]?.versions)]).filter(([, r]) => r);
|
|
137
|
+
if (seen.length) {
|
|
138
|
+
out.push("");
|
|
139
|
+
out.push(dim("Log versions: " + seen.map(([a, r]) => `${AGENT_NAMES[a]} ${r.text}`).join(" · ")));
|
|
140
|
+
for (const [a, r] of seen) if (r.newer) out.push(yellow(`${AGENT_NAMES[a]} logs are newer than the versions this release was tested with (up to ${TESTED[a]}). If numbers look off, please report it.`));
|
|
141
|
+
}
|
|
142
|
+
out.push("");
|
|
143
|
+
out.push(dim("How each number is measured: https://github.com/EleChon-pixel/agent-polygraph#how-it-decides"));
|
|
144
|
+
out.push(dim("A verdict looks wrong? Report it (redacted): https://github.com/EleChon-pixel/agent-polygraph/issues/new/choose"));
|
|
145
|
+
out.push(dim("Share a numbers-only card: npx agent-polygraph --html"));
|
|
146
|
+
out.push(dim("Catch these as they happen: npx agent-polygraph guard --install"));
|
|
147
|
+
if (!used.length) out.push(dim("See what the logs miss: npx agent-polygraph record --install (stays offline), or --ci (asks GitHub)"));
|
|
148
|
+
out.push("");
|
|
149
|
+
return out.join("\n");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function where(f) {
|
|
153
|
+
const project = f.cwd ? f.cwd.split("/").filter(Boolean).pop() : "";
|
|
154
|
+
return dim(`· ${AGENT_NAMES[f.agent]} · ${day(f.ts)}${project ? " · " + project : ""}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function tilde(p) {
|
|
158
|
+
const home = os.homedir();
|
|
159
|
+
return p && p.startsWith(home) ? "~" + p.slice(home.length) : p;
|
|
160
|
+
}
|
|
@@ -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/scan.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import * as claude from "./sources/claude.js";
|
|
2
|
+
import * as codex from "./sources/codex.js";
|
|
3
|
+
import { analyzeSession } from "./analyze.js";
|
|
4
|
+
import { applyRecords } from "./record.js";
|
|
5
|
+
|
|
6
|
+
export const SOURCES = { claude, codex };
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Discover, parse and analyze sessions.
|
|
10
|
+
* opts: { agents: ["claude","codex"], since: ms timestamp, project: substring, exclude: [id prefix or cwd substring],
|
|
11
|
+
* roots: {claude, codex}, records: false to ignore `record` data, enrich: async (session) => void,
|
|
12
|
+
* onProgress }
|
|
13
|
+
*/
|
|
14
|
+
export async function scan(opts = {}) {
|
|
15
|
+
const agents = opts.agents?.length ? opts.agents : Object.keys(SOURCES);
|
|
16
|
+
let entries = [];
|
|
17
|
+
for (const a of agents) {
|
|
18
|
+
const src = SOURCES[a];
|
|
19
|
+
entries.push(...src.discover(opts.roots?.[a] || src.defaultRoot()));
|
|
20
|
+
}
|
|
21
|
+
if (opts.since) entries = entries.filter((e) => e.mtime >= opts.since);
|
|
22
|
+
|
|
23
|
+
const results = [];
|
|
24
|
+
results.skipped = []; // session files we couldn't read: a quiet gap would look like a full audit
|
|
25
|
+
results.unrecognized = []; // files with content in a format we don't know (a new agent version?)
|
|
26
|
+
let done = 0;
|
|
27
|
+
const queue = [...entries];
|
|
28
|
+
const worker = async () => {
|
|
29
|
+
while (queue.length) {
|
|
30
|
+
const entry = queue.shift();
|
|
31
|
+
try {
|
|
32
|
+
const session = await SOURCES[entry.agent].parse(entry);
|
|
33
|
+
if (opts.project && !(session.cwd || "").includes(opts.project)) continue;
|
|
34
|
+
// --exclude: a session id prefix or a piece of its working directory
|
|
35
|
+
if (opts.exclude?.some((x) => entry.id.startsWith(x) || (session.cwd || "").includes(x))) continue;
|
|
36
|
+
if (opts.since && session.end && session.end < opts.since) continue;
|
|
37
|
+
if (session.unrecognized) {
|
|
38
|
+
results.unrecognized.push({ file: entry.files[0], agent: entry.agent });
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (!session.events.length) continue;
|
|
42
|
+
// evidence outside the logs: `record` snapshots and terminal runs (local files),
|
|
43
|
+
// then --ci (GitHub) when asked for
|
|
44
|
+
if (opts.records !== false) applyRecords(session);
|
|
45
|
+
if (opts.enrich) await opts.enrich(session);
|
|
46
|
+
results.push(analyzeSession(session));
|
|
47
|
+
} catch (err) {
|
|
48
|
+
results.skipped.push({ file: entry.files[0], error: err.message });
|
|
49
|
+
if (process.env.POLYGRAPH_DEBUG) console.error(`skip ${entry.files[0]}: ${err.message}`);
|
|
50
|
+
} finally {
|
|
51
|
+
opts.onProgress?.(++done, entries.length);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
await Promise.all(Array.from({ length: Math.min(8, entries.length || 1) }, worker));
|
|
56
|
+
const { skipped, unrecognized } = results;
|
|
57
|
+
results.sort((a, b) => (a.start ?? 0) - (b.start ?? 0));
|
|
58
|
+
Object.assign(results, { skipped, unrecognized });
|
|
59
|
+
return results;
|
|
60
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
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.
|
|
3
|
+
# Remove with: agent-polygraph record --uninstall --shell
|
|
4
|
+
case $- in *i*) ;; *) return 0 2>/dev/null ;; esac
|
|
5
|
+
[ -n "${CLAUDECODE:-}${CODEX_SANDBOX:-}${CODEX_THREAD_ID:-}${CODEX_MANAGED_BY_NPM:-}" ] && return 0 2>/dev/null
|
|
6
|
+
|
|
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=
|
|
11
|
+
|
|
12
|
+
__apg_now() {
|
|
13
|
+
if [ -n "${EPOCHREALTIME:-}" ]; then printf '%s' "${EPOCHREALTIME/,/.}"; else date +%s; fi
|
|
14
|
+
}
|
|
15
|
+
__apg_esc() {
|
|
16
|
+
local s=$1
|
|
17
|
+
s=${s//\\/\\\\}; s=${s//\"/\\\"}; s=${s//$'\n'/\\n}; s=${s//$'\t'/\\t}; s=${s//$'\r'/\\r}
|
|
18
|
+
printf '%s' "$s"
|
|
19
|
+
}
|
|
20
|
+
__apg_pre() {
|
|
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
|
|
37
|
+
__apg_dir=$PWD
|
|
38
|
+
__apg_t0=$(__apg_now)
|
|
39
|
+
}
|
|
40
|
+
__apg_post() {
|
|
41
|
+
local s=$?
|
|
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=
|
|
49
|
+
fi
|
|
50
|
+
return $s
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if [ -n "${ZSH_VERSION:-}" ]; then
|
|
54
|
+
autoload -Uz add-zsh-hook && add-zsh-hook preexec __apg_pre && add-zsh-hook precmd __apg_post
|
|
55
|
+
elif [ -n "${BASH_VERSION:-}" ]; then
|
|
56
|
+
if [ -n "${bash_preexec_imported:-}${__bp_imported:-}" ]; then
|
|
57
|
+
preexec_functions+=(__apg_pre)
|
|
58
|
+
precmd_functions=(__apg_post "${precmd_functions[@]}")
|
|
59
|
+
elif [ -z "$(trap -p DEBUG)" ]; then
|
|
60
|
+
__apg_last=$HISTCMD
|
|
61
|
+
__apg_debug() {
|
|
62
|
+
[[ ${BASH_COMMAND:-} == __apg_* ]] && return
|
|
63
|
+
[ -n "${COMP_LINE:-}" ] && return
|
|
64
|
+
[ "$HISTCMD" = "$__apg_last" ] && return
|
|
65
|
+
__apg_last=$HISTCMD
|
|
66
|
+
local h
|
|
67
|
+
h=$(HISTTIMEFORMAT= builtin history 1)
|
|
68
|
+
[[ $h =~ ^[[:space:]]*[0-9]+[*]?[[:space:]]+(.*)$ ]] && __apg_pre "${BASH_REMATCH[1]}"
|
|
69
|
+
}
|
|
70
|
+
trap '__apg_debug' DEBUG
|
|
71
|
+
PROMPT_COMMAND="__apg_post${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
|
|
72
|
+
fi
|
|
73
|
+
fi
|