@scopebond/hook 0.6.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 (46) hide show
  1. package/README.md +87 -6
  2. package/dist/cli.d.ts +2 -1
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +612 -70
  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 +8 -2
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +4 -1
  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 +35 -20
  21. package/dist/init.js.map +1 -1
  22. package/dist/install.d.ts +45 -2
  23. package/dist/install.d.ts.map +1 -1
  24. package/dist/install.js +158 -9
  25. package/dist/install.js.map +1 -1
  26. package/dist/map.d.ts +6 -0
  27. package/dist/map.d.ts.map +1 -1
  28. package/dist/map.js +4 -1
  29. package/dist/map.js.map +1 -1
  30. package/dist/rules.d.ts +51 -0
  31. package/dist/rules.d.ts.map +1 -0
  32. package/dist/rules.js +216 -0
  33. package/dist/rules.js.map +1 -0
  34. package/dist/runtime-install.d.ts +25 -0
  35. package/dist/runtime-install.d.ts.map +1 -0
  36. package/dist/runtime-install.js +106 -0
  37. package/dist/runtime-install.js.map +1 -0
  38. package/dist/runtime.d.ts +13 -0
  39. package/dist/runtime.d.ts.map +1 -1
  40. package/dist/runtime.js +52 -7
  41. package/dist/runtime.js.map +1 -1
  42. package/dist/version.d.ts +5 -1
  43. package/dist/version.d.ts.map +1 -1
  44. package/dist/version.js +6 -2
  45. package/dist/version.js.map +1 -1
  46. package/package.json +3 -3
package/dist/cli.js CHANGED
@@ -2,21 +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] [--yes]
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
15
- // scopebond-hook trust [--yes] let this project's .scopebond policy govern here (pinned)
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.
16
8
  //
17
9
  // Config dir: $SCOPEBOND_HOOK_DIR, else ./.scopebond
18
10
  // Fail-closed: any error denies the action with a repair message.
19
- import { readFileSync, existsSync, mkdtempSync, rmSync } from "node:fs";
11
+ import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
20
12
  import { join } from "node:path";
21
13
  import { tmpdir } from "node:os";
22
14
  import { execFileSync } from "node:child_process";
@@ -25,8 +17,12 @@ import { openReceiptStore, loadOrCreateAttester } from "@scopebond/gateway/node"
25
17
  import { mapClaudeToolUse, mapCodexToolUse, mapCursorEvent, fillPushBranch } from "./map.js";
26
18
  import { createHookRuntime } from "./runtime.js";
27
19
  import { scaffold, harnessSnippet, installHarness } from "./init.js";
28
- import { userHome, userHarnessFile, resolveConfigDir, writeHarnessConfig, removeHarnessConfig, cursorDetected, codexDetected, absoluteHookCommand, isHarnessConfigured, purgeHome, trustProjectPolicy, untrustedProjectPolicy, } 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";
29
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";
30
26
  import { cliCommand, hookVersion } from "./version.js";
31
27
  import { fileURLToPath } from "node:url";
