@fullstackhouse/cerber 0.7.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 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 ? `, $${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")
@@ -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({ port: Number(opts.port), host: opts.host, token, daemon });
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];
@@ -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
  }