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
package/src/ci.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// `--ci`: GitHub Actions results as evidence. The one part of agent-polygraph that goes
|
|
2
|
+
// online: it asks the GitHub API (through your `gh` login) for the workflow runs of the
|
|
3
|
+
// repositories your sessions pushed to. Nothing from your sessions is sent; the requests
|
|
4
|
+
// name only the repository, the branch and a date range.
|
|
5
|
+
//
|
|
6
|
+
// - A successful `git push` followed by a test workflow on the pushed commit becomes a test
|
|
7
|
+
// run in the session: it covers the code as of the push and its result counts from the
|
|
8
|
+
// moment the workflow finished.
|
|
9
|
+
// - "CI is green" claims are judged against the runs for the latest pushed commit.
|
|
10
|
+
|
|
11
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
12
|
+
import { promisify } from "node:util";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
|
|
16
|
+
const run = promisify(execFile);
|
|
17
|
+
const PUSH = /\bgit\b[^|;&\n]*\bpush\b/;
|
|
18
|
+
const TEST_STEP = /\b(?:tests?|pytest|jest|vitest|mocha|spec|rspec|phpunit|unit|e2e|ctest|cargo test|go test|check)\b/i;
|
|
19
|
+
|
|
20
|
+
export class CiUnavailable extends Error {}
|
|
21
|
+
|
|
22
|
+
/** Check that `gh` exists and is logged in; throws CiUnavailable with a readable reason. */
|
|
23
|
+
export async function checkGh() {
|
|
24
|
+
try {
|
|
25
|
+
await run("gh", ["auth", "status"], { timeout: 15000 });
|
|
26
|
+
} catch (e) {
|
|
27
|
+
if (e.code === "ENOENT") throw new CiUnavailable("--ci needs the GitHub CLI (https://cli.github.com), logged in with `gh auth login`.");
|
|
28
|
+
throw new CiUnavailable("--ci: `gh auth status` failed; log in with `gh auth login` first.");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function makeCi({ gh = ghApi } = {}) {
|
|
33
|
+
const repoCache = new Map();
|
|
34
|
+
const runsCache = new Map();
|
|
35
|
+
const jobsCache = new Map();
|
|
36
|
+
const stats = { repos: new Set(), requests: 0, runs: 0 };
|
|
37
|
+
|
|
38
|
+
const repoOf = (cwd) => {
|
|
39
|
+
if (!cwd) return null;
|
|
40
|
+
if (repoCache.has(cwd)) return repoCache.get(cwd);
|
|
41
|
+
let repo = null;
|
|
42
|
+
try {
|
|
43
|
+
const url = execFileSync("git", ["-C", cwd, "remote", "get-url", "origin"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim();
|
|
44
|
+
const m = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(url);
|
|
45
|
+
if (m) repo = `${m[1]}/${m[2]}`;
|
|
46
|
+
} catch {}
|
|
47
|
+
repoCache.set(cwd, repo);
|
|
48
|
+
return repo;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Workflow runs of a repository created in [from, to] (ms), every branch. */
|
|
52
|
+
const runsFor = async (repo, from, to) => {
|
|
53
|
+
const day = (t) => new Date(t).toISOString().slice(0, 10);
|
|
54
|
+
const key = `${repo}|${day(from)}|${day(to)}`;
|
|
55
|
+
if (!runsCache.has(key)) {
|
|
56
|
+
runsCache.set(
|
|
57
|
+
key,
|
|
58
|
+
(async () => {
|
|
59
|
+
stats.repos.add(repo);
|
|
60
|
+
const out = [];
|
|
61
|
+
for (let page = 1; page <= 5; page++) {
|
|
62
|
+
stats.requests++;
|
|
63
|
+
const data = await gh(`repos/${repo}/actions/runs`, { created: `${day(from)}..${day(to + 86400000)}`, per_page: "100", page: String(page) });
|
|
64
|
+
const rows = data?.workflow_runs || [];
|
|
65
|
+
out.push(...rows);
|
|
66
|
+
if (rows.length < 100) break;
|
|
67
|
+
}
|
|
68
|
+
return out.map((r) => ({
|
|
69
|
+
id: r.id,
|
|
70
|
+
name: r.name || r.display_title || "workflow",
|
|
71
|
+
sha: r.head_sha,
|
|
72
|
+
branch: r.head_branch,
|
|
73
|
+
event: r.event,
|
|
74
|
+
created: Date.parse(r.created_at),
|
|
75
|
+
completed: r.status === "completed" ? Date.parse(r.updated_at) : null,
|
|
76
|
+
conclusion: r.status === "completed" ? r.conclusion : null,
|
|
77
|
+
url: r.html_url,
|
|
78
|
+
}));
|
|
79
|
+
})().catch(() => []),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return runsCache.get(key);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Does this workflow run tests (a job or step named like one)? */
|
|
86
|
+
const runsTests = async (repo, r) => {
|
|
87
|
+
if (TEST_STEP.test(r.name)) return true;
|
|
88
|
+
if (!jobsCache.has(r.id)) {
|
|
89
|
+
jobsCache.set(
|
|
90
|
+
r.id,
|
|
91
|
+
(async () => {
|
|
92
|
+
stats.requests++;
|
|
93
|
+
const data = await gh(`repos/${repo}/actions/runs/${r.id}/jobs`, { per_page: "50" });
|
|
94
|
+
return (data?.jobs || []).some((j) => TEST_STEP.test(j.name) || (j.steps || []).some((s) => TEST_STEP.test(s.name)));
|
|
95
|
+
})().catch(() => false),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return jobsCache.get(r.id);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Add CI evidence to a parsed session (mutates it): a test run per successful push whose
|
|
103
|
+
* commit had test workflows, and `session.ci.runs` for judging "CI is green" claims.
|
|
104
|
+
*
|
|
105
|
+
* A workflow run is tied to a push only through its commit: the id the push printed, or
|
|
106
|
+
* one the session printed before the push (`git commit`, `git log`), with the run created
|
|
107
|
+
* within 10 minutes. Branch names and timing alone could attach someone else's run.
|
|
108
|
+
*/
|
|
109
|
+
const enrich = async (session) => {
|
|
110
|
+
if (session.start == null) return session;
|
|
111
|
+
const events = session.events;
|
|
112
|
+
// commit ids the session printed, with when they first appeared
|
|
113
|
+
const seen = new Map();
|
|
114
|
+
const note = (text, ts) => {
|
|
115
|
+
for (const m of String(text || "").matchAll(/\b[0-9a-f]{7,40}\b/g)) {
|
|
116
|
+
const k = m[0].slice(0, 7);
|
|
117
|
+
if (!seen.has(k)) seen.set(k, ts ?? 0);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
const pushes = [];
|
|
121
|
+
for (const ev of events) {
|
|
122
|
+
if (ev.kind === "result") note(ev.output, ev.ts);
|
|
123
|
+
if (ev.kind !== "result" || ev.partial || !ev.of || !PUSH.test(ev.of.cmd || "")) continue;
|
|
124
|
+
const out = ev.output || "";
|
|
125
|
+
const ok = ev.exitCode === 0 || (ev.exitCode == null && ev.isError !== true);
|
|
126
|
+
if (!ok || /Everything up-to-date/.test(out)) continue;
|
|
127
|
+
const dir = pushDir(ev.of.cmd, ev.of.workdir || session.cwd);
|
|
128
|
+
const repo = repoOf(dir);
|
|
129
|
+
if (!repo) continue;
|
|
130
|
+
const m = /\b([0-9a-f]{7,40})\.{2,3}([0-9a-f]{7,40})\s+(\S+)\s+->\s+(\S+)/.exec(out);
|
|
131
|
+
pushes.push({ ev, repo, dir, sha: m?.[2] || null });
|
|
132
|
+
}
|
|
133
|
+
const repos = new Set(pushes.map((p) => p.repo));
|
|
134
|
+
const own = repoOf(session.cwd);
|
|
135
|
+
if (own) repos.add(own);
|
|
136
|
+
if (!repos.size) return session;
|
|
137
|
+
const from = session.start - 86400000;
|
|
138
|
+
const to = session.end ?? session.start;
|
|
139
|
+
const byRepo = new Map();
|
|
140
|
+
for (const repo of repos) byRepo.set(repo, await runsFor(repo, from, to));
|
|
141
|
+
stats.runs += [...byRepo.values()].reduce((n, r) => n + r.length, 0);
|
|
142
|
+
|
|
143
|
+
// runs for commits this session pushed or printed: what "CI is green" can refer to
|
|
144
|
+
const mentioned = (r) => seen.has(r.sha.slice(0, 7));
|
|
145
|
+
session.ci = { repos: [...repos], runs: [...byRepo.values()].flat().filter((r) => mentioned(r) && r.created >= session.start - 3600000) };
|
|
146
|
+
|
|
147
|
+
const added = [];
|
|
148
|
+
for (const p of pushes) {
|
|
149
|
+
const runs = byRepo.get(p.repo) || [];
|
|
150
|
+
let forPush = p.sha
|
|
151
|
+
? runs.filter((r) => r.sha.startsWith(p.sha))
|
|
152
|
+
: runs.filter((r) => r.created >= p.ev.ts - 60000 && r.created <= p.ev.ts + 600000 && (seen.get(r.sha.slice(0, 7)) ?? Infinity) <= p.ev.ts);
|
|
153
|
+
// one commit per push: the first one CI started on after it
|
|
154
|
+
if (forPush.length) {
|
|
155
|
+
const first = forPush.reduce((a, b) => (b.created < a.created ? b : a)).sha;
|
|
156
|
+
forPush = forPush.filter((r) => r.sha === first);
|
|
157
|
+
}
|
|
158
|
+
const tests = [];
|
|
159
|
+
for (const r of forPush) if (await runsTests(p.repo, r)) tests.push(r);
|
|
160
|
+
if (!tests.length) continue;
|
|
161
|
+
if (!tests.every((r) => r.completed)) continue; // still running when we looked: no result to use
|
|
162
|
+
const failed = tests.some((r) => r.conclusion === "failure" || r.conclusion === "timed_out");
|
|
163
|
+
const passed = tests.every((r) => ["success", "skipped", "neutral"].includes(r.conclusion));
|
|
164
|
+
if (!failed && !passed) continue; // cancelled, or GitHub never started it (startup_failure)
|
|
165
|
+
const finished = Math.max(...tests.map((r) => r.completed));
|
|
166
|
+
const label = `CI: ${[...new Set(tests.map((r) => r.name))].join(", ")} (${tests[0].sha.slice(0, 7)})`;
|
|
167
|
+
// the run starts at the push: edits after it aren't in the pushed commit
|
|
168
|
+
const cmd = { kind: "cmd", ts: p.ev.ts, seq: (p.ev.seq ?? 0) + 0.5, cmd: label, external: "ci", workdir: p.dir, sub: false };
|
|
169
|
+
added.push(cmd, { kind: "result", of: cmd, ts: finished, seq: Number.MAX_SAFE_INTEGER, outcome: failed ? "fail" : "pass", output: "", exitCode: failed ? 1 : 0, isError: null });
|
|
170
|
+
}
|
|
171
|
+
if (added.length) {
|
|
172
|
+
events.push(...added);
|
|
173
|
+
events.sort((a, b) => (a.ts ?? 0) - (b.ts ?? 0) || (a.seq ?? 0) - (b.seq ?? 0));
|
|
174
|
+
}
|
|
175
|
+
return session;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
return { enrich, stats };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Where a push ran: `git -C dir push`, else the last `cd` before it, else the command's folder. */
|
|
182
|
+
function pushDir(cmd, base) {
|
|
183
|
+
const at = cmd.search(PUSH);
|
|
184
|
+
const before = cmd.slice(0, at + 200);
|
|
185
|
+
const c = /\bgit\s+-C\s+("[^"]+"|'[^']+'|\S+)[^|;&\n]*\bpush\b/.exec(before);
|
|
186
|
+
if (c) return abs(c[1].replace(/^["']|["']$/g, ""), base);
|
|
187
|
+
const cds = [...cmd.slice(0, at).matchAll(/(?:^|[;&|\n]\s*)(?:cd|pushd)\s+("[^"]*"|'[^']*'|[^\s;&|]+)/g)];
|
|
188
|
+
let dir = base;
|
|
189
|
+
for (const m of cds) dir = abs(m[1].replace(/^["']|["']$/g, ""), dir);
|
|
190
|
+
return dir;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function abs(p, base) {
|
|
194
|
+
if (!p || /[$`]/.test(p)) return null;
|
|
195
|
+
if (p === "~" || p.startsWith("~/")) return path.join(os.homedir(), p.slice(1));
|
|
196
|
+
return path.isAbsolute(p) ? p : base ? path.join(base, p) : null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function ghApi(endpoint, params = {}) {
|
|
200
|
+
const args = ["api", "-X", "GET", endpoint, "-H", "Accept: application/vnd.github+json"];
|
|
201
|
+
for (const [k, v] of Object.entries(params)) args.push("-f", `${k}=${v}`);
|
|
202
|
+
const { stdout } = await run("gh", args, { timeout: 30000, maxBuffer: 32 << 20 });
|
|
203
|
+
return JSON.parse(stdout);
|
|
204
|
+
}
|