32
28
  /** The current git branch in `cwd` (best-effort). A bare `git push` pushes it, so
@@ -92,7 +88,9 @@ function denyClaude(reason) {
92
88
  process.stdout.write(JSON.stringify({
93
89
  hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason },
94
90
  }) + "\n");
95
- 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`);
96
94
  process.exit(2);
97
95
  }
98
96
  /** Codex accepts the structured deny response on a successful hook exit. Keeping
@@ -108,6 +106,18 @@ const harnessName = (harness) => harness === "claude" ? "Claude Code" : harness
108
106
  const harnessFileName = (harness) => harness === "claude" ? ".claude/settings.json" : harness === "cursor" ? ".cursor/hooks.json" : ".codex/hooks.json";
109
107
  const selectedHarness = (args) => args.includes("--codex") ? "codex" : args.includes("--cursor") ? "cursor" : "claude";
110
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");
111
121
  async function runPreToolUse(mapper, deny = denyClaude) {
112
122
  let input;
113
123
  try {
@@ -116,11 +126,16 @@ async function runPreToolUse(mapper, deny = denyClaude) {
116
126
  catch {
117
127
  deny("hook received invalid JSON on stdin");
118
128
  }
129
+ let runtime;
119
130
  try {
120
131
  const cwd = input?.cwd ? String(input.cwd) : process.cwd();
121
- const runtime = createHookRuntime(runtimePaths(resolveConfigDir(cwd)));
132
+ runtime = createHookRuntime(runtimePaths(resolveConfigDir(cwd)));
122
133
  const decision = await runtime.evaluate(fillPushBranch(mapper(input), currentBranch(cwd)));
123
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;
124
139
  if (decision.decision === "deny")
125
140
  deny(decision.reason);
126
141
  // Stay silent on allow/not_evaluated so the coding agent's normal permission
@@ -128,6 +143,10 @@ async function runPreToolUse(mapper, deny = denyClaude) {
128
143
  process.exit(0);
129
144
  }
130
145
  catch (error) {
146
+ try {
147
+ runtime?.close();
148
+ }
149
+ catch { /* already failing; the deny below is what matters */ }
131
150
  deny(`Scopebond hook failed closed: ${error.message}. Repair: run \`${cliCommand("init")}\`.`);
132
151
  }
133
152
  }
@@ -137,29 +156,68 @@ async function runClaude() {
137
156
  async function runCodex() {
138
157
  await runPreToolUse(mapCodexToolUse, denyCodex);
139
158
  }
159
+ function denyCursor(reason) {
160
+ process.stdout.write(JSON.stringify({ permission: "deny", agentMessage: reason }) + "\n");
161
+ process.exit(0);
162
+ }
140
163
  async function runCursor() {
141
- 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;
142
169
  try {
143
- 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");
144
177
  }
145
- catch { /* fall through to fail-closed deny below */ }
146
- 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] ?? "");
147
180
  let permission = "deny";
148
181
  let message = "Scopebond hook failed closed";
182
+ let postHoc = false;
183
+ let runtime;
149
184
  try {
150
185
  const cwd = input?.cwd ? String(input.cwd) : process.cwd();
151
- const runtime = createHookRuntime(runtimePaths(resolveConfigDir(cwd)));
152
- 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);
153
189
  await runtime.flush();
154
- // Only an out-of-policy action is denied outright; an allowed or unevaluated
155
- // action defers to Cursor's own prompt ("ask"), never a silent auto-allow.
156
- 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";
157
204
  message = decision.reason;
158
205
  }
159
206
  catch (error) {
160
207
  permission = "deny";
161
208
  message = `Scopebond hook failed closed: ${error.message}. Repair: run \`${cliCommand("init")}\`.`;
162
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
+ }
163
221
  process.stdout.write(JSON.stringify({ permission, agentMessage: message }) + "\n");
164
222
  process.exit(0);
165
223
  }
