@scopebond/hook 0.5.0 → 0.7.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.
Files changed (54) hide show
  1. package/README.md +139 -12
  2. package/dist/cli.d.ts +2 -1
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +667 -69
  5. package/dist/cli.js.map +1 -1
  6. package/dist/cloud.d.ts +2 -0
  7. package/dist/cloud.d.ts.map +1 -1
  8. package/dist/cloud.js +1 -1
  9. package/dist/cloud.js.map +1 -1
  10. package/dist/explain.d.ts +44 -0
  11. package/dist/explain.d.ts.map +1 -0
  12. package/dist/explain.js +75 -0
  13. package/dist/explain.js.map +1 -0
  14. package/dist/index.d.ts +9 -3
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +5 -2
  17. package/dist/index.js.map +1 -1
  18. package/dist/init.d.ts +9 -3
  19. package/dist/init.d.ts.map +1 -1
  20. package/dist/init.js +36 -28
  21. package/dist/init.js.map +1 -1
  22. package/dist/install.d.ts +63 -3
  23. package/dist/install.d.ts.map +1 -1
  24. package/dist/install.js +242 -28
  25. package/dist/install.js.map +1 -1
  26. package/dist/map.d.ts +12 -3
  27. package/dist/map.d.ts.map +1 -1
  28. package/dist/map.js +765 -60
  29. package/dist/map.js.map +1 -1
  30. package/dist/minimize.d.ts +6 -3
  31. package/dist/minimize.d.ts.map +1 -1
  32. package/dist/minimize.js +24 -4
  33. package/dist/minimize.js.map +1 -1
  34. package/dist/rules.d.ts +51 -0
  35. package/dist/rules.d.ts.map +1 -0
  36. package/dist/rules.js +216 -0
  37. package/dist/rules.js.map +1 -0
  38. package/dist/runtime-install.d.ts +25 -0
  39. package/dist/runtime-install.d.ts.map +1 -0
  40. package/dist/runtime-install.js +106 -0
  41. package/dist/runtime-install.js.map +1 -0
  42. package/dist/runtime.d.ts +17 -0
  43. package/dist/runtime.d.ts.map +1 -1
  44. package/dist/runtime.js +122 -11
  45. package/dist/runtime.js.map +1 -1
  46. package/dist/shell.d.ts +53 -4
  47. package/dist/shell.d.ts.map +1 -1
  48. package/dist/shell.js +737 -81
  49. package/dist/shell.js.map +1 -1
  50. package/dist/version.d.ts +5 -1
  51. package/dist/version.d.ts.map +1 -1
  52. package/dist/version.js +6 -2
  53. package/dist/version.js.map +1 -1
  54. package/package.json +3 -3
package/dist/cli.js CHANGED
@@ -2,20 +2,13 @@
2
2
  // scopebond-hook — govern a coding agent's tool calls against policy, in-path,
3
3
  // before they run, with a signed local receipt.
4
4
  //
5
- // scopebond-hook claude evaluate a Claude Code PreToolUse call (stdin JSON)
6
- // scopebond-hook cursor evaluate a Cursor hook event (stdin JSON)
7
- // scopebond-hook codex evaluate a Codex PreToolUse call (stdin JSON)
8
- // scopebond-hook init [--cursor|--codex] [--no-install]
9
- // scopebond-hook connect <url> <bundle.json> [--cursor|--codex]
10
- // enroll with a Cloud workspace and start exporting
11
- // scopebond-hook log [-n N] show the most recent local receipts
12
- // scopebond-hook verify verify every local receipt offline against the attester key
13
- // scopebond-hook test "<shell command>" show the decision for a command without recording it
14
- // scopebond-hook flush deliver any queued receipts to Cloud now
5
+ // The command list, arguments and examples live in `COMMANDS` near the bottom of this
6
+ // file, which is what `scopebond-hook help [command]` prints — one source rather than a
7
+ // comment here that drifts from it.
15
8
  //
16
9
  // Config dir: $SCOPEBOND_HOOK_DIR, else ./.scopebond
17
10
  // Fail-closed: any error denies the action with a repair message.
18
- import { readFileSync, existsSync, mkdtempSync, rmSync } from "node:fs";
11
+ import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
19
12
  import { join } from "node:path";
20
13
  import { tmpdir } from "node:os";
21
14
  import { execFileSync } from "node:child_process";
@@ -24,8 +17,12 @@ import { openReceiptStore, loadOrCreateAttester } from "@scopebond/gateway/node"
24
17
  import { mapClaudeToolUse, mapCodexToolUse, mapCursorEvent, fillPushBranch } from "./map.js";
25
18
  import { createHookRuntime } from "./runtime.js";
26
19
  import { scaffold, harnessSnippet, installHarness } from "./init.js";
27
- import { userHome, userHarnessFile, resolveConfigDir, writeHarnessConfig, removeHarnessConfig, cursorDetected, codexDetected, absoluteHookCommand, isHarnessConfigured, purgeHome, } from "./install.js";
20
+ import { userHome, userHarnessFile, resolveConfigDir, writeHarnessConfig, removeHarnessConfig, cursorDetected, codexDetected, absoluteHookCommand, isHarnessConfigured, purgeHome, harnessScopes, harnessScopeLabel, configuredHookCommands, hookCommandResolves, projectHarnessFile, trustProjectPolicy, untrustedProjectPolicy, } from "./install.js";
28
21
  import { connectCloud, loadConnection } from "./cloud.js";
22
+ import { compile, defaultRules, describeRules, loadRules, saveRules, rulesPath, pathRuleFor } from "./rules.js";
23
+ import { createSigner } from "@scopebond/sdk";
24
+ import { describeAction } from "./explain.js";
25
+ import { ensureDurableRuntime } from "./runtime-install.js";
29
26
  import { cliCommand, hookVersion } from "./version.js";
30
27
  import { fileURLToPath } from "node:url";
