@fullstackhouse/cerber 0.6.0 → 0.8.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 +127 -0
- package/dist/cli/index.js +121 -2
- package/dist/core/artifact.js +4 -0
- package/dist/core/checkout.js +201 -0
- package/dist/core/config.js +82 -0
- package/dist/core/gh.js +24 -0
- package/dist/core/trust.js +91 -0
- package/dist/runner/claude.js +84 -4
- package/dist/runner/prompt.js +31 -4
- package/dist/runner/review.js +122 -20
- package/dist/server/daemon.js +2 -0
- package/dist/server/index.js +81 -2
- package/package.json +8 -1
- package/web/dist/assets/{index-DlvELqhQ.css → index-D05bU4ds.css} +10 -1
- package/web/dist/assets/index-DQG1BhG2.js +236 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BzQacQob.js +0 -230
package/README.md
CHANGED
|
@@ -44,10 +44,137 @@ Each review is a plain JSON artifact in `~/.cerber/reviews/` (override with
|
|
|
44
44
|
human's time
|
|
45
45
|
- **Verdict** — approve / comment / request changes, with a 0–100 confidence
|
|
46
46
|
score and reasoning
|
|
47
|
+
- **Run metadata** — model, whether it read the source or only the diff, and
|
|
48
|
+
the token spend as an API-rate equivalent
|
|
47
49
|
|
|
48
50
|
The cockpit (`cerber serve`) renders the queue and the per-PR walkthrough with
|
|
49
51
|
diffs. Artifacts are plain JSON you can `cat`, edit, or pipe into anything.
|
|
50
52
|
|
|
53
|
+
### It reviews the code, not just the diff
|
|
54
|
+
|
|
55
|
+
Cerber checks the PR's head out locally and lets the review read it. That
|
|
56
|
+
matters because a reviewer holding only a diff hedges over things it could have
|
|
57
|
+
just looked up — *"I can't see the enclosing function, so this could be
|
|
58
|
+
wrong"* — and burns its confidence score on missing context instead of on real
|
|
59
|
+
uncertainty. With the source there, it opens the enclosing function, follows
|
|
60
|
+
callers of a changed signature, checks whether a helper already exists, and
|
|
61
|
+
sees whether a test covers the new path.
|
|
62
|
+
|
|
63
|
+
On one real PR, same commit, the difference was: a speculative "this *looks
|
|
64
|
+
like* it sits after an early return" became a concrete finding; a permission
|
|
65
|
+
check the diff showed as a tidy gate turned out to hide a value the API
|
|
66
|
+
response still ships; confidence went 72% → 82%. It burned 3.3× the tokens and
|
|
67
|
+
took three minutes instead of one.
|
|
68
|
+
|
|
69
|
+
Cerber rides your `claude` login, so a review isn't billed per run: on a Claude
|
|
70
|
+
subscription it draws on your usage limits. The `≈$` figures in the CLI and
|
|
71
|
+
cockpit are what those tokens would cost at API token rates — read them as
|
|
72
|
+
"how much of the plan did this eat", not as an invoice.
|
|
73
|
+
|
|
74
|
+
If you want the old behaviour — faster, lighter, blind past the changed lines:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
cerber review owner/repo#123 --no-source
|
|
78
|
+
cerber serve --daemon --no-source
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The cockpit labels every review "read the full source" or "read the diff only",
|
|
82
|
+
and offers a one-click **Re-review with full source** on the diff-only ones.
|
|
83
|
+
|
|
84
|
+
Details worth knowing:
|
|
85
|
+
|
|
86
|
+
- The checkout is a shallow (`--depth=1`) fetch of `refs/pull/N/head` from the
|
|
87
|
+
base repo, so fork PRs work with no extra remotes. Auth rides `gh`'s
|
|
88
|
+
credential helper, set on that clone only — your git config is untouched.
|
|
89
|
+
- If git or `gh` can't produce a checkout, the run says so and reviews the diff
|
|
90
|
+
alone. A missing checkout never fails a review.
|
|
91
|
+
- The run is read-only unless you have trusted the PR (see below): `Read`,
|
|
92
|
+
`Grep` and `Glob` are the only tools it gets. Without a checkout it gets none,
|
|
93
|
+
and runs in an empty directory, so it can never read whatever project cerber
|
|
94
|
+
happens to be started from and mistake it for the PR.
|
|
95
|
+
- PR content is untrusted, and `claude -p` skips the workspace-trust prompt, so
|
|
96
|
+
cerber does the distrusting itself: a `.claude/settings.json` or `.mcp.json`
|
|
97
|
+
in the checkout is renamed aside (still readable, no longer loaded), hooks
|
|
98
|
+
and non-cerber MCP servers are off, and the prompt tells the reviewer that
|
|
99
|
+
instructions found in files are material to review, not directions to follow.
|
|
100
|
+
What a hostile PR can still do is skew the review text — it cannot run
|
|
101
|
+
anything, write anything, or reach the network. Read the verdict, don't
|
|
102
|
+
rubber-stamp it. (All of this applies to PRs you have *not* trusted; a
|
|
103
|
+
trusted one is deliberately given the run of the place.)
|
|
104
|
+
- Checkouts live in `~/.cerber/src/<owner>__<repo>__<n>` and are reused by later
|
|
105
|
+
re-reviews. A big monorepo is a few hundred MB per PR, so the cache keeps only
|
|
106
|
+
the 8 most recently reviewed and evicts the rest as it goes — never one a
|
|
107
|
+
review is currently reading. `cerber prune` reclaims the space now;
|
|
108
|
+
`cerber prune --all` takes the ones for reviews still awaiting you too.
|
|
109
|
+
|
|
110
|
+
### Trusted PRs: reviews that can run things
|
|
111
|
+
|
|
112
|
+
Reading beats guessing, but running beats reading. A review that ran the test
|
|
113
|
+
covering the change reports what happened; one that only read it guesses. So
|
|
114
|
+
for PRs you already trust — a teammate's, or anything in your own repos —
|
|
115
|
+
cerber can let the review run commands in the checkout: the test suite, a
|
|
116
|
+
typecheck, `git log`/`git blame`, a build.
|
|
117
|
+
|
|
118
|
+
Trust is about the *people*, and only about people — cerber has no way to
|
|
119
|
+
trust a repository, because anyone can open a PR against one:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
cerber trust @fullstackhouse/* # anyone in the org
|
|
123
|
+
cerber trust @fullstackhouse/devs # anyone on that GitHub team
|
|
124
|
+
cerber trust @teammate # one person, by login
|
|
125
|
+
cerber trust # show who is trusted today
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Membership is resolved against GitHub when the review runs (your `gh` login
|
|
129
|
+
needs `read:org`), and a lookup that fails counts as "not a member" — a check
|
|
130
|
+
cerber could not complete never reads as trust. Writing a repo instead of a
|
|
131
|
+
person is rejected, in the CLI, the cockpit, and on config load:
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
$ cerber trust acme/widgets
|
|
135
|
+
"acme/widgets" is not a trust rule. Trust is about people, not repositories —
|
|
136
|
+
anyone can open a PR against a repo you own, so trusting the repo would trust
|
|
137
|
+
them too. Use @acme/* for everyone in that org, @acme/team for one team, or
|
|
138
|
+
@login for a person.
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Rules live in `~/.cerber/config.json`, and the cockpit has a **settings**
|
|
142
|
+
screen that reads and writes the same file — each rule shown with what it
|
|
143
|
+
actually grants. A `!` rule denies and beats every grant, so `@fullstackhouse/*`
|
|
144
|
+
plus `!@fullstackhouse/contractors` does what it looks like. `--trust` and
|
|
145
|
+
`--no-trust` override the config for one run; `cerber trust <pattern> --delete`
|
|
146
|
+
removes a rule.
|
|
147
|
+
|
|
148
|
+
A trusted review gets `Bash`, `WebFetch` and `WebSearch` on top of reading, and
|
|
149
|
+
the repo's own `.claude` config applies — it behaves as if you had checked the
|
|
150
|
+
branch out and opened it yourself. The cockpit labels it "ran the code" so you
|
|
151
|
+
know which kind you are reading.
|
|
152
|
+
|
|
153
|
+
It is handed no GitHub credentials, and that part is *enforced* rather than
|
|
154
|
+
requested. `GH_CONFIG_DIR` points at a fresh empty directory, `GH_TOKEN` and
|
|
155
|
+
`GITHUB_TOKEN` are blanked, the fetch credential rides the fetch command
|
|
156
|
+
instead of being stored in the checkout, git's global and system config are
|
|
157
|
+
switched off so a keychain helper cannot stand in, and the ssh route is closed
|
|
158
|
+
too — no agent, no `~/.ssh/config`, no default identity — since otherwise a run
|
|
159
|
+
could point the remote at `git@github.com` and ride your keys. Measured from
|
|
160
|
+
inside a checkout: `git push` over https and over ssh, and `gh pr comment`, all
|
|
161
|
+
fail to authenticate, while `git log` still works.
|
|
162
|
+
|
|
163
|
+
**It is not a sandbox, and you should not read it as one.** A trusted run has
|
|
164
|
+
`Bash` and your filesystem: it can write files in the checkout, and a
|
|
165
|
+
determined one could read a key straight out of `~/.ssh` and use it itself.
|
|
166
|
+
What cerber guarantees is that it *hands* the run nothing. The rest is what
|
|
167
|
+
trusting a person means — which is why trust is spelled `@org/team`, and why
|
|
168
|
+
the default is a review that cannot run anything at all.
|
|
169
|
+
|
|
170
|
+
**What you are accepting.** A trusted review executes code from that PR on your
|
|
171
|
+
machine — the branch's scripts, its dependencies, its test suite. That is the
|
|
172
|
+
same exposure as checking the branch out and running the tests yourself, which
|
|
173
|
+
is what you would otherwise do; it is not the same as reading a diff. Trust
|
|
174
|
+
orgs and people, not the whole of GitHub. And note the daemon reviews
|
|
175
|
+
unattended: with trust rules set, `cerber serve --daemon` will run matching PRs'
|
|
176
|
+
code with nobody watching. It warns at startup, and `--no-trust` turns it off.
|
|
177
|
+
|
|
51
178
|
### When the PR moves under you
|
|
52
179
|
|
|
53
180
|
A review is written against one commit, but authors keep pushing. Opening a
|
package/dist/cli/index.js
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import readline from "node:readline/promises";
|
|
4
4
|
import { artifactId, artifactKey } from "../core/artifact.js";
|
|
5
|
+
import { listCheckouts, removeCheckout } from "../core/checkout.js";
|
|
5
6
|
import { toMarkdown } from "../core/export.js";
|
|
7
|
+
import { configPath, loadConfig, saveConfig } from "../core/config.js";
|
|
8
|
+
import { TrustRuleError, describeRule, explainRule, parseTrustRule } from "../core/trust.js";
|
|
6
9
|
import { parsePrRef, searchAwaitingMe, submitReview } from "../core/gh.js";
|
|
7
10
|
import { buildReviewPayload, computeCalibration, eventForRecommendation } from "../core/send.js";
|
|
8
11
|
import { cerberHome, listArtifacts, loadArtifact, readAutoSendLog, updateArtifactByKey, } from "../core/state.js";
|
|
@@ -23,6 +26,9 @@ program
|
|
|
23
26
|
.option("-a, --awaiting-me", "discover and review all open PRs awaiting your review")
|
|
24
27
|
.option("-P, --parallel <n>", "how many reviews to run concurrently", "3")
|
|
25
28
|
.option("-f, --force", "re-review even if the artifact is up to date with the PR head")
|
|
29
|
+
.option("--no-source", "review the diff alone: skip the local checkout of the PR head. Faster and cheaper, but the AI cannot see past the changed lines")
|
|
30
|
+
.option("-t, --trust", "let the review run commands in the checkout (tests, typecheck, git log) whatever the trust config says")
|
|
31
|
+
.option("--no-trust", "keep the review read-only even if a trust rule matches")
|
|
26
32
|
.action(async (prs, opts) => {
|
|
27
33
|
let refs;
|
|
28
34
|
if (opts.awaitingMe) {
|
|
@@ -52,6 +58,8 @@ program
|
|
|
52
58
|
const { artifact, skipped } = await reviewPr(ref, {
|
|
53
59
|
model: opts.model,
|
|
54
60
|
force: opts.force,
|
|
61
|
+
withSource: opts.source,
|
|
62
|
+
trust: opts.trust,
|
|
55
63
|
onProgress: (m) => console.log(`[${label}] ${m}`),
|
|
56
64
|
});
|
|
57
65
|
if (skipped) {
|
|
@@ -61,7 +69,7 @@ program
|
|
|
61
69
|
const v = artifact.verdict;
|
|
62
70
|
rows.push(`✔ ${label.padEnd(40)} ${v ? `${v.recommendation} ${v.confidence}%` : "?"}`.padEnd(65) +
|
|
63
71
|
`${artifact.comments.length} comment(s)` +
|
|
64
|
-
(artifact.run?.costUsd != null ? `,
|
|
72
|
+
(artifact.run?.costUsd != null ? `, ≈$${artifact.run.costUsd.toFixed(2)}` : ""));
|
|
65
73
|
}
|
|
66
74
|
catch (err) {
|
|
67
75
|
failures++;
|
|
@@ -74,6 +82,9 @@ program
|
|
|
74
82
|
if (skips > 0)
|
|
75
83
|
console.log(`(${skips} already up to date — use --force to re-review)`);
|
|
76
84
|
console.log(`\nArtifacts in ${cerberHome()}/reviews — view with: cerber serve`);
|
|
85
|
+
if (rows.some((r) => r.includes("≈$"))) {
|
|
86
|
+
console.log("(≈$ is what the tokens would cost at API rates — on a Claude subscription they draw on your usage limits, not your card.)");
|
|
87
|
+
}
|
|
77
88
|
if (failures > 0)
|
|
78
89
|
process.exitCode = 1;
|
|
79
90
|
});
|
|
@@ -161,6 +172,94 @@ program
|
|
|
161
172
|
}));
|
|
162
173
|
console.log(`✔ Review sent${url ? `: ${url}` : "."}`);
|
|
163
174
|
});
|
|
175
|
+
program
|
|
176
|
+
.command("trust")
|
|
177
|
+
.description("Show, add, or remove the people whose PRs a review may run (~/.cerber/config.json)")
|
|
178
|
+
.argument("[pattern]", "@org/*, @org/team, @login, or !pattern to deny — omit to list what is trusted today")
|
|
179
|
+
.option("-d, --delete", "remove this rule instead of adding it")
|
|
180
|
+
.action(async (pattern, opts) => {
|
|
181
|
+
const config = await loadConfig();
|
|
182
|
+
if (pattern) {
|
|
183
|
+
let rule;
|
|
184
|
+
try {
|
|
185
|
+
rule = parseTrustRule(pattern);
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
console.error(err instanceof TrustRuleError ? err.message : String(err));
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
if (!rule) {
|
|
192
|
+
console.error(`Not a trust rule: "${pattern}"`);
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
const canonical = describeRule(rule);
|
|
196
|
+
const without = config.trust.filter((line) => {
|
|
197
|
+
const parsed = parseTrustRule(line);
|
|
198
|
+
return !parsed || describeRule(parsed) !== canonical;
|
|
199
|
+
});
|
|
200
|
+
if (opts.delete) {
|
|
201
|
+
if (without.length === config.trust.length) {
|
|
202
|
+
console.error(`Not in the trust list: ${canonical}`);
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
await saveConfig({ ...config, trust: without });
|
|
206
|
+
console.log(`Removed ${canonical}. Those PRs are reviewed read-only again.`);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
await saveConfig({ ...config, trust: [...without, canonical] });
|
|
210
|
+
console.log(`Trusted: ${explainRule(rule)}\n → ${configPath()}`);
|
|
211
|
+
console.log("Reviews of matching PRs may run tests, typechecks and git commands in the checkout.");
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (config.trust.length === 0) {
|
|
215
|
+
console.log(`Nothing is trusted: every review reads the checkout and runs nothing.\n` +
|
|
216
|
+
`Trust people — never a repo, since anyone can open a PR against one:\n` +
|
|
217
|
+
` cerber trust @your-org/* # everyone in the org\n` +
|
|
218
|
+
` cerber trust @your-org/devs # one GitHub team\n` +
|
|
219
|
+
` cerber trust @teammate # one person\n` +
|
|
220
|
+
`Rules live in ${configPath()}, and in the cockpit under Settings.`);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
console.log(`Trusted people (${configPath()}):`);
|
|
224
|
+
for (const line of config.trust) {
|
|
225
|
+
const rule = parseTrustRule(line);
|
|
226
|
+
if (rule)
|
|
227
|
+
console.log(` ${describeRule(rule).padEnd(28)} ${explainRule(rule)}`);
|
|
228
|
+
}
|
|
229
|
+
console.log("\nEveryone else's PRs are reviewed read-only. Remove one with: cerber trust <pattern> --delete");
|
|
230
|
+
});
|
|
231
|
+
program
|
|
232
|
+
.command("prune")
|
|
233
|
+
.description("Delete cached source checkouts in ~/.cerber/src (they re-fetch on the next --with-source review)")
|
|
234
|
+
.option("--all", "delete every checkout, including ones for reviews still awaiting you")
|
|
235
|
+
.action(async (opts) => {
|
|
236
|
+
const checkouts = await listCheckouts();
|
|
237
|
+
if (checkouts.length === 0) {
|
|
238
|
+
console.log(`No source checkouts in ${cerberHome()}/src.`);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const artifacts = new Map((await listArtifacts()).map((a) => [a.id, a]));
|
|
242
|
+
const mb = (bytes) => {
|
|
243
|
+
const value = bytes / 1024 / 1024;
|
|
244
|
+
return `${value.toFixed(value < 10 ? 1 : 0)} MB`;
|
|
245
|
+
};
|
|
246
|
+
let freed = 0;
|
|
247
|
+
let kept = 0;
|
|
248
|
+
for (const checkout of checkouts) {
|
|
249
|
+
const artifact = artifacts.get(checkout.id);
|
|
250
|
+
// A review you have not sent yet is still live work — its checkout makes
|
|
251
|
+
// the next re-review cheap, so only --all takes it.
|
|
252
|
+
const done = !artifact || artifact.sent != null || artifact.pr.state !== "OPEN";
|
|
253
|
+
if (!opts.all && !done) {
|
|
254
|
+
kept++;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
await removeCheckout(checkout.dir);
|
|
258
|
+
freed += checkout.bytes;
|
|
259
|
+
console.log(`removed ${checkout.id.padEnd(40)} ${mb(checkout.bytes)}`);
|
|
260
|
+
}
|
|
261
|
+
console.log(`\nFreed ${mb(freed)}${kept > 0 ? `; kept ${kept} for open reviews (--all removes them too)` : ""}.`);
|
|
262
|
+
});
|
|
164
263
|
program
|
|
165
264
|
.command("stats")
|
|
166
265
|
.description("How well-calibrated the AI reviews are: verdict agreement and comment survival by confidence")
|
|
@@ -216,6 +315,8 @@ program
|
|
|
216
315
|
.option("-R, --repo <owner/repo>", "repo(s) the daemon watches (repeatable; default: all)", collect, [])
|
|
217
316
|
.option("-P, --parallel <n>", "daemon review concurrency", "3")
|
|
218
317
|
.option("-m, --model <model>", "Claude model override for daemon reviews")
|
|
318
|
+
.option("--no-source", "review the diff alone, with no local checkout of the PR head (applies to daemon runs and cockpit re-reviews)")
|
|
319
|
+
.option("--no-trust", "ignore the configured trust rules: every review here stays read-only, even for repos you trust")
|
|
219
320
|
.option("--auto-send", "ACTUALLY auto-send APPROVE verdicts at/above --auto-send-threshold (default: shadow mode, which only logs what would be sent)")
|
|
220
321
|
.option("--auto-send-threshold <pct>", "confidence needed to auto-send", "90")
|
|
221
322
|
.option("--insecure", "allow binding a non-localhost host without a token (NOT recommended)")
|
|
@@ -232,17 +333,35 @@ program
|
|
|
232
333
|
console.log(`⚠ auto-send is ON: APPROVE verdicts with confidence ≥ ${threshold}% will be sent to GitHub as you, without a per-review confirmation.\n` +
|
|
233
334
|
` Every decision is logged to ${cerberHome()}/autosend.ndjson. COMMENT/REQUEST_CHANGES always wait for a human.`);
|
|
234
335
|
}
|
|
336
|
+
// Trust means "run this PR's code". A human clicking review accepted that
|
|
337
|
+
// per PR; a daemon does it unattended, on whatever lands in the queue.
|
|
338
|
+
if (opts.daemon && opts.trust) {
|
|
339
|
+
const rules = (await loadConfig()).trust;
|
|
340
|
+
if (rules.length > 0) {
|
|
341
|
+
console.log(`⚠ ${rules.length} trust rule(s) in ${configPath()}: this daemon will run commands from matching PRs ` +
|
|
342
|
+
`automatically, with nobody watching. Pass --no-trust to keep every review read-only.`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
235
345
|
const daemon = opts.daemon
|
|
236
346
|
? startDaemon({
|
|
237
347
|
repos: opts.repo,
|
|
238
348
|
intervalMs: Math.max(1, Number(opts.interval) || 5) * 60_000,
|
|
239
349
|
parallel: Math.max(1, Number(opts.parallel) || 1),
|
|
240
350
|
model: opts.model,
|
|
351
|
+
withSource: opts.source,
|
|
352
|
+
trust: opts.trust ? undefined : false,
|
|
241
353
|
autoSend: opts.autoSend ? "on" : "shadow",
|
|
242
354
|
autoSendThreshold: threshold,
|
|
243
355
|
})
|
|
244
356
|
: undefined;
|
|
245
|
-
await startServer({
|
|
357
|
+
await startServer({
|
|
358
|
+
port: Number(opts.port),
|
|
359
|
+
host: opts.host,
|
|
360
|
+
token,
|
|
361
|
+
daemon,
|
|
362
|
+
withSource: opts.source,
|
|
363
|
+
trust: opts.trust ? undefined : false,
|
|
364
|
+
});
|
|
246
365
|
});
|
|
247
366
|
function collect(value, previous) {
|
|
248
367
|
return [...previous, value];
|
package/dist/core/artifact.js
CHANGED
|
@@ -62,6 +62,10 @@ export const RunInfoSchema = z.object({
|
|
|
62
62
|
finishedAt: z.string().nullable().default(null),
|
|
63
63
|
costUsd: z.number().nullable().default(null),
|
|
64
64
|
error: z.string().nullable().default(null),
|
|
65
|
+
/** The reviewer could read the repo at the PR head, not just the diff. */
|
|
66
|
+
withSource: z.boolean().default(false),
|
|
67
|
+
/** The user vouched for this PR, so the reviewer could also run commands. */
|
|
68
|
+
trusted: z.boolean().default(false),
|
|
65
69
|
});
|
|
66
70
|
export const SentInfoSchema = z.object({
|
|
67
71
|
at: z.string(),
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { cerberHome } from "./state.js";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
/**
|
|
8
|
+
* Config files a checked-out repo could use to make the reviewing agent run
|
|
9
|
+
* code. `claude -p` skips the workspace-trust prompt, so a PR that adds a hook
|
|
10
|
+
* here would otherwise execute on the reviewer's machine.
|
|
11
|
+
*/
|
|
12
|
+
export const AGENT_CONFIG_FILES = [
|
|
13
|
+
".claude/settings.json",
|
|
14
|
+
".claude/settings.local.json",
|
|
15
|
+
".mcp.json",
|
|
16
|
+
];
|
|
17
|
+
const QUARANTINE_SUFFIX = ".cerber-quarantined";
|
|
18
|
+
/**
|
|
19
|
+
* Auth for the fetch, passed per command and never written to the clone.
|
|
20
|
+
* Persisting it would leave a working push credential lying inside a directory
|
|
21
|
+
* a trusted review is about to run commands in.
|
|
22
|
+
*/
|
|
23
|
+
const FETCH_AUTH = ["-c", "credential.helper=!gh auth git-credential"];
|
|
24
|
+
const realDeps = {
|
|
25
|
+
git: async (args, cwd) => {
|
|
26
|
+
try {
|
|
27
|
+
const { stdout } = await execFileAsync("git", args, { cwd, maxBuffer: 16 * 1024 * 1024 });
|
|
28
|
+
return stdout;
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
const e = err;
|
|
32
|
+
throw new Error(`git ${args.slice(0, 2).join(" ")} failed: ${e.stderr?.trim() || e.message}`);
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
isRepo: (dir) => fs
|
|
36
|
+
.access(path.join(dir, ".git"))
|
|
37
|
+
.then(() => true)
|
|
38
|
+
.catch(() => false),
|
|
39
|
+
mkdir: async (dir) => {
|
|
40
|
+
await fs.mkdir(dir, { recursive: true });
|
|
41
|
+
},
|
|
42
|
+
quarantine: async (from, to) => {
|
|
43
|
+
try {
|
|
44
|
+
await fs.rename(from, to);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
if (err.code === "ENOENT")
|
|
49
|
+
return false;
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
touch: async (dir) => {
|
|
54
|
+
const now = new Date();
|
|
55
|
+
await fs.utimes(dir, now, now).catch(() => { });
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
/** One shallow working tree per PR, under ~/.cerber/src — a cache, safe to delete. */
|
|
59
|
+
export function sourceDir(ref) {
|
|
60
|
+
return path.join(cerberHome(), "src", `${ref.owner}__${ref.repo}__${ref.number}`);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* A fresh empty directory for one review run: the cwd of a diff-only run, and
|
|
64
|
+
* the throwaway `GH_CONFIG_DIR` of every run.
|
|
65
|
+
*
|
|
66
|
+
* Per run, not shared, because both uses assume "empty" and a run can write.
|
|
67
|
+
* A trusted run has `Bash`; given one directory reused forever, it could leave
|
|
68
|
+
* a `CLAUDE.md` there that every later diff-only run would then load as its
|
|
69
|
+
* own memory. Delete it with {@link removeRunDir} when the run ends.
|
|
70
|
+
*/
|
|
71
|
+
export async function createRunDir() {
|
|
72
|
+
const root = path.join(cerberHome(), "run");
|
|
73
|
+
await fs.mkdir(root, { recursive: true });
|
|
74
|
+
await sweepStaleRunDirs(root);
|
|
75
|
+
return fs.mkdtemp(path.join(root, "run-"));
|
|
76
|
+
}
|
|
77
|
+
export async function removeRunDir(dir) {
|
|
78
|
+
await fs.rm(dir, { recursive: true, force: true }).catch(() => { });
|
|
79
|
+
}
|
|
80
|
+
/** A crashed run leaves its directory behind; don't accumulate them forever. */
|
|
81
|
+
async function sweepStaleRunDirs(root) {
|
|
82
|
+
const cutoff = Date.now() - 24 * 60 * 60_000;
|
|
83
|
+
const entries = await fs.readdir(root).catch(() => []);
|
|
84
|
+
for (const entry of entries) {
|
|
85
|
+
if (!entry.startsWith("run-"))
|
|
86
|
+
continue;
|
|
87
|
+
const full = path.join(root, entry);
|
|
88
|
+
const stat = await fs.stat(full).catch(() => null);
|
|
89
|
+
if (stat && stat.mtimeMs < cutoff)
|
|
90
|
+
await removeRunDir(full);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Put the PR's head commit on disk so the reviewer can read the code around the
|
|
95
|
+
* diff. Fetches `refs/pull/N/head` from the base repo, which resolves fork PRs
|
|
96
|
+
* too, at depth 1 — cerber never needs the history, only the tree.
|
|
97
|
+
*
|
|
98
|
+
* Read-only towards GitHub. Auth rides `gh`'s credential helper, set locally on
|
|
99
|
+
* this clone so nothing in the user's git config is touched.
|
|
100
|
+
*/
|
|
101
|
+
export async function prepareCheckout(ref, opts = {}) {
|
|
102
|
+
const deps = { ...realDeps, ...opts.deps };
|
|
103
|
+
const log = opts.log ?? (() => { });
|
|
104
|
+
const dir = sourceDir(ref);
|
|
105
|
+
if (!(await deps.isRepo(dir))) {
|
|
106
|
+
log(`Preparing a source checkout in ${dir}…`);
|
|
107
|
+
await deps.mkdir(dir);
|
|
108
|
+
await deps.git(["init", "-q"], dir);
|
|
109
|
+
await deps.git(["remote", "add", "origin", `https://github.com/${ref.owner}/${ref.repo}.git`], dir);
|
|
110
|
+
}
|
|
111
|
+
// Clones made before the credential moved out of the config still carry one.
|
|
112
|
+
await deps.git(["config", "--unset-all", "credential.helper"], dir).catch(() => "");
|
|
113
|
+
await deps.git([...FETCH_AUTH, "fetch", "-q", "--depth=1", "--no-tags", "origin", `refs/pull/${ref.number}/head`], dir);
|
|
114
|
+
await deps.git(["checkout", "-q", "--force", "--detach", "FETCH_HEAD"], dir);
|
|
115
|
+
// A previous head may have left untracked files behind; they would read as
|
|
116
|
+
// part of this commit's tree. This also wipes any quarantine an earlier run
|
|
117
|
+
// left, which is why the loop below never has to undo one: every run starts
|
|
118
|
+
// from the commit's own files, whether or not the last one was trusted.
|
|
119
|
+
await deps.git(["clean", "-qfdx"], dir);
|
|
120
|
+
if (!opts.trusted) {
|
|
121
|
+
// Renamed rather than deleted: the reviewer can still read and comment on
|
|
122
|
+
// these files, but Claude Code won't load them as its own configuration.
|
|
123
|
+
for (const file of AGENT_CONFIG_FILES) {
|
|
124
|
+
const live = path.join(dir, file);
|
|
125
|
+
if (await deps.quarantine(live, live + QUARANTINE_SUFFIX)) {
|
|
126
|
+
log(`Quarantined ${file} from the checkout — PR config never configures the reviewer.`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const sha = (await deps.git(["rev-parse", "HEAD"], dir)).trim();
|
|
131
|
+
await deps.touch(dir);
|
|
132
|
+
return { dir, sha };
|
|
133
|
+
}
|
|
134
|
+
async function listCheckoutDirs() {
|
|
135
|
+
const root = path.join(cerberHome(), "src");
|
|
136
|
+
let entries;
|
|
137
|
+
try {
|
|
138
|
+
entries = await fs.readdir(root);
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
if (err.code === "ENOENT")
|
|
142
|
+
return [];
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
const found = [];
|
|
146
|
+
for (const entry of entries) {
|
|
147
|
+
const parsed = entry.match(/^(.+)__(.+)__(\d+)$/);
|
|
148
|
+
if (!parsed)
|
|
149
|
+
continue;
|
|
150
|
+
const dir = path.join(root, entry);
|
|
151
|
+
const usedAt = await fs.stat(dir).then((s) => s.mtimeMs, () => 0);
|
|
152
|
+
found.push({ id: `${parsed[1]}/${parsed[2]}#${parsed[3]}`, dir, usedAt });
|
|
153
|
+
}
|
|
154
|
+
return found;
|
|
155
|
+
}
|
|
156
|
+
/** Every checkout currently on disk, with what it costs. Walks each tree. */
|
|
157
|
+
export async function listCheckouts() {
|
|
158
|
+
const entries = await listCheckoutDirs();
|
|
159
|
+
const found = [];
|
|
160
|
+
for (const entry of entries) {
|
|
161
|
+
found.push({ id: entry.id, dir: entry.dir, bytes: await dirSize(entry.dir) });
|
|
162
|
+
}
|
|
163
|
+
return found.sort((a, b) => b.bytes - a.bytes);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Checkouts are the default now, so the cache has to stop growing on its own —
|
|
167
|
+
* a daemon watching a busy repo would otherwise clone every PR it ever sees and
|
|
168
|
+
* keep them all. Drops the least recently used, and never one being reviewed.
|
|
169
|
+
*/
|
|
170
|
+
export const MAX_CACHED_CHECKOUTS = 8;
|
|
171
|
+
export async function evictOldCheckouts(opts = {}) {
|
|
172
|
+
const keep = opts.keep ?? MAX_CACHED_CHECKOUTS;
|
|
173
|
+
const inUse = opts.inUse ?? (() => false);
|
|
174
|
+
const entries = (await listCheckoutDirs()).sort((a, b) => b.usedAt - a.usedAt);
|
|
175
|
+
const evicted = [];
|
|
176
|
+
let kept = 0;
|
|
177
|
+
for (const entry of entries) {
|
|
178
|
+
if (inUse(entry.id) || kept < keep) {
|
|
179
|
+
kept++;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
await removeCheckout(entry.dir);
|
|
183
|
+
evicted.push(entry.id);
|
|
184
|
+
}
|
|
185
|
+
return evicted;
|
|
186
|
+
}
|
|
187
|
+
export async function removeCheckout(dir) {
|
|
188
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
189
|
+
}
|
|
190
|
+
async function dirSize(dir) {
|
|
191
|
+
let total = 0;
|
|
192
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
193
|
+
for (const entry of entries) {
|
|
194
|
+
const full = path.join(dir, entry.name);
|
|
195
|
+
if (entry.isDirectory())
|
|
196
|
+
total += await dirSize(full);
|
|
197
|
+
else if (entry.isFile())
|
|
198
|
+
total += await fs.stat(full).then((s) => s.size, () => 0);
|
|
199
|
+
}
|
|
200
|
+
return total;
|
|
201
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { cerberHome } from "./state.js";
|
|
5
|
+
import { TrustRuleError, parseTrustRule } from "./trust.js";
|
|
6
|
+
/**
|
|
7
|
+
* `~/.cerber/config.json` — everything cerber lets you decide, in one file the
|
|
8
|
+
* cockpit can write and you can still edit by hand. Absent means defaults, so
|
|
9
|
+
* "no config" stays a working state.
|
|
10
|
+
*
|
|
11
|
+
* Rules stay one-line strings rather than objects: the same syntax you type at
|
|
12
|
+
* `cerber trust`, readable in a diff, and short enough to explain in a tooltip.
|
|
13
|
+
*/
|
|
14
|
+
export const ConfigSchema = z.object({
|
|
15
|
+
/** Whose PRs may be reviewed by running code. See core/trust.ts for syntax. */
|
|
16
|
+
trust: z
|
|
17
|
+
.array(z.string())
|
|
18
|
+
.default([])
|
|
19
|
+
// Checked here so a rule cerber cannot honour is caught when you write it,
|
|
20
|
+
// not silently ignored until you wonder why a review never runs anything.
|
|
21
|
+
.superRefine((lines, ctx) => {
|
|
22
|
+
lines.forEach((line, index) => {
|
|
23
|
+
try {
|
|
24
|
+
parseTrustRule(line);
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
if (!(err instanceof TrustRuleError))
|
|
28
|
+
throw err;
|
|
29
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [index], message: err.message });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}),
|
|
33
|
+
});
|
|
34
|
+
export const DEFAULT_CONFIG = { trust: [] };
|
|
35
|
+
export function configPath() {
|
|
36
|
+
return path.join(cerberHome(), "config.json");
|
|
37
|
+
}
|
|
38
|
+
export async function loadConfig() {
|
|
39
|
+
const raw = await fs.readFile(configPath(), "utf8").catch((err) => {
|
|
40
|
+
if (err.code === "ENOENT")
|
|
41
|
+
return null;
|
|
42
|
+
throw err;
|
|
43
|
+
});
|
|
44
|
+
if (raw === null)
|
|
45
|
+
return { ...DEFAULT_CONFIG };
|
|
46
|
+
try {
|
|
47
|
+
return ConfigSchema.parse(JSON.parse(raw));
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
// A hand-edited file with a typo shouldn't silently turn trust off — that
|
|
51
|
+
// would be a quiet security change in the *safe* direction, but a review
|
|
52
|
+
// would then behave differently for reasons nobody can see. So: stop, and
|
|
53
|
+
// say which entry is wrong in the words the person needs, not as a dump.
|
|
54
|
+
throw new Error(`${configPath()} is not valid cerber config:\n${describeConfigError(err)}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function describeConfigError(err) {
|
|
58
|
+
if (err instanceof z.ZodError) {
|
|
59
|
+
return err.issues
|
|
60
|
+
.map((issue) => ` ${issue.path.length ? `${issue.path.join(".")}: ` : ""}${issue.message}`)
|
|
61
|
+
.join("\n");
|
|
62
|
+
}
|
|
63
|
+
if (err instanceof SyntaxError)
|
|
64
|
+
return ` not JSON — ${err.message}`;
|
|
65
|
+
return ` ${err instanceof Error ? err.message : String(err)}`;
|
|
66
|
+
}
|
|
67
|
+
/** Write atomically — the cockpit and a `cerber trust` can both be writing. */
|
|
68
|
+
export async function saveConfig(config) {
|
|
69
|
+
const file = configPath();
|
|
70
|
+
await fs.mkdir(cerberHome(), { recursive: true });
|
|
71
|
+
const tmp = `${file}.tmp`;
|
|
72
|
+
let validated;
|
|
73
|
+
try {
|
|
74
|
+
validated = ConfigSchema.parse(config);
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
throw new Error(describeConfigError(err).trim());
|
|
78
|
+
}
|
|
79
|
+
await fs.writeFile(tmp, JSON.stringify(validated, null, 2) + "\n");
|
|
80
|
+
await fs.rename(tmp, file);
|
|
81
|
+
return file;
|
|
82
|
+
}
|
package/dist/core/gh.js
CHANGED
|
@@ -65,6 +65,30 @@ export async function fetchPrInfo(ref) {
|
|
|
65
65
|
changedFiles: raw.changedFiles ?? 0,
|
|
66
66
|
});
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Org and team membership, for `@org/*` and `@org/team` trust rules. A 404 is
|
|
70
|
+
* GitHub's "no" and the answer we want; anything else (no `read:org` scope, an
|
|
71
|
+
* outage) throws, so a broken check can never read as "trusted".
|
|
72
|
+
*/
|
|
73
|
+
export async function isOrgMember(org, login) {
|
|
74
|
+
return membershipCheck(["api", `orgs/${org}/members/${login}`, "--silent"]);
|
|
75
|
+
}
|
|
76
|
+
export async function isTeamMember(org, team, login) {
|
|
77
|
+
return membershipCheck(["api", `orgs/${org}/teams/${team}/memberships/${login}`, "--jq", ".state"]);
|
|
78
|
+
}
|
|
79
|
+
async function membershipCheck(args) {
|
|
80
|
+
try {
|
|
81
|
+
const out = await gh(args);
|
|
82
|
+
// The team endpoint answers for invited-but-not-yet-joined people too.
|
|
83
|
+
return out.trim() === "" || out.trim() === "active";
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
87
|
+
if (/not found|HTTP 404/i.test(message))
|
|
88
|
+
return false;
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
68
92
|
export async function fetchPrDiff(ref) {
|
|
69
93
|
return gh(["pr", "diff", String(ref.number), "--repo", `${ref.owner}/${ref.repo}`]);
|
|
70
94
|
}
|