@@ -170,31 +228,49 @@ async function runCursor() {
170
228
  function requireInteractive(command, args) {
171
229
  if (process.stdin.isTTY || args.includes("--yes"))
172
230
  return;
173
- console.error(`scopebond ${command} changes what governs your coding agent, so it must be run from an interactive terminal.`);
174
- console.error(`In a script or CI, pass --yes: ${cliCommand(`${command} --yes`)}`);
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.`);
175
238
  process.exit(1);
176
239
  }
177
240
  function runInit(args) {
178
241
  requireInteractive("init", args);
179
242
  const harness = selectedHarness(args);
180
243
  const dir = configDir();
181
- const { agentKid, policyPath } = scaffold(dir, { force: args.includes("--force") });
244
+ const { agentKid, policyPath, rulesPath: rulesFile } = scaffold(dir, { force: args.includes("--force") });
182
245
  console.log(`Scopebond hook enrolled in ${dir}`);
183
246
  console.log(` machine key ${agentKid}`);
184
- 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)`);
185
249
  // With a user-level install present, a project policy governs only once trusted.
186
250
  // Running init here is that decision, so pin this policy now.
187
251
  if (!process.env.SCOPEBOND_HOOK_DIR && existsSync(join(userHome(), "policy.json"))) {
188
252
  trustProjectPolicy(dir);
189
253
  console.log(` trusted overrides ${userHome()} here; after editing it, run \`${cliCommand("trust")}\``);
190
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`}`);
191
267
  console.log("");
192
268
  // Configure the agent automatically by default (idempotent), so there is no
193
269
  // hand-editing step; --no-install prints the snippet instead.
194
270
  if (!args.includes("--no-install")) {
195
271
  let file;
196
272
  try {
197
- file = installHarness(harness);
273
+ file = installHarness(harness, process.cwd(), command);
198
274
  }
199
275
  catch (error) {
200
276
  console.error(error.message);
@@ -203,10 +279,14 @@ function runInit(args) {
203
279
  console.log(`✓ ${harnessName(harness)} configured in ${file}`);
204
280
  if (harness === "codex")
205
281
  console.log(`\nOne last step: ${codexTrustStep}`);
282
+ if (harness === "cursor")
283
+ console.log(`\n${cursorCoverageNote}`);
206
284
  }
207
285
  else {
208
286
  console.log(`Add this to your ${harnessFileName(harness)}:`);
209
- console.log(harnessSnippet(harness));
287
+ console.log(harnessSnippet(harness, command));
288
+ if (harness === "cursor")
289
+ console.log(`\n${cursorCoverageNote}`);
210
290
  }
211
291
  console.log("");
212
292
  // Print the runnable `npx` form: after `npx @scopebond/hook init` there is no
@@ -225,15 +305,14 @@ function decisionOf(payload) {
225
305
  return "allow";
226
306
  }
227
307
  function describeIntent(payload) {
228
- const intent = (payload.intent ?? {});
229
- const p = (intent.params ?? {});
230
- const bits = intent.action_type === "shell.exec" ? String(p.program ?? "")
231
- : intent.action_type === "git.push" ? `${p.remote ?? ""} ${p.ref ?? ""}`.trim()
232
- : intent.action_type === "file.write" || intent.action_type === "file.read" ? String(p.path ?? "")
233
- : intent.action_type === "mcp.tool.call" ? `${p.server ?? ""}/${p.tool ?? ""}`
234
- : intent.action_type === "net.fetch" ? String(p.host ?? "") : "";
235
- return `${String(intent.action_type ?? "?")}${bits ? ` ${bits}` : ""}`;
308
+ return describeAction(payload.intent);
236
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. */
237
316
  async function runLog(args) {
238
317
  const dir = resolveConfigDir(process.cwd());
239
318
  const dbPath = join(dir, "receipts.db");
@@ -243,18 +322,259 @@ async function runLog(args) {
243
322
  }
244
323
  const nIdx = args.indexOf("-n");
245
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
+ }
246
332
  const { store } = openReceiptStore({ db: dbPath });
247
- const all = await Promise.resolve(store.list());
248
- const recent = all.slice(-n);
249
- if (recent.length === 0) {
250
- 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.`);
251
521
  process.exit(0);
252
522
  }
253
- for (const r of recent) {
254
- const p = r.payload;
255
- 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);
565
+ }
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;
256
575
  }
257
- console.log(`\n${recent.length} of ${all.length} receipt(s). Verify them: ${cliCommand("verify")}`);
576
+ const at = Date.parse(value);
577
+ return Number.isFinite(at) ? at : null;
258
578
  }
259
579
  async function runVerify() {
260
580
  const dir = resolveConfigDir(process.cwd());
@@ -266,10 +586,18 @@ async function runVerify() {
266
586
  }
267
587
  const { attester } = loadOrCreateAttester({ file: attesterPath });
268
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.
269
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;
270
598
  let ok = 0;
271
599
  const bad = [];
272
- for (const r of all) {
600
+ for (const [index, r] of all.entries()) {
273
601
  const result = verifyReceipt(r, attester.publicKeyPem);
274
602
  if (result.valid)
275
603
  ok += 1;
@@ -277,7 +605,11 @@ async function runVerify() {
277
605
  const failed = Object.entries(result).filter(([k, v]) => k.endsWith("_valid") && v === false).map(([k]) => k);
278
606
  bad.push(`${String(r.payload.timestamp ?? "")}: ${failed.join(", ") || "invalid"}`);
279
607
  }
608
+ if (progress && (index + 1) % 1000 === 0)
609
+ process.stderr.write(`\rverifying ${index + 1}/${all.length}…`);
280
610
  }
611
+ if (progress)
612
+ process.stderr.write("\r".padEnd(40) + "\r");
281
613
  console.log(`${ok}/${all.length} receipt(s) verify offline against ${attesterPath}.`);
282
614
  if (bad.length) {
283
615
  for (const b of bad)
@@ -310,10 +642,17 @@ async function runTest(args) {
310
642
  console.log(`command: ${command}`);
311
643
  for (const m of mapped) {
312
644
  const d = await runtime.evaluateOne(m);
313
- 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}`);
314
651
  }
315
652
  const overall = await runtime.evaluate(mapped);
316
- 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}`);
317
656
  process.exit(overall.decision === "deny" ? 2 : 0);
318
657
  }
319
658
  finally {
@@ -389,20 +728,44 @@ function cliPath() {
389
728
  * so every project a developer opens is governed without a per-repo `init`. */
390
729
  function runInstall(args) {
391
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
+ }
392
754
  const { agentKid, policyPath } = scaffold(dir, { force: args.includes("--force") });
393
755
  console.log(`Scopebond installed for this user in ${dir}`);
394
756
  console.log(` machine key ${agentKid}`);
395
757
  console.log(` policy ${policyPath} (starter — edit the limits)`);
396
758
  console.log("");
397
- const harnesses = args.includes("--codex") ? ["codex"]
398
- : args.includes("--cursor") ? ["cursor"]
399
- : args.includes("--claude") ? ["claude"]
400
- : ["claude", ...(cursorDetected() ? ["cursor"] : []), ...(codexDetected() ? ["codex"] : [])];
759
+ const harnesses = harnessesFor();
401
760
  if (!args.includes("--no-install")) {
402
761
  for (const h of harnesses) {
403
762
  try {
404
- const file = writeHarnessConfig(userHarnessFile(h), h, absoluteHookCommand(cliPath(), h));
763
+ const target = userHarnessFile(h);
764
+ const backup = existsSync(target) ? `${target}.scopebond-backup` : null;
765
+ const file = writeHarnessConfig(target, h, absoluteHookCommand(cliPath(), h));
405
766
  console.log(`✓ ${harnessName(h)} configured in ${file}`);
767
+ if (backup && existsSync(backup))
768
+ console.log(` original kept at ${backup}`);
406
769
  }
407
770
  catch (error) {
408
771
  console.error(`✗ ${harnessName(h)}: ${error.message}`);
@@ -421,12 +784,41 @@ function runInstall(args) {
421
784
  console.log(`Check it: ${cliCommand("doctor")} · see decisions: ${cliCommand("log")}`);
422
785
  console.log(`To send receipts to a workspace: ${cliCommand("connect <workspace-url> <enrollment>")}`);
423
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
+ }
424
814
  function runStatus() {
425
815
  const home = userHome();
426
816
  const installed = existsSync(join(home, "policy.json"));
427
- const claude = isHarnessConfigured(userHarnessFile("claude"));
428
- const cursor = isHarnessConfigured(userHarnessFile("cursor"));
429
- 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());
430
822
  const connected = !!loadConnection(resolveConfigDir(process.cwd()));
431
823
  const dbPath = join(resolveConfigDir(process.cwd()), "receipts.db");
432
824
  console.log(`Scopebond hook ${hookVersion()}`);
@@ -435,11 +827,16 @@ function runStatus() {
435
827
  const ignored = untrustedProjectPolicy(process.cwd());
436
828
  if (ignored)
437
829
  console.log(` project policy ${ignored} ignored — not trusted (run \`${cliCommand("trust")}\` to use it)`);
438
- console.log(` Claude Code ${claude ? "configured" : "not configured"}`);
439
- console.log(` Cursor ${cursor ? "configured" : cursorDetected() ? "detected, not configured" : "not detected"}`);
440
- console.log(` Codex ${codex ? "configured (approve once with /hooks)" : codexDetected() ? "detected, not configured" : "not detected"}`);
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"}`);
441
833
  console.log(` cloud workspace ${connected ? "connected" : "not connected (local only)"}`);
442
- 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
+ }
443
840
  }
