@fullstackhouse/cerber 0.7.0 → 0.9.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 CHANGED
@@ -28,10 +28,18 @@ npx @fullstackhouse/cerber review 123 124 --repo owner/repo
28
28
  # See what's in the queue
29
29
  npx @fullstackhouse/cerber list
30
30
 
31
- # Open the cockpit
31
+ # Open the cockpit — it's an inbox: it polls GitHub for PRs awaiting your
32
+ # review and drafts a review for each, so they're ready when you arrive
32
33
  npx @fullstackhouse/cerber serve # → http://127.0.0.1:4820
33
34
  ```
34
35
 
36
+ `serve` does the whole loop by default: discover → draft → wait for you.
37
+ Sending stays a human click. Tame it with `--no-auto-review` (list awaiting
38
+ PRs, review on click) or `--no-poll` (no GitHub polling at all) — or flip the
39
+ same two switches in the cockpit's Settings, which persist in
40
+ `~/.cerber/config.json` and apply on the next poll. Merged and closed PRs are
41
+ archived out of the queue automatically.
42
+
35
43
  ## What a review looks like
36
44
 
37
45
  Each review is a plain JSON artifact in `~/.cerber/reviews/` (override with
@@ -44,10 +52,138 @@ Each review is a plain JSON artifact in `~/.cerber/reviews/` (override with
44
52
  human's time
45
53
  - **Verdict** — approve / comment / request changes, with a 0–100 confidence
46
54
  score and reasoning
55
+ - **Run metadata** — model, whether it read the source or only the diff, and
56
+ the token spend as an API-rate equivalent
47
57
 
48
58
  The cockpit (`cerber serve`) renders the queue and the per-PR walkthrough with
49
59
  diffs. Artifacts are plain JSON you can `cat`, edit, or pipe into anything.
50
60
 
61
+ ### It reviews the code, not just the diff
62
+
63
+ Cerber checks the PR's head out locally and lets the review read it. That
64
+ matters because a reviewer holding only a diff hedges over things it could have
65
+ just looked up — *"I can't see the enclosing function, so this could be
66
+ wrong"* — and burns its confidence score on missing context instead of on real
67
+ uncertainty. With the source there, it opens the enclosing function, follows
68
+ callers of a changed signature, checks whether a helper already exists, and
69
+ sees whether a test covers the new path.
70
+
71
+ On one real PR, same commit, the difference was: a speculative "this *looks
72
+ like* it sits after an early return" became a concrete finding; a permission
73
+ check the diff showed as a tidy gate turned out to hide a value the API
74
+ response still ships; confidence went 72% → 82%. It burned 3.3× the tokens and
75
+ took three minutes instead of one.
76
+
77
+ Cerber rides your `claude` login, so a review isn't billed per run: on a Claude
78
+ subscription it draws on your usage limits. The `≈$` figures in the CLI and
79
+ cockpit are what those tokens would cost at API token rates — read them as
80
+ "how much of the plan did this eat", not as an invoice.
81
+
82
+ If you want the old behaviour — faster, lighter, blind past the changed lines:
83
+
84
+ ```bash
85
+ cerber review owner/repo#123 --no-source
86
+ cerber serve --no-source
87
+ ```
88
+
89
+ The cockpit labels every review "read the full source" or "read the diff only",
90
+ and offers a one-click **Re-review with full source** on the diff-only ones.
91
+
92
+ Details worth knowing:
93
+
94
+ - The checkout is a shallow (`--depth=1`) fetch of `refs/pull/N/head` from the
95
+ base repo, so fork PRs work with no extra remotes. Auth rides `gh`'s
96
+ credential helper, set on that clone only — your git config is untouched.
97
+ - If git or `gh` can't produce a checkout, the run says so and reviews the diff
98
+ alone. A missing checkout never fails a review.
99
+ - The run is read-only unless you have trusted the PR (see below): `Read`,
100
+ `Grep` and `Glob` are the only tools it gets. Without a checkout it gets none,
101
+ and runs in an empty directory, so it can never read whatever project cerber
102
+ happens to be started from and mistake it for the PR.
103
+ - PR content is untrusted, and `claude -p` skips the workspace-trust prompt, so
104
+ cerber does the distrusting itself: a `.claude/settings.json` or `.mcp.json`
105
+ in the checkout is renamed aside (still readable, no longer loaded), hooks
106
+ and non-cerber MCP servers are off, and the prompt tells the reviewer that
107
+ instructions found in files are material to review, not directions to follow.
108
+ What a hostile PR can still do is skew the review text — it cannot run
109
+ anything, write anything, or reach the network. Read the verdict, don't
110
+ rubber-stamp it. (All of this applies to PRs you have *not* trusted; a
111
+ trusted one is deliberately given the run of the place.)
112
+ - Checkouts live in `~/.cerber/src/<owner>__<repo>__<n>` and are reused by later
113
+ re-reviews. A big monorepo is a few hundred MB per PR, so the cache keeps only
114
+ the 8 most recently reviewed and evicts the rest as it goes — never one a
115
+ review is currently reading. `cerber prune` reclaims the space now;
116
+ `cerber prune --all` takes the ones for reviews still awaiting you too.
117
+
118
+ ### Trusted PRs: reviews that can run things
119
+
120
+ Reading beats guessing, but running beats reading. A review that ran the test
121
+ covering the change reports what happened; one that only read it guesses. So
122
+ for PRs you already trust — a teammate's, or anything in your own repos —
123
+ cerber can let the review run commands in the checkout: the test suite, a
124
+ typecheck, `git log`/`git blame`, a build.
125
+
126
+ Trust is about the *people*, and only about people — cerber has no way to
127
+ trust a repository, because anyone can open a PR against one:
128
+
129
+ ```bash
130
+ cerber trust @fullstackhouse/* # anyone in the org
131
+ cerber trust @fullstackhouse/devs # anyone on that GitHub team
132
+ cerber trust @teammate # one person, by login
133
+ cerber trust # show who is trusted today
134
+ ```
135
+
136
+ Membership is resolved against GitHub when the review runs (your `gh` login
137
+ needs `read:org`), and a lookup that fails counts as "not a member" — a check
138
+ cerber could not complete never reads as trust. Writing a repo instead of a
139
+ person is rejected, in the CLI, the cockpit, and on config load:
140
+
141
+ ```
142
+ $ cerber trust acme/widgets
143
+ "acme/widgets" is not a trust rule. Trust is about people, not repositories —
144
+ anyone can open a PR against a repo you own, so trusting the repo would trust
145
+ them too. Use @acme/* for everyone in that org, @acme/team for one team, or
146
+ @login for a person.
147
+ ```
148
+
149
+ Rules live in `~/.cerber/config.json`, and the cockpit has a **settings**
150
+ screen that reads and writes the same file — each rule shown with what it
151
+ actually grants. A `!` rule denies and beats every grant, so `@fullstackhouse/*`
152
+ plus `!@fullstackhouse/contractors` does what it looks like. `--trust` and
153
+ `--no-trust` override the config for one run; `cerber trust <pattern> --delete`
154
+ removes a rule.
155
+
156
+ A trusted review gets `Bash`, `WebFetch` and `WebSearch` on top of reading, and
157
+ the repo's own `.claude` config applies — it behaves as if you had checked the
158
+ branch out and opened it yourself. The cockpit labels it "ran the code" so you
159
+ know which kind you are reading.
160
+
161
+ It is handed no GitHub credentials, and that part is *enforced* rather than
162
+ requested. `GH_CONFIG_DIR` points at a fresh empty directory, `GH_TOKEN` and
163
+ `GITHUB_TOKEN` are blanked, the fetch credential rides the fetch command
164
+ instead of being stored in the checkout, git's global and system config are
165
+ switched off so a keychain helper cannot stand in, and the ssh route is closed
166
+ too — no agent, no `~/.ssh/config`, no default identity — since otherwise a run
167
+ could point the remote at `git@github.com` and ride your keys. Measured from
168
+ inside a checkout: `git push` over https and over ssh, and `gh pr comment`, all
169
+ fail to authenticate, while `git log` still works.
170
+
171
+ **It is not a sandbox, and you should not read it as one.** A trusted run has
172
+ `Bash` and your filesystem: it can write files in the checkout, and a
173
+ determined one could read a key straight out of `~/.ssh` and use it itself.
174
+ What cerber guarantees is that it *hands* the run nothing. The rest is what
175
+ trusting a person means — which is why trust is spelled `@org/team`, and why
176
+ the default is a review that cannot run anything at all.
177
+
178
+ **What you are accepting.** A trusted review executes code from that PR on your
179
+ machine — the branch's scripts, its dependencies, its test suite. That is the
180
+ same exposure as checking the branch out and running the tests yourself, which
181
+ is what you would otherwise do; it is not the same as reading a diff. Trust
182
+ orgs and people, not the whole of GitHub. And note that `cerber serve` reviews
183
+ unattended by default: with trust rules set, it will run matching PRs' code
184
+ with nobody watching. It warns at startup, the cockpit shows a ⚡ badge while
185
+ it's the case, and `--no-trust` (or `--no-auto-review`) turns it off.
186
+
51
187
  ### When the PR moves under you
52
188
 
53
189
  A review is written against one commit, but authors keep pushing. Opening a
@@ -72,9 +208,8 @@ Early, but all six planned phases have shipped.
72
208
  only GitHub write in the codebase) + `cerber export` to markdown
73
209
  3. ✅ `--awaiting-me` discovery, parallel runs (`--parallel`, default 3),
74
210
  freshness skip (same head SHA → no re-run; `--force` overrides), queue stats
75
- 4. ✅ Daemon/VPS mode: `cerber serve --daemon` polls for PRs awaiting your
76
- review and reviews them automatically (read-only); token auth for
77
- non-localhost binds
211
+ 4. ✅ Inbox mode (now the default): `cerber serve` polls for PRs awaiting your
212
+ review and reviews them automatically; token auth for non-localhost binds
78
213
  5. ✅ Confidence calibration (`cerber stats`), shadow-mode auto-send (default
79
214
  in daemon: logs what would be sent), opt-in `--auto-send` (approve-only,
80
215
  `--auto-send-threshold`, default 90%)
@@ -85,11 +220,11 @@ Early, but all six planned phases have shipped.
85
220
  ## Running on a VPS
86
221
 
87
222
  ```bash
88
- cerber serve --daemon --host 0.0.0.0 --token "$(openssl rand -hex 16)" \
223
+ cerber serve --host 0.0.0.0 --token "$(openssl rand -hex 16)" \
89
224
  --repo you/repo-a --repo you/repo-b --interval 10
90
225
  ```
91
226
 
92
- The daemon polls GitHub, reviews anything new (skipping PRs whose artifact
227
+ The server polls GitHub, reviews anything new (skipping PRs whose artifact
93
228
  already matches the head SHA), and the cockpit is always warm — open it any
94
229
  minute and see pending/ready/sent reviews. Auth: `?token=…` once in the
95
230
  browser (sets a cookie) or `Authorization: Bearer …`. Binding a non-localhost
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 ? `, $${artifact.run.costUsd.toFixed(2)}` : ""));
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")
@@ -206,16 +305,21 @@ program
206
305
  }
207
306
  });
208
307
  program
209
- .command("serve")
210
- .description("Start the review cockpit; --daemon keeps the queue warm by polling GitHub")
308
+ .command("serve", { isDefault: true })
309
+ .description("Start the review cockpit — an inbox: it polls GitHub for PRs awaiting your review and drafts " +
310
+ "a review for each, so they're ready when you open it. --no-poll / --no-auto-review to tame it.")
211
311
  .option("-p, --port <port>", "port", "4820")
212
312
  .option("-H, --host <host>", "host to bind (0.0.0.0 for VPS — requires --token)", "127.0.0.1")
213
313
  .option("-t, --token <token>", "require this token on every request (env: CERBER_TOKEN)")
214
- .option("-d, --daemon", "poll for PRs awaiting your review and auto-review them (read-only)")
215
- .option("-i, --interval <minutes>", "daemon poll interval", "5")
216
- .option("-R, --repo <owner/repo>", "repo(s) the daemon watches (repeatable; default: all)", collect, [])
217
- .option("-P, --parallel <n>", "daemon review concurrency", "3")
218
- .option("-m, --model <model>", "Claude model override for daemon reviews")
314
+ .option("--no-poll", "don't poll GitHub for PRs awaiting your review (also disables auto-review)")
315
+ .option("--no-auto-review", "list awaiting PRs but don't review them until you click review")
316
+ .option("-d, --daemon", "(deprecated no-op — polling and auto-review are now the default)")
317
+ .option("-i, --interval <minutes>", "poll interval (default: config daemon.intervalMinutes, 5)")
318
+ .option("-R, --repo <owner/repo>", "repo(s) to watch (repeatable; default: config daemon.repos, all)", collect, [])
319
+ .option("-P, --parallel <n>", "review concurrency (default: config daemon.parallel, 3)")
320
+ .option("-m, --model <model>", "Claude model override for unattended reviews")
321
+ .option("--no-source", "review the diff alone, with no local checkout of the PR head (applies to daemon runs and cockpit re-reviews)")
322
+ .option("--no-trust", "ignore the configured trust rules: every review here stays read-only, even for repos you trust")
219
323
  .option("--auto-send", "ACTUALLY auto-send APPROVE verdicts at/above --auto-send-threshold (default: shadow mode, which only logs what would be sent)")
220
324
  .option("--auto-send-threshold <pct>", "confidence needed to auto-send", "90")
221
325
  .option("--insecure", "allow binding a non-localhost host without a token (NOT recommended)")
@@ -232,17 +336,46 @@ program
232
336
  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
337
  ` Every decision is logged to ${cerberHome()}/autosend.ndjson. COMMENT/REQUEST_CHANGES always wait for a human.`);
234
338
  }
235
- const daemon = opts.daemon
339
+ if (opts.daemon) {
340
+ console.log("Note: --daemon is deprecated — polling and auto-review are on by default now.");
341
+ }
342
+ // Config holds the defaults and is re-read by the daemon on every poll
343
+ // (so the cockpit's Settings toggles apply live); a --no-* flag caps
344
+ // this run only, and --no-poll skips the loop entirely.
345
+ const config = await loadConfig();
346
+ const d = config.daemon;
347
+ // Trust means "run this PR's code". A human clicking review accepted that
348
+ // per PR; auto-review does it unattended, on whatever lands in the queue.
349
+ if (opts.poll && d.poll && opts.autoReview && d.autoReview && opts.trust && config.trust.length > 0) {
350
+ console.log(`⚠ ${config.trust.length} trust rule(s) in ${configPath()}: unattended reviews will run commands from ` +
351
+ `matching PRs, with nobody watching. Pass --no-trust to keep every review read-only, ` +
352
+ `or --no-auto-review to only review on click.`);
353
+ }
354
+ const daemon = opts.poll
236
355
  ? startDaemon({
237
- repos: opts.repo,
238
- intervalMs: Math.max(1, Number(opts.interval) || 5) * 60_000,
239
- parallel: Math.max(1, Number(opts.parallel) || 1),
356
+ repos: opts.repo.length > 0 ? opts.repo : d.repos,
357
+ intervalMs: Math.max(1, opts.interval !== undefined ? Number(opts.interval) || d.intervalMinutes : d.intervalMinutes) *
358
+ 60_000,
359
+ parallel: Math.max(1, opts.parallel !== undefined ? Number(opts.parallel) || d.parallel : d.parallel),
240
360
  model: opts.model,
361
+ withSource: opts.source,
362
+ trust: opts.trust ? undefined : false,
363
+ autoReview: opts.autoReview,
241
364
  autoSend: opts.autoSend ? "on" : "shadow",
242
365
  autoSendThreshold: threshold,
243
366
  })
244
367
  : undefined;
245
- await startServer({ port: Number(opts.port), host: opts.host, token, daemon });
368
+ if (!opts.poll) {
369
+ console.log("Polling is off (--no-poll) — the queue only shows what you review by hand.");
370
+ }
371
+ await startServer({
372
+ port: Number(opts.port),
373
+ host: opts.host,
374
+ token,
375
+ daemon,
376
+ withSource: opts.source,
377
+ trust: opts.trust ? undefined : false,
378
+ });
246
379
  });
247
380
  function collect(value, previous) {
248
381
  return [...previous, value];
@@ -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
+ }