31
28
  /** The current git branch in `cwd` (best-effort). A bare `git push` pushes it, so
@@ -91,7 +88,9 @@ function denyClaude(reason) {
91
88
  process.stdout.write(JSON.stringify({
92
89
  hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason },
93
90
  }) + "\n");
94
- process.stderr.write(`Scopebond: ${reason}\n`);
91
+ // A composed explanation already names Scopebond in its first line; only the
92
+ // bare internal messages need the prefix.
93
+ process.stderr.write(`${reason.startsWith("Scopebond") ? reason : `Scopebond: ${reason}`}\n`);
95
94
  process.exit(2);
96
95
  }
97
96
  /** Codex accepts the structured deny response on a successful hook exit. Keeping
@@ -107,6 +106,18 @@ const harnessName = (harness) => harness === "claude" ? "Claude Code" : harness
107
106
  const harnessFileName = (harness) => harness === "claude" ? ".claude/settings.json" : harness === "cursor" ? ".cursor/hooks.json" : ".codex/hooks.json";
108
107
  const selectedHarness = (args) => args.includes("--codex") ? "codex" : args.includes("--cursor") ? "cursor" : "claude";
109
108
  const codexTrustStep = "Open Codex, run `/hooks`, review Scopebond, and choose Trust. Then start a new task.";
109
+ /** What Cursor can and cannot stop. Cursor has before-hooks for shell commands, MCP
110
+ * calls and file reads, but reports file *edits* only after they are written, so an
111
+ * out-of-policy edit is recorded and flagged rather than prevented. Said at install
112
+ * time, because someone choosing a guardrail needs to know its edges up front. */
113
+ const cursorCoverageNote = [
114
+ "What this covers in Cursor:",
115
+ " prevented shell commands, MCP tool calls, file reads — checked before they run",
116
+ " recorded file edits — Cursor reports an edit only after writing it, so an",
117
+ " out-of-policy edit is signed and flagged, not blocked",
118
+ "For edits that must be blocked before they land, use the GitHub Action as a required",
119
+ "check on pull requests.",
120
+ ].join("\n");
110
121
  async function runPreToolUse(mapper, deny = denyClaude) {
111
122
  let input;
112
123
  try {
@@ -115,11 +126,16 @@ async function runPreToolUse(mapper, deny = denyClaude) {
115
126
  catch {
116
127
  deny("hook received invalid JSON on stdin");
117
128
  }
129
+ let runtime;
118
130
  try {
119
131
  const cwd = input?.cwd ? String(input.cwd) : process.cwd();
120
- const runtime = createHookRuntime(runtimePaths(resolveConfigDir(cwd)));
132
+ runtime = createHookRuntime(runtimePaths(resolveConfigDir(cwd)));
121
133
  const decision = await runtime.evaluate(fillPushBranch(mapper(input), currentBranch(cwd)));
122
134
  await runtime.flush();
135
+ // Close before deciding: the receipt is already committed, and leaving the handle
136
+ // open is what made the write-ahead log grow without bound.
137
+ runtime.close();
138
+ runtime = undefined;
123
139
  if (decision.decision === "deny")
124
140
  deny(decision.reason);
125
141
  // Stay silent on allow/not_evaluated so the coding agent's normal permission
@@ -127,6 +143,10 @@ async function runPreToolUse(mapper, deny = denyClaude) {
127
143
  process.exit(0);
128
144
  }
129
145
  catch (error) {
146
+ try {
147
+ runtime?.close();
148
+ }
149
+ catch { /* already failing; the deny below is what matters */ }
130
150
  deny(`Scopebond hook failed closed: ${error.message}. Repair: run \`${cliCommand("init")}\`.`);
131
151
  }
132
152
  }
@@ -136,51 +156,137 @@ async function runClaude() {
136
156
  async function runCodex() {
137
157
  await runPreToolUse(mapCodexToolUse, denyCodex);
138
158
  }
159
+ function denyCursor(reason) {
160
+ process.stdout.write(JSON.stringify({ permission: "deny", agentMessage: reason }) + "\n");
161
+ process.exit(0);
162
+ }
139
163
  async function runCursor() {
140
- let input = {};
164
+ // Unparseable input denies, like every other adapter. Previously this fell through
165
+ // to evaluation with an empty payload, which mapped to no known action and so
166
+ // answered "ask" — handing an unreadable request to a prompt the user would very
167
+ // likely accept. Deny-by-default on unparseable input is not optional.
168
+ let parsed;
141
169
  try {
142
- input = JSON.parse(readStdin());
170
+ parsed = JSON.parse(readStdin());
171
+ }
172
+ catch {
173
+ denyCursor("Scopebond: hook received invalid JSON on stdin");
174
+ }
175
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
176
+ denyCursor("Scopebond: hook received a payload that is not a JSON object");
143
177
  }
144
- catch { /* fall through to fail-closed deny below */ }
145
- const event = String(input?.hook_event_name ?? input?.event ?? process.argv[3] ?? "");
178
+ const input = parsed;
179
+ const event = String(input.hook_event_name ?? input.event ?? process.argv[3] ?? "");
146
180
  let permission = "deny";
147
181
  let message = "Scopebond hook failed closed";
182
+ let postHoc = false;
183
+ let runtime;
148
184
  try {
149
185
  const cwd = input?.cwd ? String(input.cwd) : process.cwd();
150
- const runtime = createHookRuntime(runtimePaths(resolveConfigDir(cwd)));
151
- const decision = await runtime.evaluate(fillPushBranch(mapCursorEvent(event, input), currentBranch(cwd)));
186
+ runtime = createHookRuntime(runtimePaths(resolveConfigDir(cwd)));
187
+ const mapped = fillPushBranch(mapCursorEvent(event, input), currentBranch(cwd));
188
+ const decision = await runtime.evaluate(mapped);
152
189
  await runtime.flush();
153
- // Only an out-of-policy action is denied outright; an allowed or unevaluated
154
- // action defers to Cursor's own prompt ("ask"), never a silent auto-allow.
155
- permission = decision.decision === "deny" ? "deny" : "ask";
190
+ // An `afterFileEdit` violation is real and recorded, but the edit has already
191
+ // landed. Say so rather than letting "blocked" imply it was stopped.
192
+ postHoc = mapped.some((m) => m.postHoc);
193
+ // Three outcomes, three answers:
194
+ // deny — out of policy, blocked outright.
195
+ // allow — a rule was evaluated and permitted it. Returning "ask" here put a
196
+ // confirmation prompt in front of every ordinary command, which is not
197
+ // "your agent works as normal"; it also trained people to click through
198
+ // prompts, which makes the real denials easier to miss. An evaluated
199
+ // allow is a decision, not a silent auto-approval.
200
+ // ask — nothing was evaluated (no rule covers this action), so Cursor's own
201
+ // permission flow stays in charge. That is the fail-closed case and it
202
+ // keeps its prompt.
203
+ permission = decision.decision === "deny" ? "deny" : decision.decision === "allow" ? "allow" : "ask";
156
204
  message = decision.reason;
157
205
  }
158
206
  catch (error) {
159
207
  permission = "deny";
160
208
  message = `Scopebond hook failed closed: ${error.message}. Repair: run \`${cliCommand("init")}\`.`;
161
209
  }
210
+ finally {
211
+ // Release the SQLite handles on every path: an unclosed writer leaves its
212
+ // write-ahead log behind for the next tool call to extend.
213
+ try {
214
+ runtime?.close();
215
+ }
216
+ catch { /* the decision is already recorded */ }
217
+ }
218
+ if (postHoc && permission === "deny") {
219
+ message = `${message}\nCursor reports a file edit only after it is written, so this edit was not prevented. Review and revert it yourself.`;
220
+ }
162
221
  process.stdout.write(JSON.stringify({ permission, agentMessage: message }) + "\n");
163
222
  process.exit(0);
164
223
  }
224
+ /** `init`, `trust` and `uninstall` change what governs the agent, so they are for a
225
+ * person at a terminal. A coding agent's shell is not interactive: without a TTY on
226
+ * stdin they refuse unless `--yes` is passed (for scripts and CI). The starter policy
227
+ * also denies the agent running them. */
228
+ function requireInteractive(command, args) {
229
+ if (process.stdin.isTTY || args.includes("--yes"))
230
+ return;
231
+ console.error(`scopebond ${command} changes what governs your coding agent, so it does not run unattended.`);
232
+ console.error(`There is no terminal on stdin here — which is also what it looks like when the agent itself`);
233
+ console.error(`tries to run this, so the refusal is deliberate rather than a bug.`);
234
+ console.error(``);
235
+ console.error(`If you are a person: run it in your own terminal, or confirm it now with --yes:`);
236
+ console.error(` ${cliCommand(`${command} --yes`)}`);
237
+ console.error(`Scripts, CI and container builds should always pass --yes.`);
238
+ process.exit(1);
239
+ }
165
240
  function runInit(args) {
241
+ requireInteractive("init", args);
166
242
  const harness = selectedHarness(args);
167
243
  const dir = configDir();
168
- const { agentKid, policyPath } = scaffold(dir, { force: args.includes("--force") });
244
+ const { agentKid, policyPath, rulesPath: rulesFile } = scaffold(dir, { force: args.includes("--force") });
169
245
  console.log(`Scopebond hook enrolled in ${dir}`);
170
246
  console.log(` machine key ${agentKid}`);
171
- console.log(` policy ${policyPath} (starter — edit the limits)`);
247
+ console.log(` rules ${rulesFile} (the readable list — edit this)`);
248
+ console.log(` policy ${policyPath} (compiled from the rules; don't hand-edit)`);
249
+ // With a user-level install present, a project policy governs only once trusted.
250
+ // Running init here is that decision, so pin this policy now.
251
+ if (!process.env.SCOPEBOND_HOOK_DIR && existsSync(join(userHome(), "policy.json"))) {
252
+ trustProjectPolicy(dir);
253
+ console.log(` trusted overrides ${userHome()} here; after editing it, run \`${cliCommand("trust")}\``);
254
+ }
255
+ // The hook command runs once per tool call, so it must start fast. `npx` re-resolves
256
+ // a package that is already on disk and costs ~830 ms a call; the same CLI invoked
257
+ // directly costs ~110 ms. Pin a durable copy and use that, and fall back to `npx`
258
+ // (slow, but it always starts) when no durable copy can be made. `--npx` forces the
259
+ // portable form for anyone who wants it.
260
+ const pin = args.includes("--npx") ? { cli: null, how: "unavailable" } : ensureDurableRuntime(cliPath(), hookVersion());
261
+ const command = pin.cli ? absoluteHookCommand(pin.cli, harness) : undefined;
262
+ // No per-action millisecond claim here: it varies by machine, and this project only
263
+ // states numbers it has measured. The measured comparison lives in the changelog.
264
+ console.log(` hook runtime ${pin.cli
265
+ ? `${pin.cli}\n pinned — no npx resolution per action`
266
+ : `npx @scopebond/hook@${hookVersion()} — portable, but re-resolves on every action`}`);
172
267
  console.log("");
173
268
  // Configure the agent automatically by default (idempotent), so there is no
174
269
  // hand-editing step; --no-install prints the snippet instead.
175
270
  if (!args.includes("--no-install")) {
176
- const file = installHarness(harness);
271
+ let file;
272
+ try {
273
+ file = installHarness(harness, process.cwd(), command);
274
+ }
275
+ catch (error) {
276
+ console.error(error.message);
277
+ process.exit(1);
278
+ }
177
279
  console.log(`✓ ${harnessName(harness)} configured in ${file}`);
178
280
  if (harness === "codex")
179
281
  console.log(`\nOne last step: ${codexTrustStep}`);
282
+ if (harness === "cursor")
283
+ console.log(`\n${cursorCoverageNote}`);
180
284
  }
181
285
  else {
182
286
  console.log(`Add this to your ${harnessFileName(harness)}:`);
183
- console.log(harnessSnippet(harness));
287
+ console.log(harnessSnippet(harness, command));
288
+ if (harness === "cursor")
289
+ console.log(`\n${cursorCoverageNote}`);
184
290
  }
185
291
  console.log("");
186
292
  // Print the runnable `npx` form: after `npx @scopebond/hook init` there is no
@@ -199,15 +305,14 @@ function decisionOf(payload) {
199
305
  return "allow";
200
306
  }
201
307
  function describeIntent(payload) {
202
- const intent = (payload.intent ?? {});
203
- const p = (intent.params ?? {});
204
- const bits = intent.action_type === "shell.exec" ? String(p.program ?? "")
205
- : intent.action_type === "git.push" ? `${p.remote ?? ""} ${p.ref ?? ""}`.trim()
206
- : intent.action_type === "file.write" || intent.action_type === "file.read" ? String(p.path ?? "")
207
- : intent.action_type === "mcp.tool.call" ? `${p.server ?? ""}/${p.tool ?? ""}`
208
- : intent.action_type === "net.fetch" ? String(p.host ?? "") : "";
209
- return `${String(intent.action_type ?? "?")}${bits ? ` ${bits}` : ""}`;
308
+ return describeAction(payload.intent);
210
309
  }
310
+ /** `log [-n N] [--deny] [--since <when>]` — the recent decisions.
311
+ *
312
+ * "What did my agents get blocked on this week" had no answer at the CLI: the only
313
+ * output was an unfiltered tail. `--deny` and `--since` make it answerable, and the
314
+ * tail is read as a tail (`ORDER BY id DESC LIMIT`) rather than by loading and parsing
315
+ * every receipt ever recorded and slicing the end off. */
211
316
  async function runLog(args) {
212
317
  const dir = resolveConfigDir(process.cwd());
213
318
  const dbPath = join(dir, "receipts.db");
@@ -217,18 +322,259 @@ async function runLog(args) {
217
322
  }
218
323
  const nIdx = args.indexOf("-n");
219
324
  const n = nIdx >= 0 ? Math.max(1, Number(args[nIdx + 1]) || 20) : 20;
325
+ const denyOnly = args.includes("--deny");
326
+ const sinceIdx = args.indexOf("--since");
327
+ const since = sinceIdx >= 0 ? parseSince(args[sinceIdx + 1]) : null;
328
+ if (sinceIdx >= 0 && since === null) {
329
+ console.error(`--since wants a duration (7d, 24h, 30m) or a date (2026-09-25); got ${args[sinceIdx + 1] ?? "nothing"}`);
330
+ process.exit(1);
331
+ }
220
332
  const { store } = openReceiptStore({ db: dbPath });
221
- const all = await Promise.resolve(store.list());
222
- const recent = all.slice(-n);
223
- if (recent.length === 0) {
224
- console.log("no receipts yet.");
333
+ try {
334
+ const total = store.count ? await Promise.resolve(store.count()) : (await Promise.resolve(store.list())).length;
335
+ // A filter has to look past the last N to find N matches; without one, read only the
336
+ // tail. The scan is still bounded so a huge log cannot hang the command.
337
+ const budget = denyOnly || since ? Math.min(Math.max(n * 50, 1000), 20000) : n;
338
+ const scanned = store.recent
339
+ ? await Promise.resolve(store.recent(budget))
340
+ : (await Promise.resolve(store.list())).slice(-budget).reverse();
341
+ const matching = scanned.filter((r) => {
342
+ const p = r.payload;
343
+ if (denyOnly && decisionOf(p) !== "deny")
344
+ return false;
345
+ if (since) {
346
+ const at = Date.parse(String(p.timestamp ?? ""));
347
+ if (!Number.isFinite(at) || at < since)
348
+ return false;
349
+ }
350
+ return true;
351
+ });
352
+ const shown = matching.slice(0, n).reverse(); // oldest-first on screen, newest last
353
+ if (shown.length === 0) {
354
+ console.log(denyOnly || since ? "no receipts match that filter." : "no receipts yet.");
355
+ process.exit(0);
356
+ }
357
+ for (const r of shown) {
358
+ const p = r.payload;
359
+ console.log(`${String(p.timestamp ?? "")} ${decisionOf(p).padEnd(13)} ${describeIntent(p)}`);
360
+ }
361
+ const filters = [denyOnly ? "denied" : "", since ? `since ${new Date(since).toISOString()}` : ""].filter(Boolean).join(", ");
362
+ const scope = filters ? ` matching ${filters}` : "";
363
+ const capped = (denyOnly || since) && scanned.length >= budget && total > budget;
364
+ console.log(`\n${shown.length}${scope} of ${total} receipt(s)${capped ? ` — searched the most recent ${budget}` : ""}. Verify them: ${cliCommand("verify")}`);
365
+ }
366
+ finally {
367
+ try {
368
+ store.close?.();
369
+ }
370
+ catch { /* read-only */ }
371
+ }
372
+ }
373
+ /** `rules` — read and change the limits in plain terms.
374
+ *
375
+ * Without this, "edit the limits" meant hand-writing a ~700-character case-folded
376
+ * negative lookahead, which nobody does — so the starter policy was effectively the only
377
+ * policy. The lists live in `.scopebond/rules.json`; `policy.json` is compiled from them. */
378
+ function runRules(args) {
379
+ const dir = resolveConfigDir(process.cwd());
380
+ if (!existsSync(join(dir, "policy.json"))) {
381
+ console.error(`no policy here yet — run \`${cliCommand("init")}\` first.`);
382
+ process.exit(1);
383
+ }
384
+ const rules = loadRules(dir) ?? defaultRules();
385
+ const [verb, ...values] = args.filter((a) => !a.startsWith("--"));
386
+ const value = values.join(" ").trim();
387
+ if (!verb || verb === "show") {
388
+ console.log(`Rules for ${dir}`);
389
+ if (!loadRules(dir))
390
+ console.log(`(showing the defaults — ${rulesPath(dir)} will be written on your first change)`);
391
+ console.log("");
392
+ console.log(describeRules(rules));
393
+ console.log("");
394
+ console.log(`Change them:`);
395
+ console.log(` ${cliCommand("rules allow <program>")} stop blocking a program`);
396
+ console.log(` ${cliCommand("rules block <program>")} start blocking one`);
397
+ console.log(` ${cliCommand("rules protect <path>")} never write there`);
398
+ console.log(` ${cliCommand("rules unprotect <path>")} allow writing there again`);
399
+ console.log(` ${cliCommand("rules protect-branch <name>")} never push there`);
400
+ console.log(` ${cliCommand("rules unprotect-branch <name>")}`);
401
+ console.log(`Or edit ${rulesPath(dir)} directly, then \`${cliCommand("rules apply")}\`.`);
402
+ process.exit(0);
403
+ }
404
+ const needsValue = ["allow", "block", "protect", "unprotect", "protect-branch", "unprotect-branch"];
405
+ if (needsValue.includes(verb) && !value) {
406
+ console.error(`\`rules ${verb}\` needs a value, e.g. \`${cliCommand(`rules ${verb} ${verb.includes("branch") ? "production" : verb.includes("protect") ? "infra/" : "dd"}`)}\``);
407
+ process.exit(1);
408
+ }
409
+ let changed = "";
410
+ switch (verb) {
411
+ case "allow": {
412
+ const before = rules.destructive_programs.length;
413
+ rules.destructive_programs = rules.destructive_programs.filter((p) => p.toLowerCase() !== value.toLowerCase());
414
+ if (rules.destructive_programs.length === before) {
415
+ console.log(`${value} was not in the blocked list; nothing to change.`);
416
+ process.exit(0);
417
+ }
418
+ changed = `${value} is no longer blocked`;
419
+ break;
420
+ }
421
+ case "block": {
422
+ if (rules.destructive_programs.some((p) => p.toLowerCase() === value.toLowerCase())) {
423
+ console.log(`${value} is already blocked.`);
424
+ process.exit(0);
425
+ }
426
+ rules.destructive_programs.push(value.toLowerCase());
427
+ changed = `${value} is now blocked`;
428
+ break;
429
+ }
430
+ case "protect":
431
+ case "unprotect": {
432
+ const list = verb === "protect" ? "protected_write" : "protected_write";
433
+ const rule = pathRuleFor(value);
434
+ if (verb === "protect") {
435
+ if (rules[list].some((r) => r.label === rule.label)) {
436
+ console.log(`${value} is already protected.`);
437
+ process.exit(0);
438
+ }
439
+ rules[list].push(rule);
440
+ changed = `writes to ${rule.label} are now blocked`;
441
+ }
442
+ else {
443
+ const before = rules[list].length;
444
+ rules[list] = rules[list].filter((r) => r.label !== rule.label && !r.label.startsWith(`${value.replace(/\/$/, "")}/`));
445
+ if (rules[list].length === before) {
446
+ console.log(`${value} is not in the protected list. Current list:`);
447
+ for (const r of rules[list])
448
+ console.log(` ${r.label}`);
449
+ process.exit(1);
450
+ }
451
+ changed = `writes to ${value} are allowed again`;
452
+ }
453
+ break;
454
+ }
455
+ case "protect-branch": {
456
+ if (rules.protected_branches.some((b) => b.toLowerCase() === value.toLowerCase())) {
457
+ console.log(`${value} is already protected.`);
458
+ process.exit(0);
459
+ }
460
+ rules.protected_branches.push(value);
461
+ changed = `pushes to ${value} are now blocked`;
462
+ break;
463
+ }
464
+ case "unprotect-branch": {
465
+ const before = rules.protected_branches.length;
466
+ rules.protected_branches = rules.protected_branches.filter((b) => b.toLowerCase() !== value.toLowerCase());
467
+ if (rules.protected_branches.length === before) {
468
+ console.log(`${value} was not protected; nothing to change.`);
469
+ process.exit(0);
470
+ }
471
+ changed = `pushes to ${value} are allowed again`;
472
+ break;
473
+ }
474
+ case "apply":
475
+ changed = `recompiled from ${rulesPath(dir)}`;
476
+ break;
477
+ default:
478
+ console.error(`unknown: rules ${verb}`);
479
+ printHelp("rules", true);
480
+ process.exit(1);
481
+ }
482
+ // Changing what governs the agent is the same class of action as `init`.
483
+ requireInteractive("rules", args);
484
+ const policyPath = join(dir, "policy.json");
485
+ const agentKid = createSigner({ privateKeyPem: readFileSync(join(dir, "agent.key"), "utf8") }).kid;
486
+ saveRules(dir, rules);
487
+ writeFileSync(policyPath, `${JSON.stringify(compile(rules, agentKid), null, 2)}\n`);
488
+ console.log(`✓ ${changed}`);
489
+ console.log(` rules ${rulesPath(dir)}`);
490
+ console.log(` policy ${policyPath} (recompiled)`);
491
+ // A project policy governs only once trusted, and the hash just changed.
492
+ if (!process.env.SCOPEBOND_HOOK_DIR && existsSync(join(userHome(), "policy.json"))) {
493
+ trustProjectPolicy(dir);
494
+ console.log(` trusted re-pinned for this project`);
495
+ }
496
+ console.log(`\nCheck it: ${cliCommand('test "rm -rf /"')}`);
497
+ process.exit(0);
498
+ }
499
+ /** `prune --before <when> [--yes]` — bound the local receipt store.
500
+ *
501
+ * Signed receipts are the product, so this never runs on its own and never quietly
502
+ * destroys anything: it archives what it will remove to a JSONL file beside the
503
+ * database first, and it refuses entirely once the log has been anchored, because a
504
+ * receipt's position is its anchor leaf index. Without a `--before` it reports the
505
+ * footprint and exits. */
506
+ async function runPrune(args) {
507
+ const dir = resolveConfigDir(process.cwd());
508
+ const dbPath = join(dir, "receipts.db");
509
+ if (!existsSync(dbPath)) {
510
+ console.log("no local receipts yet — nothing to prune.");
511
+ process.exit(0);
512
+ }
513
+ const beforeIdx = args.indexOf("--before");
514
+ if (beforeIdx < 0) {
515
+ console.log(`local receipts ${dbPath}`);
516
+ console.log(` ${describeStore(dbPath)}`);
517
+ console.log(`\nNothing is removed automatically. To bound it, name a cutoff:`);
518
+ console.log(` ${cliCommand("prune --before 90d")} # older than 90 days`);
519
+ console.log(` ${cliCommand("prune --before 2026-01-01")}`);
520
+ console.log(`Receipts are archived beside the database before removal.`);
225
521
  process.exit(0);
226
522
  }
227
- for (const r of recent) {
228
- const p = r.payload;
229
- console.log(`${String(p.timestamp ?? "")} ${decisionOf(p).padEnd(13)} ${describeIntent(p)}`);
523
+ const cutoff = parseSince(args[beforeIdx + 1]);
524
+ if (cutoff === null) {
525
+ console.error(`--before wants a duration (90d, 24h) or a date (2026-01-01); got ${args[beforeIdx + 1] ?? "nothing"}`);
526
+ process.exit(1);
527
+ }
528
+ const iso = new Date(cutoff).toISOString();
529
+ const { store } = openReceiptStore({ db: dbPath });
530
+ const sqlite = store;
531
+ if (!sqlite.before || !sqlite.removeBefore) {
532
+ console.error("this receipt store does not support pruning (no SQLite available).");
533
+ process.exit(1);
534
+ }
535
+ try {
536
+ const doomed = sqlite.before(iso);
537
+ if (doomed.length === 0) {
538
+ console.log(`no receipts older than ${iso}.`);
539
+ process.exit(0);
540
+ }
541
+ const before = describeStore(dbPath);
542
+ console.log(`${doomed.length} receipt(s) recorded before ${iso} (store is currently ${before}).`);
543
+ if (!args.includes("--yes") && !process.stdin.isTTY) {
544
+ console.error(`\nThis removes signed evidence, so it needs an explicit confirmation:`);
545
+ console.error(` ${cliCommand(`prune --before ${args[beforeIdx + 1]} --yes`)}`);
546
+ process.exit(1);
547
+ }
548
+ const archive = join(dir, `receipts-archived-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`);
549
+ writeFileSync(archive, `${doomed.map((r) => JSON.stringify(r)).join("\n")}\n`);
550
+ console.log(`archived to ${archive}`);
551
+ const { removed } = sqlite.removeBefore(iso);
552
+ console.log(`removed ${removed} receipt(s)`);
553
+ store.close?.();
554
+ console.log(`store now ${describeStore(dbPath)}`);
555
+ console.log(`\nThe archive is a plain JSONL of signed receipts — still verifiable, still yours.`);
556
+ process.exit(0);
557
+ }
558
+ catch (error) {
559
+ try {
560
+ store.close?.();
561
+ }
562
+ catch { /* closing after a failure */ }
563
+ console.error(`prune refused: ${error.message}`);
564
+ process.exit(1);
230
565
  }
231
- console.log(`\n${recent.length} of ${all.length} receipt(s). Verify them: ${cliCommand("verify")}`);
566
+ }
567
+ /** A `--since` value: a duration (`7d`, `24h`, `30m`) or an ISO-ish date. */
568
+ export function parseSince(value, now = Date.now()) {
569
+ if (!value)
570
+ return null;
571
+ const duration = /^(\d+)\s*([dhm])$/i.exec(value.trim());
572
+ if (duration) {
573
+ const scale = { d: 86_400_000, h: 3_600_000, m: 60_000 }[duration[2].toLowerCase()];
574
+ return now - Number(duration[1]) * scale;
575
+ }
576
+ const at = Date.parse(value);
577
+ return Number.isFinite(at) ? at : null;
232
578
  }
233
579
  async function runVerify() {
234
580
  const dir = resolveConfigDir(process.cwd());
@@ -240,10 +586,18 @@ async function runVerify() {
240
586
  }
241
587
  const { attester } = loadOrCreateAttester({ file: attesterPath });
242
588
  const { store } = openReceiptStore({ db: dbPath });
589
+ // Verification is the one command that must read everything — that is the point of it.
590
+ // It just should not look hung while doing so: at ~2.6 s per 20,000 receipts a long
591
+ // history is a visible wait, so report progress on a TTY.
243
592
  const all = await Promise.resolve(store.list());
593
+ try {
594
+ store.close?.();
595
+ }
596
+ catch { /* read-only */ }
597
+ const progress = process.stdout.isTTY && all.length >= 2000;
244
598
  let ok = 0;
245
599
  const bad = [];
246
- for (const r of all) {
600
+ for (const [index, r] of all.entries()) {
247
601
  const result = verifyReceipt(r, attester.publicKeyPem);
248
602
  if (result.valid)
249
603
  ok += 1;
@@ -251,7 +605,11 @@ async function runVerify() {
251
605
  const failed = Object.entries(result).filter(([k, v]) => k.endsWith("_valid") && v === false).map(([k]) => k);
252
606
  bad.push(`${String(r.payload.timestamp ?? "")}: ${failed.join(", ") || "invalid"}`);
253
607
  }
608
+ if (progress && (index + 1) % 1000 === 0)
609
+ process.stderr.write(`\rverifying ${index + 1}/${all.length}…`);
254
610
  }
611
+ if (progress)
612
+ process.stderr.write("\r".padEnd(40) + "\r");
255
613
  console.log(`${ok}/${all.length} receipt(s) verify offline against ${attesterPath}.`);
256
614
  if (bad.length) {
257
615
  for (const b of bad)
@@ -284,10 +642,17 @@ async function runTest(args) {
284
642
  console.log(`command: ${command}`);
285
643
  for (const m of mapped) {
286
644
  const d = await runtime.evaluateOne(m);
287
- console.log(` ${describeIntent({ intent: m.intent }).padEnd(28)} → ${d.decision}${d.reason ? ` (${d.reason})` : ""}`);
645
+ // One tidy row per action. A deny's full explanation is multi-line, so the
646
+ // row carries only the deciding rule and the whole message is printed once,
647
+ // below — exactly as the agent will receive it.
648
+ const note = d.decision === "deny" ? (d.clauseId ? `rule "${d.clauseId}"` : "")
649
+ : d.decision === "not_evaluated" ? d.reason : "";
650
+ console.log(` ${describeIntent({ intent: m.intent }).padEnd(28)} → ${d.decision.padEnd(14)}${note}`);
288
651
  }
289
652
  const overall = await runtime.evaluate(mapped);
290
- console.log(`\noverall: ${overall.decision}${overall.reason ? ` · ${overall.reason}` : ""}`);
653
+ console.log(`\noverall: ${overall.decision}`);
654
+ if (overall.decision === "deny" && overall.reason)
655
+ console.log(`\n${overall.reason}`);
291
656
  process.exit(overall.decision === "deny" ? 2 : 0);
292
657
  }
293
658
  finally {
@@ -363,19 +728,49 @@ function cliPath() {
363
728
  * so every project a developer opens is governed without a per-repo `init`. */
364
729
  function runInstall(args) {
365
730
  const dir = userHome();
731
+ const harnessesFor = () => args.includes("--codex") ? ["codex"]
732
+ : args.includes("--cursor") ? ["cursor"]
733
+ : args.includes("--claude") ? ["claude"]
734
+ : ["claude", ...(cursorDetected() ? ["cursor"] : []), ...(codexDetected() ? ["codex"] : [])];
735
+ // `install` rewrites agent config files the user did not create — their theme, plugins
736
+ // and permissions live in ~/.claude/settings.json — so there is a way to see exactly
737
+ // what it would touch before it touches anything.
738
+ if (args.includes("--dry-run")) {
739
+ console.log(`Dry run — nothing is written.\n`);
740
+ console.log(`Would scaffold ${dir} (machine key, countersigning key, starter policy)`);
741
+ for (const h of harnessesFor()) {
742
+ const file = userHarnessFile(h);
743
+ const exists = existsSync(file);
744
+ console.log(`Would ${exists ? "modify" : "create"} ${file}`);
745
+ if (exists)
746
+ console.log(` backing it up to ${file}.scopebond-backup`);
747
+ console.log(` adding hook ${absoluteHookCommand(cliPath(), h)}`);
748
+ if (exists && isHarnessConfigured(file))
749
+ console.log(` (a Scopebond hook is already there; it would be replaced, not duplicated)`);
750
+ }
751
+ console.log(`\nNothing else in those files is changed. Run without --dry-run to apply.`);
752
+ process.exit(0);
753
+ }
366
754
  const { agentKid, policyPath } = scaffold(dir, { force: args.includes("--force") });
367
755
  console.log(`Scopebond installed for this user in ${dir}`);
368
756
  console.log(` machine key ${agentKid}`);
369
757
  console.log(` policy ${policyPath} (starter — edit the limits)`);
370
758
  console.log("");
371
- const harnesses = args.includes("--codex") ? ["codex"]
372
- : args.includes("--cursor") ? ["cursor"]
373
- : args.includes("--claude") ? ["claude"]
374
- : ["claude", ...(cursorDetected() ? ["cursor"] : []), ...(codexDetected() ? ["codex"] : [])];
759
+ const harnesses = harnessesFor();
375
760
  if (!args.includes("--no-install")) {
376
761
  for (const h of harnesses) {
377
- const file = writeHarnessConfig(userHarnessFile(h), h, absoluteHookCommand(cliPath(), h));
378
- console.log(`✓ ${harnessName(h)} configured in ${file}`);
762
+ try {
763
+ const target = userHarnessFile(h);
764
+ const backup = existsSync(target) ? `${target}.scopebond-backup` : null;
765
+ const file = writeHarnessConfig(target, h, absoluteHookCommand(cliPath(), h));
766
+ console.log(`✓ ${harnessName(h)} configured in ${file}`);
767
+ if (backup && existsSync(backup))
768
+ console.log(` original kept at ${backup}`);
769
+ }
770
+ catch (error) {
771
+ console.error(`✗ ${harnessName(h)}: ${error.message}`);
772
+ process.exitCode = 1;
773
+ }
379
774
  }
380
775
  }
381
776
  else {
@@ -385,26 +780,63 @@ function runInstall(args) {
385
780
  if (harnesses.includes("codex"))
386
781
  console.log(`\nOne last step for Codex: ${codexTrustStep}`);
387
782
  console.log("");
388
- console.log("A project-local .scopebond still takes precedence when present.");
783
+ console.log(`A project's own .scopebond policy applies only after you trust it there (${cliCommand("trust")}).`);
389
784
  console.log(`Check it: ${cliCommand("doctor")} · see decisions: ${cliCommand("log")}`);
390
785
  console.log(`To send receipts to a workspace: ${cliCommand("connect <workspace-url> <enrollment>")}`);
391
786
  }
787
+ /** Size and count of the local receipt store, plus its write-ahead log. Signed evidence
788
+ * accumulates in the user's project directory and there is no automatic deletion — so
789
+ * the footprint is reported rather than left to be discovered. */
790
+ function describeStore(dbPath) {
791
+ const bytes = (file) => { try {
792
+ return statSync(file).size;
793
+ }
794
+ catch {
795
+ return 0;
796
+ } };
797
+ const total = bytes(dbPath) + bytes(`${dbPath}-wal`) + bytes(`${dbPath}-shm`);
798
+ const human = total >= 1024 * 1024 ? `${(total / 1024 / 1024).toFixed(1)} MiB` : `${Math.max(1, Math.round(total / 1024))} KiB`;
799
+ let count = null;
800
+ try {
801
+ const { store } = openReceiptStore({ db: dbPath });
802
+ try {
803
+ count = store.count ? Number(store.count()) : null;
804
+ }
805
+ finally {
806
+ store.close?.();
807
+ }
808
+ }
809
+ catch {
810
+ count = null;
811
+ }
812
+ return count === null ? human : `${count} receipt(s), ${human}`;
813
+ }
392
814
  function runStatus() {
393
815
  const home = userHome();
394
816
  const installed = existsSync(join(home, "policy.json"));
395
- const claude = isHarnessConfigured(userHarnessFile("claude"));
396
- const cursor = isHarnessConfigured(userHarnessFile("cursor"));
397
- const codex = isHarnessConfigured(userHarnessFile("codex"));
817
+ // Both scopes, always: `init` writes the project config and `install` writes the
818
+ // user one, so a single-scope check contradicts whichever command the user ran.
819
+ const claude = harnessScopes("claude", process.cwd());
820
+ const cursor = harnessScopes("cursor", process.cwd());
821
+ const codex = harnessScopes("codex", process.cwd());
398
822
  const connected = !!loadConnection(resolveConfigDir(process.cwd()));
399
823
  const dbPath = join(resolveConfigDir(process.cwd()), "receipts.db");
400
824
  console.log(`Scopebond hook ${hookVersion()}`);
401
825
  console.log(` user home ${home} ${installed ? "(installed)" : "(not installed — run `scopebond install`)"}`);
402
826
  console.log(` active config ${resolveConfigDir(process.cwd())}`);
403
- console.log(` Claude Code ${claude ? "configured" : "not configured"}`);
404
- console.log(` Cursor ${cursor ? "configured" : cursorDetected() ? "detected, not configured" : "not detected"}`);
405
- console.log(` Codex ${codex ? "configured (approve once with /hooks)" : codexDetected() ? "detected, not configured" : "not detected"}`);
827
+ const ignored = untrustedProjectPolicy(process.cwd());
828
+ if (ignored)
829
+ console.log(` project policy ${ignored} ignored — not trusted (run \`${cliCommand("trust")}\` to use it)`);
830
+ console.log(` Claude Code ${harnessScopeLabel(claude) || "not configured"}`);
831
+ console.log(` Cursor ${harnessScopeLabel(cursor) || (cursorDetected() ? "detected, not configured" : "not detected")}`);
832
+ console.log(` Codex ${codex.project || codex.user ? `${harnessScopeLabel(codex)} — approve once with /hooks` : codexDetected() ? "detected, not configured" : "not detected"}`);
406
833
  console.log(` cloud workspace ${connected ? "connected" : "not connected (local only)"}`);
407
- console.log(` local receipts ${existsSync(dbPath) ? dbPath : "none yet"}`);
834
+ console.log(` local receipts ${existsSync(dbPath) ? `${dbPath} (${describeStore(dbPath)})` : "none yet"}`);
835
+ for (const [name, scopes] of [["Claude Code", claude], ["Cursor", cursor], ["Codex", codex]]) {
836
+ for (const file of [scopes.project, scopes.user])
837
+ if (file)
838
+ console.log(` ${name}: ${file}`);
839
+ }
408
840
  }
409
841
  async function runDoctor() {
410
842
  const problems = [];
@@ -421,10 +853,41 @@ async function runDoctor() {
421
853
  console.log(` active config ${active} ${hasPolicy ? "ok" : "no policy (run `scopebond install` or `init`)"}`);
422
854
  if (!hasPolicy)
423
855
  problems.push("no policy found in the active config dir");
424
- const codex = isHarnessConfigured(userHarnessFile("codex"));
425
- console.log(` Codex hook ${codex ? "configured" : codexDetected() ? "not configured — run `scopebond install --codex`" : "not detected"}`);
426
- if (codex)
427
- console.log(` Codex approval run /hooks in Codex and approve Scopebond once`);
856
+ const ignored = untrustedProjectPolicy(process.cwd());
857
+ if (ignored)
858
+ console.log(` project policy ${ignored} IGNORED — not trusted (never trusted, or edited since). Review it, then \`${cliCommand("trust")}\``);
859
+ // Every harness, in both scopes, plus a check that each configured command can
860
+ // actually start. A pinned path that has gone missing is the one failure mode of
861
+ // the fast absolute-path install, so doctor is where it must surface.
862
+ let anyHarness = false;
863
+ for (const harness of ["claude", "cursor", "codex"]) {
864
+ const scopes = harnessScopes(harness, process.cwd());
865
+ const label = harnessScopeLabel(scopes);
866
+ const name = harnessName(harness);
867
+ if (!label) {
868
+ const detected = harness === "claude" || (harness === "cursor" ? cursorDetected() : codexDetected());
869
+ console.log(` ${name.padEnd(15)} ${detected ? `not configured — run \`${cliCommand(`init${harness === "claude" ? "" : ` --${harness}`}`)}\`` : "not detected"}`);
870
+ continue;
871
+ }
872
+ anyHarness = true;
873
+ console.log(` ${name.padEnd(15)} ${label}`);
874
+ for (const file of [scopes.project, scopes.user]) {
875
+ if (!file)
876
+ continue;
877
+ for (const command of configuredHookCommands(file)) {
878
+ const ok = hookCommandResolves(command);
879
+ console.log(` ${ok ? "ok " : "BAD "} ${file}`);
880
+ if (!ok) {
881
+ console.log(` command cannot start: ${command}`);
882
+ problems.push(`${name} hook command no longer resolves in ${file} — run \`${cliCommand("init")}\` to repair it`);
883
+ }
884
+ }
885
+ }
886
+ if (harness === "codex")
887
+ console.log(` run /hooks in Codex and approve Scopebond once`);
888
+ }
889
+ if (!anyHarness)
890
+ problems.push(`no coding agent is configured — run \`${cliCommand("init")}\` in your project root`);
428
891
  const connection = loadConnection(active);
429
892
  if (!connection) {
430
893
  console.log(` cloud not connected (local only) — receipts stay on this machine`);
@@ -444,21 +907,45 @@ async function runDoctor() {
444
907
  process.exitCode = problems.length ? 1 : 0;
445
908
  }
446
909
  function runUninstall(args) {
910
+ requireInteractive("uninstall", args);
447
911
  let removed = 0;
912
+ // Both scopes. Checking only the user config meant that after a per-project `init` —
913
+ // the install the site actually tells people to run — `uninstall` reported "no
914
+ // user-level harness config found" and left the project hook in place.
448
915
  for (const h of ["claude", "cursor", "codex"]) {
449
- if (removeHarnessConfig(userHarnessFile(h))) {
450
- console.log(`✓ removed the Scopebond hook from ${userHarnessFile(h)}`);
451
- removed++;
916
+ for (const file of [projectHarnessFile(h, process.cwd()), userHarnessFile(h)]) {
917
+ if (removeHarnessConfig(file)) {
918
+ console.log(`✓ removed the Scopebond hook from ${file}`);
919
+ removed++;
920
+ }
452
921
  }
453
922
  }
454
923
  if (removed === 0)
455
- console.log("no user-level harness config found.");
924
+ console.log("no Scopebond hook found in this project or your user config.");
456
925
  if (args.includes("--purge")) {
457
926
  purgeHome();
458
927
  console.log(`✓ purged ${userHome()} (keys, policy, receipts)`);
459
928
  }
460
929
  else
461
930
  console.log(`Kept ${userHome()} (keys, policy, receipts). Use --purge to remove it too.`);
931
+ const projectDir = resolveConfigDir(process.cwd());
932
+ if (existsSync(join(projectDir, "policy.json"))) {
933
+ console.log(`Kept ${projectDir} (this project's keys, policy and receipts) — delete it by hand if you want it gone.`);
934
+ }
935
+ }
936
+ /** `trust` — let this project's .scopebond policy govern here instead of the user
937
+ * home, pinned to its current contents. Run by the user, not the agent: the file it
938
+ * writes lives in the user home, which the starter policy write-protects. */
939
+ function runTrust(args) {
940
+ requireInteractive("trust", args);
941
+ const dir = join(process.cwd(), ".scopebond");
942
+ if (!existsSync(join(dir, "policy.json"))) {
943
+ console.error(`no project policy at ${join(dir, "policy.json")}`);
944
+ process.exit(1);
945
+ }
946
+ const digest = trustProjectPolicy(dir);
947
+ console.log(`✓ trusted ${join(dir, "policy.json")} (sha256 ${digest.slice(0, 12)}…)`);
948
+ console.log("It governs agents in this project until it changes; after any edit, review it and run trust again.");
462
949
  }
463
950
  function runLogin() {
464
951
  console.log("Device-code login is not available yet.");
@@ -466,6 +953,104 @@ function runLogin() {
466
953
  console.log(` ${cliCommand("connect <workspace-url> <enrollment>")}`);
467
954
  process.exit(0);
468
955
  }
956
+ /** What each command does, its arguments, and one example. The whole help used to be a
957
+ * single usage line listing 15 command names, which told a reader nothing about what any
958
+ * of them did or what arguments they take. */
959
+ const COMMANDS = [
960
+ { name: "init", args: "[--cursor|--codex] [--no-install] [--npx] [--force] [--yes]",
961
+ summary: "set this project up: keys, a starter policy, and your agent wired to the hook",
962
+ detail: [
963
+ "Writes .scopebond/ (machine key, countersigning key, starter policy, .gitignore) and",
964
+ "configures .claude/settings.json, .cursor/hooks.json or .codex/hooks.json.",
965
+ "Pins a durable copy of this package so the hook starts fast; --npx keeps the portable",
966
+ "command instead. --no-install prints the config snippet rather than writing it.",
967
+ "Needs a terminal, or --yes in a script, because it changes what governs your agent.",
968
+ ] },
969
+ { name: "install", args: "[--claude] [--cursor] [--codex] [--dry-run] [--force] [--yes]",
970
+ summary: "set up once for this user, so every project you open is governed",
971
+ detail: [
972
+ "Scaffolds ~/.scopebond and registers the hook in your user-level agent config.",
973
+ "--dry-run prints exactly which files it would touch and changes nothing. Each config",
974
+ "is copied to <file>.scopebond-backup before its first modification.",
975
+ ] },
976
+ { name: "rules", args: "[show|allow|block|protect|unprotect|protect-branch|unprotect-branch|apply] [value]",
977
+ summary: "read and change the limits in plain terms",
978
+ detail: [
979
+ "With no arguments, prints what is blocked in plain English — no regular expressions.",
980
+ "The editable lists are .scopebond/rules.json; policy.json is compiled from them, so",
981
+ "you never hand-write a lookahead.",
982
+ " rules allow dd stop blocking a program",
983
+ " rules protect infra/ never write there",
984
+ " rules protect-branch production",
985
+ " rules apply recompile after editing rules.json by hand",
986
+ ] },
987
+ { name: "status", summary: "what is configured, where, and how big the local log is" },
988
+ { name: "doctor", summary: "check the setup and whether each configured hook command can start",
989
+ detail: ["Exits non-zero when something is wrong, so it works in a script."] },
990
+ { name: "log", args: "[-n N] [--deny] [--since 7d]",
991
+ summary: "the recent decisions",
992
+ detail: [`--deny shows only blocked actions; --since takes 7d, 24h, 30m or a date.`, `e.g. ${cliCommand("log --deny --since 7d")}`] },
993
+ { name: "verify", summary: "check every local receipt offline against the countersigning key",
994
+ detail: ["No network, no account. Exits non-zero if any receipt fails."] },
995
+ { name: "test", args: '"<shell command>"',
996
+ summary: "show the decision for a command without running or recording it",
997
+ detail: [`e.g. ${cliCommand('test "rm -rf /"')}`] },
998
+ { name: "prune", args: "[--before 90d] [--yes]",
999
+ summary: "report the local store's size, or bound it",
1000
+ detail: [
1001
+ "With no --before it only reports. With one, it archives the receipts it will remove",
1002
+ "to a JSONL file beside the database, then removes them. Refuses once the log has been",
1003
+ "anchored, because a receipt's position is its anchor leaf index.",
1004
+ ] },
1005
+ { name: "connect", args: "<workspace-url> <enrollment> [--claude|--cursor|--codex]",
1006
+ summary: "send receipts to a Scopebond Cloud workspace as well as keeping them locally" },
1007
+ { name: "flush", summary: "deliver any receipts still queued for the workspace now" },
1008
+ { name: "trust", args: "[--yes]", summary: "let this project's .scopebond policy govern here (pinned by hash)" },
1009
+ { name: "uninstall", args: "[--purge] [--yes]", summary: "remove the hook from your agent config; --purge also deletes the home" },
1010
+ { name: "claude", summary: "(internal) decide one Claude Code PreToolUse call, JSON on stdin" },
1011
+ { name: "cursor", summary: "(internal) decide one Cursor hook event, JSON on stdin" },
1012
+ { name: "codex", summary: "(internal) decide one Codex PreToolUse call, JSON on stdin" },
1013
+ ];
1014
+ function printHelp(topic, toStderr = false) {
1015
+ const out = toStderr ? console.error : console.log;
1016
+ const match = topic ? COMMANDS.find((c) => c.name === topic.replace(/^--?/, "")) : undefined;
1017
+ if (match) {
1018
+ out(`scopebond-hook ${match.name}${match.args ? ` ${match.args}` : ""}`);
1019
+ out("");
1020
+ out(` ${match.summary}`);
1021
+ if (match.detail) {
1022
+ out("");
1023
+ for (const line of match.detail)
1024
+ out(` ${line}`);
1025
+ }
1026
+ return;
1027
+ }
1028
+ if (topic) {
1029
+ out(`no such command: ${topic}`);
1030
+ out("");
1031
+ }
1032
+ out(`scopebond-hook — govern a coding agent's tool calls against policy, before they run.`);
1033
+ out("");
1034
+ out(`Usage: scopebond-hook <command> [options]`);
1035
+ out("");
1036
+ out(` ${cliCommand("init")} set up this project`);
1037
+ out(` ${cliCommand('test "rm -rf /"')} see a decision without running it`);
1038
+ out(` ${cliCommand("log --deny")} what got blocked`);
1039
+ out(` ${cliCommand("rules")} what is blocked, in plain English`);
1040
+ out("");
1041
+ out("Commands:");
1042
+ const width = Math.max(...COMMANDS.map((c) => c.name.length));
1043
+ for (const command of COMMANDS) {
1044
+ if (command.summary.startsWith("(internal)"))
1045
+ continue;
1046
+ out(` ${command.name.padEnd(width)} ${command.summary}`);
1047
+ }
1048
+ out("");
1049
+ out(` ${"help".padEnd(width)} \`help <command>\` for that command's arguments and examples`);
1050
+ out("");
1051
+ out(`Receipts and keys stay in .scopebond/ in this project. Nothing leaves your machine`);
1052
+ out(`unless you run \`connect\`. Docs: https://github.com/avouro-com/scopebond`);
1053
+ }
469
1054
  const [cmd, ...rest] = process.argv.slice(2);
470
1055
  if (cmd === "claude") {
471
1056
  await runClaude();
@@ -509,8 +1094,21 @@ else if (cmd === "uninstall") {
509
1094
  else if (cmd === "login") {
510
1095
  runLogin();
511
1096
  }
1097
+ else if (cmd === "trust") {
1098
+ runTrust(rest);
1099
+ }
1100
+ else if (cmd === "prune") {
1101
+ await runPrune(rest);
1102
+ }
1103
+ else if (cmd === "rules") {
1104
+ runRules(rest);
1105
+ }
1106
+ else if (cmd === "help" || cmd === "--help" || cmd === "-h" || cmd === undefined) {
1107
+ printHelp(rest[0]);
1108
+ }
512
1109
  else {
513
- console.error("usage: scopebond <claude|cursor|codex|init|install|connect|log|verify|test|flush|status|doctor|uninstall|login> [--cursor] [--claude] [--codex] [--force] [--strict] [--no-install] [--purge]");
1110
+ console.error(`unknown command: ${cmd}`);
1111
+ printHelp(undefined, true);
514
1112
  process.exit(1);
515
1113
  }
516
1114
  //# sourceMappingURL=cli.js.map