444
841
  async function runDoctor() {
445
842
  const problems = [];
@@ -459,10 +856,38 @@ async function runDoctor() {
459
856
  const ignored = untrustedProjectPolicy(process.cwd());
460
857
  if (ignored)
461
858
  console.log(` project policy ${ignored} IGNORED — not trusted (never trusted, or edited since). Review it, then \`${cliCommand("trust")}\``);
462
- const codex = isHarnessConfigured(userHarnessFile("codex"));
463
- console.log(` Codex hook ${codex ? "configured" : codexDetected() ? "not configured — run `scopebond install --codex`" : "not detected"}`);
464
- if (codex)
465
- console.log(` Codex approval run /hooks in Codex and approve Scopebond once`);
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`);
466
891
  const connection = loadConnection(active);
467
892
  if (!connection) {
468
893
  console.log(` cloud not connected (local only) — receipts stay on this machine`);
@@ -484,20 +909,29 @@ async function runDoctor() {
484
909
  function runUninstall(args) {
485
910
  requireInteractive("uninstall", args);
486
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.
487
915
  for (const h of ["claude", "cursor", "codex"]) {
488
- if (removeHarnessConfig(userHarnessFile(h))) {
489
- console.log(`✓ removed the Scopebond hook from ${userHarnessFile(h)}`);
490
- 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
+ }
491
921
  }
492
922
  }
493
923
  if (removed === 0)
494
- console.log("no user-level harness config found.");
924
+ console.log("no Scopebond hook found in this project or your user config.");
495
925
  if (args.includes("--purge")) {
496
926
  purgeHome();
497
927
  console.log(`✓ purged ${userHome()} (keys, policy, receipts)`);
498
928
  }
499
929
  else
500
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
+ }
501
935
  }
502
936
  /** `trust` — let this project's .scopebond policy govern here instead of the user
503
937
  * home, pinned to its current contents. Run by the user, not the agent: the file it
@@ -519,6 +953,104 @@ function runLogin() {
519
953
  console.log(` ${cliCommand("connect <workspace-url> <enrollment>")}`);
520
954
  process.exit(0);
521
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
+ }
522
1054
  const [cmd, ...rest] = process.argv.slice(2);
523
1055
  if (cmd === "claude") {
524
1056
  await runClaude();
@@ -565,8 +1097,18 @@ else if (cmd === "login") {
565
1097
  else if (cmd === "trust") {
566
1098
  runTrust(rest);
567
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
+ }
568
1109
  else {
569
- console.error("usage: scopebond <claude|cursor|codex|init|install|connect|log|verify|test|flush|status|doctor|uninstall|login|trust> [--cursor] [--claude] [--codex] [--force] [--strict] [--no-install] [--purge] [--yes]");
1110
+ console.error(`unknown command: ${cmd}`);
1111
+ printHelp(undefined, true);
570
1112
  process.exit(1);
571
1113
  }
572
1114
  //# sourceMappingURL=cli.js.map