@scopebond/hook 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +123 -13
  2. package/dist/cli.d.ts +2 -1
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +825 -92
  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 +11 -4
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +6 -3
  17. package/dist/index.js.map +1 -1
  18. package/dist/init.d.ts +41 -4
  19. package/dist/init.d.ts.map +1 -1
  20. package/dist/init.js +104 -21
  21. package/dist/init.js.map +1 -1
  22. package/dist/install.d.ts +70 -2
  23. package/dist/install.d.ts.map +1 -1
  24. package/dist/install.js +262 -10
  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/minimize.d.ts +11 -3
  31. package/dist/minimize.d.ts.map +1 -1
  32. package/dist/minimize.js +35 -6
  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 +13 -0
  43. package/dist/runtime.d.ts.map +1 -1
  44. package/dist/runtime.js +52 -7
  45. package/dist/runtime.js.map +1 -1
  46. package/dist/version.d.ts +5 -1
  47. package/dist/version.d.ts.map +1 -1
  48. package/dist/version.js +6 -2
  49. package/dist/version.js.map +1 -1
  50. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -2,32 +2,44 @@
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
+ // `node:sqlite` (the receipt store) is still flagged experimental on Node 22, and Node
12
+ // prints a warning on stderr the first time it loads — on every `verify`, and into the
13
+ // agent's transcript on every hook call. It is a notice about Node, not about the user's
14
+ // setup, so it is dropped; every other warning still prints through Node's own handler.
15
+ // (The store is required lazily, after this module has run, so the filter is in place.)
16
+ {
17
+ const nodeWarningHandlers = process.listeners("warning");
18
+ process.removeAllListeners("warning");
19
+ process.on("warning", (warning) => {
20
+ if (warning.name === "ExperimentalWarning" && /sqlite/i.test(warning.message))
21
+ return;
22
+ for (const handler of nodeWarningHandlers)
23
+ handler.call(process, warning);
24
+ });
25
+ }
26
+ import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
20
27
  import { join } from "node:path";
21
- import { tmpdir } from "node:os";
28
+ import { hostname, tmpdir } from "node:os";
22
29
  import { execFileSync } from "node:child_process";
23
30
  import { verifyReceipt } from "@scopebond/gateway";
24
31
  import { openReceiptStore, loadOrCreateAttester } from "@scopebond/gateway/node";
25
32
  import { mapClaudeToolUse, mapCodexToolUse, mapCursorEvent, fillPushBranch } from "./map.js";
26
33
  import { createHookRuntime } from "./runtime.js";
27
- import { scaffold, harnessSnippet, installHarness } from "./init.js";
28
- import { userHome, userHarnessFile, resolveConfigDir, writeHarnessConfig, removeHarnessConfig, cursorDetected, codexDetected, absoluteHookCommand, isHarnessConfigured, purgeHome, trustProjectPolicy, untrustedProjectPolicy, } from "./install.js";
34
+ import { useDigestKey, loadOrCreateDigestKey } from "./minimize.js";
35
+ import { scaffold, harnessSnippet, placeHook } from "./init.js";
36
+ import { userHome, userHarnessFile, resolveConfigDir, writeHarnessConfig, removeHarnessConfig, cursorDetected, codexDetected, absoluteHookCommand, isHarnessConfigured, purgeHome, harnessScopes, harnessScopeLabel, configuredHookCommands, hookCommandResolves, projectHarnessFile, localHarnessFile, gitShareState, isMachineSpecificCommand, trustProjectPolicy, untrustedProjectPolicy, } from "./install.js";
29
37
  import { connectCloud, loadConnection } from "./cloud.js";
30
- import { cliCommand, hookVersion } from "./version.js";
38
+ import { compile, defaultRules, describeRules, loadRules, saveRules, rulesPath, pathRuleFor } from "./rules.js";
39
+ import { createSigner } from "@scopebond/sdk";
40
+ import { describeAction } from "./explain.js";
41
+ import { ensureDurableRuntime, pinnedCliPath, isEphemeralPath } from "./runtime-install.js";
42
+ import { cliCommand, hookCommand, hookVersion } from "./version.js";
31
43
  import { fileURLToPath } from "node:url";
32
44
  /** The current git branch in `cwd` (best-effort). A bare `git push` pushes it, so
33
45
  * the runtime fills it in before evaluating; on failure the ref stays absent and
@@ -92,7 +104,9 @@ function denyClaude(reason) {
92
104
  process.stdout.write(JSON.stringify({
93
105
  hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason },
94
106
  }) + "\n");
95
- process.stderr.write(`Scopebond: ${reason}\n`);
107
+ // A composed explanation already names Scopebond in its first line; only the
108
+ // bare internal messages need the prefix.
109
+ process.stderr.write(`${reason.startsWith("Scopebond") ? reason : `Scopebond: ${reason}`}\n`);
96
110
  process.exit(2);
97
111
  }
98
112
  /** Codex accepts the structured deny response on a successful hook exit. Keeping
@@ -108,6 +122,18 @@ const harnessName = (harness) => harness === "claude" ? "Claude Code" : harness
108
122
  const harnessFileName = (harness) => harness === "claude" ? ".claude/settings.json" : harness === "cursor" ? ".cursor/hooks.json" : ".codex/hooks.json";
109
123
  const selectedHarness = (args) => args.includes("--codex") ? "codex" : args.includes("--cursor") ? "cursor" : "claude";
110
124
  const codexTrustStep = "Open Codex, run `/hooks`, review Scopebond, and choose Trust. Then start a new task.";
125
+ /** What Cursor can and cannot stop. Cursor has before-hooks for shell commands, MCP
126
+ * calls and file reads, but reports file *edits* only after they are written, so an
127
+ * out-of-policy edit is recorded and flagged rather than prevented. Said at install
128
+ * time, because someone choosing a guardrail needs to know its edges up front. */
129
+ const cursorCoverageNote = [
130
+ "What this covers in Cursor:",
131
+ " prevented shell commands, MCP tool calls, file reads — checked before they run",
132
+ " recorded file edits — Cursor reports an edit only after writing it, so an",
133
+ " out-of-policy edit is signed and flagged, not blocked",
134
+ "For edits that must be blocked before they land, use the GitHub Action as a required",
135
+ "check on pull requests.",
136
+ ].join("\n");
111
137
  async function runPreToolUse(mapper, deny = denyClaude) {
112
138
  let input;
113
139
  try {
@@ -116,11 +142,18 @@ async function runPreToolUse(mapper, deny = denyClaude) {
116
142
  catch {
117
143
  deny("hook received invalid JSON on stdin");
118
144
  }
145
+ let runtime;
119
146
  try {
120
147
  const cwd = input?.cwd ? String(input.cwd) : process.cwd();
121
- const runtime = createHookRuntime(runtimePaths(resolveConfigDir(cwd)));
148
+ const dir = resolveConfigDir(cwd);
149
+ runtime = createHookRuntime(runtimePaths(dir));
150
+ useDigestKey(loadOrCreateDigestKey(dir));
122
151
  const decision = await runtime.evaluate(fillPushBranch(mapper(input), currentBranch(cwd)));
123
152
  await runtime.flush();
153
+ // Close before deciding: the receipt is already committed, and leaving the handle
154
+ // open is what made the write-ahead log grow without bound.
155
+ runtime.close();
156
+ runtime = undefined;
124
157
  if (decision.decision === "deny")
125
158
  deny(decision.reason);
126
159
  // Stay silent on allow/not_evaluated so the coding agent's normal permission
@@ -128,6 +161,10 @@ async function runPreToolUse(mapper, deny = denyClaude) {
128
161
  process.exit(0);
129
162
  }
130
163
  catch (error) {
164
+ try {
165
+ runtime?.close();
166
+ }
167
+ catch { /* already failing; the deny below is what matters */ }
131
168
  deny(`Scopebond hook failed closed: ${error.message}. Repair: run \`${cliCommand("init")}\`.`);
132
169
  }
133
170
  }
@@ -137,29 +174,70 @@ async function runClaude() {
137
174
  async function runCodex() {
138
175
  await runPreToolUse(mapCodexToolUse, denyCodex);
139
176
  }
177
+ function denyCursor(reason) {
178
+ process.stdout.write(JSON.stringify({ permission: "deny", agentMessage: reason }) + "\n");
179
+ process.exit(0);
180
+ }
140
181
  async function runCursor() {
141
- let input = {};
182
+ // Unparseable input denies, like every other adapter. Previously this fell through
183
+ // to evaluation with an empty payload, which mapped to no known action and so
184
+ // answered "ask" — handing an unreadable request to a prompt the user would very
185
+ // likely accept. Deny-by-default on unparseable input is not optional.
186
+ let parsed;
142
187
  try {
143
- input = JSON.parse(readStdin());
188
+ parsed = JSON.parse(readStdin());
144
189
  }
145
- catch { /* fall through to fail-closed deny below */ }
146
- const event = String(input?.hook_event_name ?? input?.event ?? process.argv[3] ?? "");
190
+ catch {
191
+ denyCursor("Scopebond: hook received invalid JSON on stdin");
192
+ }
193
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
194
+ denyCursor("Scopebond: hook received a payload that is not a JSON object");
195
+ }
196
+ const input = parsed;
197
+ const event = String(input.hook_event_name ?? input.event ?? process.argv[3] ?? "");
147
198
  let permission = "deny";
148
199
  let message = "Scopebond hook failed closed";
200
+ let postHoc = false;
201
+ let runtime;
149
202
  try {
150
203
  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)));
204
+ const dir = resolveConfigDir(cwd);
205
+ runtime = createHookRuntime(runtimePaths(dir));
206
+ useDigestKey(loadOrCreateDigestKey(dir));
207
+ const mapped = fillPushBranch(mapCursorEvent(event, input), currentBranch(cwd));
208
+ const decision = await runtime.evaluate(mapped);
153
209
  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";
210
+ // An `afterFileEdit` violation is real and recorded, but the edit has already
211
+ // landed. Say so rather than letting "blocked" imply it was stopped.
212
+ postHoc = mapped.some((m) => m.postHoc);
213
+ // Three outcomes, three answers:
214
+ // deny — out of policy, blocked outright.
215
+ // allow — a rule was evaluated and permitted it. Returning "ask" here put a
216
+ // confirmation prompt in front of every ordinary command, which is not
217
+ // "your agent works as normal"; it also trained people to click through
218
+ // prompts, which makes the real denials easier to miss. An evaluated
219
+ // allow is a decision, not a silent auto-approval.
220
+ // ask — nothing was evaluated (no rule covers this action), so Cursor's own
221
+ // permission flow stays in charge. That is the fail-closed case and it
222
+ // keeps its prompt.
223
+ permission = decision.decision === "deny" ? "deny" : decision.decision === "allow" ? "allow" : "ask";
157
224
  message = decision.reason;
158
225
  }
159
226
  catch (error) {
160
227
  permission = "deny";
161
228
  message = `Scopebond hook failed closed: ${error.message}. Repair: run \`${cliCommand("init")}\`.`;
162
229
  }
230
+ finally {
231
+ // Release the SQLite handles on every path: an unclosed writer leaves its
232
+ // write-ahead log behind for the next tool call to extend.
233
+ try {
234
+ runtime?.close();
235
+ }
236
+ catch { /* the decision is already recorded */ }
237
+ }
238
+ if (postHoc && permission === "deny") {
239
+ message = `${message}\nCursor reports a file edit only after it is written, so this edit was not prevented. Review and revert it yourself.`;
240
+ }
163
241
  process.stdout.write(JSON.stringify({ permission, agentMessage: message }) + "\n");
164
242
  process.exit(0);
165
243
  }
@@ -170,43 +248,105 @@ async function runCursor() {
170
248
  function requireInteractive(command, args) {
171
249
  if (process.stdin.isTTY || args.includes("--yes"))
172
250
  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`)}`);
251
+ console.error(`scopebond ${command} changes what governs your coding agent, so it does not run unattended.`);
252
+ console.error(`There is no terminal on stdin here — which is also what it looks like when the agent itself`);
253
+ console.error(`tries to run this, so the refusal is deliberate rather than a bug.`);
254
+ console.error(``);
255
+ console.error(`If you are a person: run it in your own terminal, or confirm it now with --yes:`);
256
+ console.error(` ${cliCommand(`${command} --yes`)}`);
257
+ console.error(`Scripts, CI and container builds should always pass --yes.`);
175
258
  process.exit(1);
176
259
  }
177
260
  function runInit(args) {
178
- requireInteractive("init", args);
179
261
  const harness = selectedHarness(args);
180
262
  const dir = configDir();
181
- const { agentKid, policyPath } = scaffold(dir, { force: args.includes("--force") });
263
+ // A dry run changes nothing, so it needs no terminal and no --yes.
264
+ if (args.includes("--dry-run")) {
265
+ const shared = projectHarnessFile(harness, process.cwd());
266
+ const local = localHarnessFile(harness, process.cwd());
267
+ const personal = args.includes("--shared") || args.includes("--npx") ? null
268
+ : local ?? (gitShareState(shared) === "tracked" ? null : shared);
269
+ console.log(`Dry run — nothing is written.\n`);
270
+ console.log(`Would scaffold ${dir} (machine key, countersigning key, starter policy, .gitignore)`);
271
+ if (args.includes("--no-install")) {
272
+ console.log(`Would print the ${harnessFileName(harness)} snippet instead of writing it`);
273
+ }
274
+ else if (personal) {
275
+ console.log(`Would configure ${personal} (this machine only; kept out of git)`);
276
+ console.log(` adding hook "<node>" "${pinnedCliPath(hookVersion())}" ${harness}`);
277
+ console.log(` (or the path this copy runs from, when it is already installed durably)`);
278
+ if (local)
279
+ console.log(` and remove any machine-specific Scopebond entry from ${shared}`);
280
+ }
281
+ else {
282
+ console.log(`Would configure ${shared} (shared — safe to commit)`);
283
+ console.log(` adding hook ${hookCommand(harness)}`);
284
+ }
285
+ console.log(`\nNothing else in those files is changed. Run without --dry-run to apply.`);
286
+ process.exit(0);
287
+ }
288
+ requireInteractive("init", args);
289
+ const { agentKid, policyPath, rulesPath: rulesFile } = scaffold(dir, { force: args.includes("--force") });
182
290
  console.log(`Scopebond hook enrolled in ${dir}`);
183
291
  console.log(` machine key ${agentKid}`);
184
- console.log(` policy ${policyPath} (starter — edit the limits)`);
292
+ console.log(` rules ${rulesFile} (the readable list — edit this)`);
293
+ console.log(` policy ${policyPath} (compiled from the rules; don't hand-edit)`);
185
294
  // With a user-level install present, a project policy governs only once trusted.
186
295
  // Running init here is that decision, so pin this policy now.
187
296
  if (!process.env.SCOPEBOND_HOOK_DIR && existsSync(join(userHome(), "policy.json"))) {
188
297
  trustProjectPolicy(dir);
189
298
  console.log(` trusted overrides ${userHome()} here; after editing it, run \`${cliCommand("trust")}\``);
190
299
  }
191
- console.log("");
300
+ // The hook command runs once per tool call, so it must start fast. `npx` re-resolves
301
+ // a package that is already on disk and costs ~830 ms a call; the same CLI invoked
302
+ // directly costs ~110 ms. Pin a durable copy and use that, and fall back to `npx`
303
+ // (slow, but it always starts) when no durable copy can be made. `--npx` forces the
304
+ // portable form for anyone who wants it.
305
+ const shared = args.includes("--shared");
306
+ const pin = args.includes("--npx") || shared ? { cli: null, how: "unavailable" } : ensureDurableRuntime(cliPath(), hookVersion());
307
+ const command = pin.cli ? absoluteHookCommand(pin.cli, harness) : undefined;
192
308
  // Configure the agent automatically by default (idempotent), so there is no
193
309
  // hand-editing step; --no-install prints the snippet instead.
194
310
  if (!args.includes("--no-install")) {
195
- let file;
311
+ let placed;
196
312
  try {
197
- file = installHarness(harness);
313
+ placed = placeHook(harness, process.cwd(), command, { shared });
198
314
  }
199
315
  catch (error) {
200
316
  console.error(error.message);
201
317
  process.exit(1);
202
318
  }
203
- console.log(`✓ ${harnessName(harness)} configured in ${file}`);
319
+ // No per-action millisecond claim here: it varies by machine, and this project only
320
+ // states numbers it has measured. The measured comparison lives in the changelog.
321
+ console.log(` hook runtime ${placed.scope === "personal"
322
+ ? `${pin.cli}\n pinned — no npx resolution per action`
323
+ : `npx @scopebond/hook@${hookVersion()} — portable, but re-resolves on every action`}`);
324
+ console.log("");
325
+ console.log(`✓ ${harnessName(harness)} configured in ${placed.file}`);
326
+ console.log(placed.scope === "personal"
327
+ ? ` this machine only — kept out of git, so no teammate inherits a path that does not exist for them`
328
+ : ` shared — the portable command starts on any machine that clones this project`);
329
+ if (placed.repaired > 0)
330
+ console.log(` moved a machine-specific hook out of ${projectHarnessFile(harness, process.cwd())}; commit that change`);
331
+ if (placed.note)
332
+ console.log(` note: ${placed.note}`);
204
333
  if (harness === "codex")
205
334
  console.log(`\nOne last step: ${codexTrustStep}`);
335
+ if (harness === "cursor")
336
+ console.log(`\n${cursorCoverageNote}`);
206
337
  }
207
338
  else {
339
+ // The snippet is for a file the user will likely commit, so it carries the portable
340
+ // command; the pinned one is offered separately, for a file only this machine uses.
208
341
  console.log(`Add this to your ${harnessFileName(harness)}:`);
209
342
  console.log(harnessSnippet(harness));
343
+ const local = localHarnessFile(harness, process.cwd());
344
+ if (command && local) {
345
+ console.log(`\nFaster, for this machine only — use this command in ${local} instead (keep that file out of git):`);
346
+ console.log(` ${command}`);
347
+ }
348
+ if (harness === "cursor")
349
+ console.log(`\n${cursorCoverageNote}`);
210
350
  }
211
351
  console.log("");
212
352
  // Print the runnable `npx` form: after `npx @scopebond/hook init` there is no
@@ -225,15 +365,14 @@ function decisionOf(payload) {
225
365
  return "allow";
226
366
  }
227
367
  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}` : ""}`;
368
+ return describeAction(payload.intent);
236
369
  }
370
+ /** `log [-n N] [--deny] [--since <when>]` — the recent decisions.
371
+ *
372
+ * "What did my agents get blocked on this week" had no answer at the CLI: the only
373
+ * output was an unfiltered tail. `--deny` and `--since` make it answerable, and the
374
+ * tail is read as a tail (`ORDER BY id DESC LIMIT`) rather than by loading and parsing
375
+ * every receipt ever recorded and slicing the end off. */
237
376
  async function runLog(args) {
238
377
  const dir = resolveConfigDir(process.cwd());
239
378
  const dbPath = join(dir, "receipts.db");
@@ -243,18 +382,259 @@ async function runLog(args) {
243
382
  }
244
383
  const nIdx = args.indexOf("-n");
245
384
  const n = nIdx >= 0 ? Math.max(1, Number(args[nIdx + 1]) || 20) : 20;
385
+ const denyOnly = args.includes("--deny");
386
+ const sinceIdx = args.indexOf("--since");
387
+ const since = sinceIdx >= 0 ? parseSince(args[sinceIdx + 1]) : null;
388
+ if (sinceIdx >= 0 && since === null) {
389
+ console.error(`--since wants a duration (7d, 24h, 30m) or a date (2026-09-25); got ${args[sinceIdx + 1] ?? "nothing"}`);
390
+ process.exit(1);
391
+ }
246
392
  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.");
393
+ try {
394
+ const total = store.count ? await Promise.resolve(store.count()) : (await Promise.resolve(store.list())).length;
395
+ // A filter has to look past the last N to find N matches; without one, read only the
396
+ // tail. The scan is still bounded so a huge log cannot hang the command.
397
+ const budget = denyOnly || since ? Math.min(Math.max(n * 50, 1000), 20000) : n;
398
+ const scanned = store.recent
399
+ ? await Promise.resolve(store.recent(budget))
400
+ : (await Promise.resolve(store.list())).slice(-budget).reverse();
401
+ const matching = scanned.filter((r) => {
402
+ const p = r.payload;
403
+ if (denyOnly && decisionOf(p) !== "deny")
404
+ return false;
405
+ if (since) {
406
+ const at = Date.parse(String(p.timestamp ?? ""));
407
+ if (!Number.isFinite(at) || at < since)
408
+ return false;
409
+ }
410
+ return true;
411
+ });
412
+ const shown = matching.slice(0, n).reverse(); // oldest-first on screen, newest last
413
+ if (shown.length === 0) {
414
+ console.log(denyOnly || since ? "no receipts match that filter." : "no receipts yet.");
415
+ process.exit(0);
416
+ }
417
+ for (const r of shown) {
418
+ const p = r.payload;
419
+ console.log(`${String(p.timestamp ?? "")} ${decisionOf(p).padEnd(13)} ${describeIntent(p)}`);
420
+ }
421
+ const filters = [denyOnly ? "denied" : "", since ? `since ${new Date(since).toISOString()}` : ""].filter(Boolean).join(", ");
422
+ const scope = filters ? ` matching ${filters}` : "";
423
+ const capped = (denyOnly || since) && scanned.length >= budget && total > budget;
424
+ console.log(`\n${shown.length}${scope} of ${total} receipt(s)${capped ? ` — searched the most recent ${budget}` : ""}. Verify them: ${cliCommand("verify")}`);
425
+ }
426
+ finally {
427
+ try {
428
+ store.close?.();
429
+ }
430
+ catch { /* read-only */ }
431
+ }
432
+ }
433
+ /** `rules` — read and change the limits in plain terms.
434
+ *
435
+ * Without this, "edit the limits" meant hand-writing a ~700-character case-folded
436
+ * negative lookahead, which nobody does — so the starter policy was effectively the only
437
+ * policy. The lists live in `.scopebond/rules.json`; `policy.json` is compiled from them. */
438
+ function runRules(args) {
439
+ const dir = resolveConfigDir(process.cwd());
440
+ if (!existsSync(join(dir, "policy.json"))) {
441
+ console.error(`no policy here yet — run \`${cliCommand("init")}\` first.`);
442
+ process.exit(1);
443
+ }
444
+ const rules = loadRules(dir) ?? defaultRules();
445
+ const [verb, ...values] = args.filter((a) => !a.startsWith("--"));
446
+ const value = values.join(" ").trim();
447
+ if (!verb || verb === "show") {
448
+ console.log(`Rules for ${dir}`);
449
+ if (!loadRules(dir))
450
+ console.log(`(showing the defaults — ${rulesPath(dir)} will be written on your first change)`);
451
+ console.log("");
452
+ console.log(describeRules(rules));
453
+ console.log("");
454
+ console.log(`Change them:`);
455
+ console.log(` ${cliCommand("rules allow <program>")} stop blocking a program`);
456
+ console.log(` ${cliCommand("rules block <program>")} start blocking one`);
457
+ console.log(` ${cliCommand("rules protect <path>")} never write there`);
458
+ console.log(` ${cliCommand("rules unprotect <path>")} allow writing there again`);
459
+ console.log(` ${cliCommand("rules protect-branch <name>")} never push there`);
460
+ console.log(` ${cliCommand("rules unprotect-branch <name>")}`);
461
+ console.log(`Or edit ${rulesPath(dir)} directly, then \`${cliCommand("rules apply")}\`.`);
462
+ process.exit(0);
463
+ }
464
+ const needsValue = ["allow", "block", "protect", "unprotect", "protect-branch", "unprotect-branch"];
465
+ if (needsValue.includes(verb) && !value) {
466
+ console.error(`\`rules ${verb}\` needs a value, e.g. \`${cliCommand(`rules ${verb} ${verb.includes("branch") ? "production" : verb.includes("protect") ? "infra/" : "dd"}`)}\``);
467
+ process.exit(1);
468
+ }
469
+ let changed = "";
470
+ switch (verb) {
471
+ case "allow": {
472
+ const before = rules.destructive_programs.length;
473
+ rules.destructive_programs = rules.destructive_programs.filter((p) => p.toLowerCase() !== value.toLowerCase());
474
+ if (rules.destructive_programs.length === before) {
475
+ console.log(`${value} was not in the blocked list; nothing to change.`);
476
+ process.exit(0);
477
+ }
478
+ changed = `${value} is no longer blocked`;
479
+ break;
480
+ }
481
+ case "block": {
482
+ if (rules.destructive_programs.some((p) => p.toLowerCase() === value.toLowerCase())) {
483
+ console.log(`${value} is already blocked.`);
484
+ process.exit(0);
485
+ }
486
+ rules.destructive_programs.push(value.toLowerCase());
487
+ changed = `${value} is now blocked`;
488
+ break;
489
+ }
490
+ case "protect":
491
+ case "unprotect": {
492
+ const list = verb === "protect" ? "protected_write" : "protected_write";
493
+ const rule = pathRuleFor(value);
494
+ if (verb === "protect") {
495
+ if (rules[list].some((r) => r.label === rule.label)) {
496
+ console.log(`${value} is already protected.`);
497
+ process.exit(0);
498
+ }
499
+ rules[list].push(rule);
500
+ changed = `writes to ${rule.label} are now blocked`;
501
+ }
502
+ else {
503
+ const before = rules[list].length;
504
+ rules[list] = rules[list].filter((r) => r.label !== rule.label && !r.label.startsWith(`${value.replace(/\/$/, "")}/`));
505
+ if (rules[list].length === before) {
506
+ console.log(`${value} is not in the protected list. Current list:`);
507
+ for (const r of rules[list])
508
+ console.log(` ${r.label}`);
509
+ process.exit(1);
510
+ }
511
+ changed = `writes to ${value} are allowed again`;
512
+ }
513
+ break;
514
+ }
515
+ case "protect-branch": {
516
+ if (rules.protected_branches.some((b) => b.toLowerCase() === value.toLowerCase())) {
517
+ console.log(`${value} is already protected.`);
518
+ process.exit(0);
519
+ }
520
+ rules.protected_branches.push(value);
521
+ changed = `pushes to ${value} are now blocked`;
522
+ break;
523
+ }
524
+ case "unprotect-branch": {
525
+ const before = rules.protected_branches.length;
526
+ rules.protected_branches = rules.protected_branches.filter((b) => b.toLowerCase() !== value.toLowerCase());
527
+ if (rules.protected_branches.length === before) {
528
+ console.log(`${value} was not protected; nothing to change.`);
529
+ process.exit(0);
530
+ }
531
+ changed = `pushes to ${value} are allowed again`;
532
+ break;
533
+ }
534
+ case "apply":
535
+ changed = `recompiled from ${rulesPath(dir)}`;
536
+ break;
537
+ default:
538
+ console.error(`unknown: rules ${verb}`);
539
+ printHelp("rules", true);
540
+ process.exit(1);
541
+ }
542
+ // Changing what governs the agent is the same class of action as `init`.
543
+ requireInteractive("rules", args);
544
+ const policyPath = join(dir, "policy.json");
545
+ const agentKid = createSigner({ privateKeyPem: readFileSync(join(dir, "agent.key"), "utf8") }).kid;
546
+ saveRules(dir, rules);
547
+ writeFileSync(policyPath, `${JSON.stringify(compile(rules, agentKid), null, 2)}\n`);
548
+ console.log(`✓ ${changed}`);
549
+ console.log(` rules ${rulesPath(dir)}`);
550
+ console.log(` policy ${policyPath} (recompiled)`);
551
+ // A project policy governs only once trusted, and the hash just changed.
552
+ if (!process.env.SCOPEBOND_HOOK_DIR && existsSync(join(userHome(), "policy.json"))) {
553
+ trustProjectPolicy(dir);
554
+ console.log(` trusted re-pinned for this project`);
555
+ }
556
+ console.log(`\nCheck it: ${cliCommand('test "rm -rf /"')}`);
557
+ process.exit(0);
558
+ }
559
+ /** `prune --before <when> [--yes]` — bound the local receipt store.
560
+ *
561
+ * Signed receipts are the product, so this never runs on its own and never quietly
562
+ * destroys anything: it archives what it will remove to a JSONL file beside the
563
+ * database first, and it refuses entirely once the log has been anchored, because a
564
+ * receipt's position is its anchor leaf index. Without a `--before` it reports the
565
+ * footprint and exits. */
566
+ async function runPrune(args) {
567
+ const dir = resolveConfigDir(process.cwd());
568
+ const dbPath = join(dir, "receipts.db");
569
+ if (!existsSync(dbPath)) {
570
+ console.log("no local receipts yet — nothing to prune.");
571
+ process.exit(0);
572
+ }
573
+ const beforeIdx = args.indexOf("--before");
574
+ if (beforeIdx < 0) {
575
+ console.log(`local receipts ${dbPath}`);
576
+ console.log(` ${describeStore(dbPath)}`);
577
+ console.log(`\nNothing is removed automatically. To bound it, name a cutoff:`);
578
+ console.log(` ${cliCommand("prune --before 90d")} # older than 90 days`);
579
+ console.log(` ${cliCommand("prune --before 2026-01-01")}`);
580
+ console.log(`Receipts are archived beside the database before removal.`);
581
+ process.exit(0);
582
+ }
583
+ const cutoff = parseSince(args[beforeIdx + 1]);
584
+ if (cutoff === null) {
585
+ console.error(`--before wants a duration (90d, 24h) or a date (2026-01-01); got ${args[beforeIdx + 1] ?? "nothing"}`);
586
+ process.exit(1);
587
+ }
588
+ const iso = new Date(cutoff).toISOString();
589
+ const { store } = openReceiptStore({ db: dbPath });
590
+ const sqlite = store;
591
+ if (!sqlite.before || !sqlite.removeBefore) {
592
+ console.error("this receipt store does not support pruning (no SQLite available).");
593
+ process.exit(1);
594
+ }
595
+ try {
596
+ const doomed = sqlite.before(iso);
597
+ if (doomed.length === 0) {
598
+ console.log(`no receipts older than ${iso}.`);
599
+ process.exit(0);
600
+ }
601
+ const before = describeStore(dbPath);
602
+ console.log(`${doomed.length} receipt(s) recorded before ${iso} (store is currently ${before}).`);
603
+ if (!args.includes("--yes") && !process.stdin.isTTY) {
604
+ console.error(`\nThis removes signed evidence, so it needs an explicit confirmation:`);
605
+ console.error(` ${cliCommand(`prune --before ${args[beforeIdx + 1]} --yes`)}`);
606
+ process.exit(1);
607
+ }
608
+ const archive = join(dir, `receipts-archived-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`);
609
+ writeFileSync(archive, `${doomed.map((r) => JSON.stringify(r)).join("\n")}\n`);
610
+ console.log(`archived to ${archive}`);
611
+ const { removed } = sqlite.removeBefore(iso);
612
+ console.log(`removed ${removed} receipt(s)`);
613
+ store.close?.();
614
+ console.log(`store now ${describeStore(dbPath)}`);
615
+ console.log(`\nThe archive is a plain JSONL of signed receipts — still verifiable, still yours.`);
251
616
  process.exit(0);
252
617
  }
253
- for (const r of recent) {
254
- const p = r.payload;
255
- console.log(`${String(p.timestamp ?? "")} ${decisionOf(p).padEnd(13)} ${describeIntent(p)}`);
618
+ catch (error) {
619
+ try {
620
+ store.close?.();
621
+ }
622
+ catch { /* closing after a failure */ }
623
+ console.error(`prune refused: ${error.message}`);
624
+ process.exit(1);
625
+ }
626
+ }
627
+ /** A `--since` value: a duration (`7d`, `24h`, `30m`) or an ISO-ish date. */
628
+ export function parseSince(value, now = Date.now()) {
629
+ if (!value)
630
+ return null;
631
+ const duration = /^(\d+)\s*([dhm])$/i.exec(value.trim());
632
+ if (duration) {
633
+ const scale = { d: 86_400_000, h: 3_600_000, m: 60_000 }[duration[2].toLowerCase()];
634
+ return now - Number(duration[1]) * scale;
256
635
  }
257
- console.log(`\n${recent.length} of ${all.length} receipt(s). Verify them: ${cliCommand("verify")}`);
636
+ const at = Date.parse(value);
637
+ return Number.isFinite(at) ? at : null;
258
638
  }
259
639
  async function runVerify() {
260
640
  const dir = resolveConfigDir(process.cwd());
@@ -266,10 +646,18 @@ async function runVerify() {
266
646
  }
267
647
  const { attester } = loadOrCreateAttester({ file: attesterPath });
268
648
  const { store } = openReceiptStore({ db: dbPath });
649
+ // Verification is the one command that must read everything — that is the point of it.
650
+ // It just should not look hung while doing so: at ~2.6 s per 20,000 receipts a long
651
+ // history is a visible wait, so report progress on a TTY.
269
652
  const all = await Promise.resolve(store.list());
653
+ try {
654
+ store.close?.();
655
+ }
656
+ catch { /* read-only */ }
657
+ const progress = process.stdout.isTTY && all.length >= 2000;
270
658
  let ok = 0;
271
659
  const bad = [];
272
- for (const r of all) {
660
+ for (const [index, r] of all.entries()) {
273
661
  const result = verifyReceipt(r, attester.publicKeyPem);
274
662
  if (result.valid)
275
663
  ok += 1;
@@ -277,7 +665,11 @@ async function runVerify() {
277
665
  const failed = Object.entries(result).filter(([k, v]) => k.endsWith("_valid") && v === false).map(([k]) => k);
278
666
  bad.push(`${String(r.payload.timestamp ?? "")}: ${failed.join(", ") || "invalid"}`);
279
667
  }
668
+ if (progress && (index + 1) % 1000 === 0)
669
+ process.stderr.write(`\rverifying ${index + 1}/${all.length}…`);
280
670
  }
671
+ if (progress)
672
+ process.stderr.write("\r".padEnd(40) + "\r");
281
673
  console.log(`${ok}/${all.length} receipt(s) verify offline against ${attesterPath}.`);
282
674
  if (bad.length) {
283
675
  for (const b of bad)
@@ -294,7 +686,7 @@ async function runTest(args) {
294
686
  }
295
687
  const dir = resolveConfigDir(process.cwd());
296
688
  if (!existsSync(join(dir, "policy.json"))) {
297
- console.error("no policy yet — run `scopebond-hook init` first.");
689
+ console.error(`no policy yet — run \`${cliCommand("init")}\` first.`);
298
690
  process.exit(1);
299
691
  }
300
692
  // Evaluate against the real policy and keys, but a throwaway store, so `test`
@@ -310,10 +702,17 @@ async function runTest(args) {
310
702
  console.log(`command: ${command}`);
311
703
  for (const m of mapped) {
312
704
  const d = await runtime.evaluateOne(m);
313
- console.log(` ${describeIntent({ intent: m.intent }).padEnd(28)} → ${d.decision}${d.reason ? ` (${d.reason})` : ""}`);
705
+ // One tidy row per action. A deny's full explanation is multi-line, so the
706
+ // row carries only the deciding rule and the whole message is printed once,
707
+ // below — exactly as the agent will receive it.
708
+ const note = d.decision === "deny" ? (d.clauseId ? `rule "${d.clauseId}"` : "")
709
+ : d.decision === "not_evaluated" ? d.reason : "";
710
+ console.log(` ${describeIntent({ intent: m.intent }).padEnd(28)} → ${d.decision.padEnd(14)}${note}`);
314
711
  }
315
712
  const overall = await runtime.evaluate(mapped);
316
- console.log(`\noverall: ${overall.decision}${overall.reason ? ` · ${overall.reason}` : ""}`);
713
+ console.log(`\noverall: ${overall.decision}`);
714
+ if (overall.decision === "deny" && overall.reason)
715
+ console.log(`\n${overall.reason}`);
317
716
  process.exit(overall.decision === "deny" ? 2 : 0);
318
717
  }
319
718
  finally {
@@ -326,7 +725,8 @@ async function runConnect(args) {
326
725
  const bundleArg = positional[1];
327
726
  const harness = selectedHarness(args);
328
727
  if (!url) {
329
- console.error("usage: scopebond-hook connect <workspace-url> <enrollment> [--claude|--cursor|--codex] [--no-install]");
728
+ console.error(`usage: ${cliCommand("connect <workspace-url> <enrollment> [--claude|--cursor|--codex] [--no-install]")}`);
729
+ console.error(enrollmentHelp);
330
730
  process.exit(1);
331
731
  }
332
732
  const dir = configDir();
@@ -340,16 +740,28 @@ async function runConnect(args) {
340
740
  bundle = readBundleArg(bundleArg, readStdin);
341
741
  }
342
742
  catch {
343
- console.error("could not read the enrollment (expected a file, inline blob, or JSON on stdin)");
743
+ console.error(`could not read the enrollment (expected a file, inline blob, or JSON on stdin)\n${enrollmentHelp}`);
344
744
  process.exit(1);
345
745
  }
746
+ await finishConnect(dir, url, bundle, harness, args);
747
+ }
748
+ /** Enroll with a bundle and wire the agent: shared by `connect` (a pasted enrollment)
749
+ * and `login` (one received through device-code approval). */
750
+ async function finishConnect(dir, url, bundle, harness, args) {
346
751
  try {
347
752
  const c = await connectCloud(dir, url, bundle);
348
753
  console.log(`✓ Connected to ${c.url}`);
349
754
  // Configure the agent automatically (merges into the existing config), unless the
350
- // caller opts out. This removes the "paste this snippet" step.
351
- if (!args.includes("--no-install")) {
352
- const file = installHarness(harness);
755
+ // caller opts out. This removes the "paste this snippet" step. A hook that is already
756
+ // configured — pinned by `init`, or user-level by `install` — is left as it is:
757
+ // connecting changes where receipts go, not how the hook starts.
758
+ const scopes = harnessScopes(harness, process.cwd());
759
+ const existing = scopes.local ?? scopes.project ?? scopes.user;
760
+ if (existing && !args.includes("--no-install")) {
761
+ console.log(`✓ ${harnessName(harness)} already configured in ${existing}`);
762
+ }
763
+ else if (!args.includes("--no-install")) {
764
+ const { file } = placeHook(harness, process.cwd(), undefined);
353
765
  console.log(`✓ ${harnessName(harness)} configured in ${file}`);
354
766
  if (harness === "codex")
355
767
  console.log(`\nOne last step: ${codexTrustStep}`);
@@ -362,14 +774,26 @@ async function runConnect(args) {
362
774
  console.log("Run your agent — the first action appears in your workspace within seconds.");
363
775
  }
364
776
  catch (error) {
365
- console.error(`connect failed: ${error.message}`);
777
+ const message = error.message;
778
+ console.error(`connect failed: ${message}`);
779
+ if (/enrollment|expired|401|403/i.test(message))
780
+ console.error(enrollmentHelp);
366
781
  process.exit(1);
367
782
  }
368
783
  }
784
+ /** Where an enrollment comes from, for every connect error that means "this one will
785
+ * not work": the bare "invalid enrollment token" told the reader nothing about what to
786
+ * do next. */
787
+ const enrollmentHelp = [
788
+ "An enrollment comes from your Scopebond workspace: open it, choose to connect an agent,",
789
+ "and copy the command it shows — it includes the workspace URL and a fresh enrollment.",
790
+ "Each enrollment is single-use and expires soon after it is created; if this one was",
791
+ "used or has expired, create a new one there.",
792
+ ].join("\n");
369
793
  async function runFlush() {
370
794
  const dir = resolveConfigDir(process.cwd());
371
795
  if (!loadConnection(dir)) {
372
- console.error("not connected to a workspace; run `scopebond-hook connect` first");
796
+ console.error(`not connected to a workspace; run \`${cliCommand("connect <workspace-url> <enrollment>")}\` first`);
373
797
  process.exit(1);
374
798
  }
375
799
  const runtime = createHookRuntime(runtimePaths(dir));
@@ -389,20 +813,53 @@ function cliPath() {
389
813
  * so every project a developer opens is governed without a per-repo `init`. */
390
814
  function runInstall(args) {
391
815
  const dir = userHome();
816
+ // Run through `npx`, this CLI lives in npm's throwaway cache; registering that path
817
+ // would leave a hook that stops starting whenever npm clears it — and a hook that
818
+ // cannot start lets every action through. Pin the durable copy, as `init` does, and
819
+ // fall back to the portable `npx` command when none can be made.
820
+ const commandFor = (h) => {
821
+ const pin = ensureDurableRuntime(cliPath(), hookVersion());
822
+ return pin.cli ? absoluteHookCommand(pin.cli, h) : hookCommand(h);
823
+ };
824
+ const harnessesFor = () => args.includes("--codex") ? ["codex"]
825
+ : args.includes("--cursor") ? ["cursor"]
826
+ : args.includes("--claude") ? ["claude"]
827
+ : ["claude", ...(cursorDetected() ? ["cursor"] : []), ...(codexDetected() ? ["codex"] : [])];
828
+ // `install` rewrites agent config files the user did not create — their theme, plugins
829
+ // and permissions live in ~/.claude/settings.json — so there is a way to see exactly
830
+ // what it would touch before it touches anything.
831
+ if (args.includes("--dry-run")) {
832
+ console.log(`Dry run — nothing is written.\n`);
833
+ console.log(`Would scaffold ${dir} (machine key, countersigning key, starter policy)`);
834
+ for (const h of harnessesFor()) {
835
+ const file = userHarnessFile(h);
836
+ const exists = existsSync(file);
837
+ console.log(`Would ${exists ? "modify" : "create"} ${file}`);
838
+ if (exists)
839
+ console.log(` backing it up to ${file}.scopebond-backup`);
840
+ // Previewed without copying anything: the path the real run would pin.
841
+ console.log(` adding hook ${absoluteHookCommand(isEphemeralPath(cliPath()) ? pinnedCliPath(hookVersion()) : cliPath(), h)}`);
842
+ if (exists && isHarnessConfigured(file))
843
+ console.log(` (a Scopebond hook is already there; it would be replaced, not duplicated)`);
844
+ }
845
+ console.log(`\nNothing else in those files is changed. Run without --dry-run to apply.`);
846
+ process.exit(0);
847
+ }
392
848
  const { agentKid, policyPath } = scaffold(dir, { force: args.includes("--force") });
393
849
  console.log(`Scopebond installed for this user in ${dir}`);
394
850
  console.log(` machine key ${agentKid}`);
395
851
  console.log(` policy ${policyPath} (starter — edit the limits)`);
396
852
  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"] : [])];
853
+ const harnesses = harnessesFor();
401
854
  if (!args.includes("--no-install")) {
402
855
  for (const h of harnesses) {
403
856
  try {
404
- const file = writeHarnessConfig(userHarnessFile(h), h, absoluteHookCommand(cliPath(), h));
857
+ const target = userHarnessFile(h);
858
+ const backup = existsSync(target) ? `${target}.scopebond-backup` : null;
859
+ const file = writeHarnessConfig(target, h, commandFor(h));
405
860
  console.log(`✓ ${harnessName(h)} configured in ${file}`);
861
+ if (backup && existsSync(backup))
862
+ console.log(` original kept at ${backup}`);
406
863
  }
407
864
  catch (error) {
408
865
  console.error(`✗ ${harnessName(h)}: ${error.message}`);
@@ -421,25 +878,59 @@ function runInstall(args) {
421
878
  console.log(`Check it: ${cliCommand("doctor")} · see decisions: ${cliCommand("log")}`);
422
879
  console.log(`To send receipts to a workspace: ${cliCommand("connect <workspace-url> <enrollment>")}`);
423
880
  }
881
+ /** Size and count of the local receipt store, plus its write-ahead log. Signed evidence
882
+ * accumulates in the user's project directory and there is no automatic deletion — so
883
+ * the footprint is reported rather than left to be discovered. */
884
+ function describeStore(dbPath) {
885
+ const bytes = (file) => { try {
886
+ return statSync(file).size;
887
+ }
888
+ catch {
889
+ return 0;
890
+ } };
891
+ const total = bytes(dbPath) + bytes(`${dbPath}-wal`) + bytes(`${dbPath}-shm`);
892
+ const human = total >= 1024 * 1024 ? `${(total / 1024 / 1024).toFixed(1)} MiB` : `${Math.max(1, Math.round(total / 1024))} KiB`;
893
+ let count = null;
894
+ try {
895
+ const { store } = openReceiptStore({ db: dbPath });
896
+ try {
897
+ count = store.count ? Number(store.count()) : null;
898
+ }
899
+ finally {
900
+ store.close?.();
901
+ }
902
+ }
903
+ catch {
904
+ count = null;
905
+ }
906
+ return count === null ? human : `${count} receipt(s), ${human}`;
907
+ }
424
908
  function runStatus() {
425
909
  const home = userHome();
426
910
  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"));
911
+ // Both scopes, always: `init` writes the project config and `install` writes the
912
+ // user one, so a single-scope check contradicts whichever command the user ran.
913
+ const claude = harnessScopes("claude", process.cwd());
914
+ const cursor = harnessScopes("cursor", process.cwd());
915
+ const codex = harnessScopes("codex", process.cwd());
430
916
  const connected = !!loadConnection(resolveConfigDir(process.cwd()));
431
917
  const dbPath = join(resolveConfigDir(process.cwd()), "receipts.db");
432
918
  console.log(`Scopebond hook ${hookVersion()}`);
433
- console.log(` user home ${home} ${installed ? "(installed)" : "(not installed — run `scopebond install`)"}`);
919
+ console.log(` user home ${home} ${installed ? "(installed)" : `(not installed — run \`${cliCommand("install")}\`)`}`);
434
920
  console.log(` active config ${resolveConfigDir(process.cwd())}`);
435
921
  const ignored = untrustedProjectPolicy(process.cwd());
436
922
  if (ignored)
437
923
  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"}`);
924
+ console.log(` Claude Code ${harnessScopeLabel(claude) || "not configured"}`);
925
+ console.log(` Cursor ${harnessScopeLabel(cursor) || (cursorDetected() ? "detected, not configured" : "not detected")}`);
926
+ console.log(` Codex ${codex.project || codex.user ? `${harnessScopeLabel(codex)} — approve once with /hooks` : codexDetected() ? "detected, not configured" : "not detected"}`);
441
927
  console.log(` cloud workspace ${connected ? "connected" : "not connected (local only)"}`);
442
- console.log(` local receipts ${existsSync(dbPath) ? dbPath : "none yet"}`);
928
+ console.log(` local receipts ${existsSync(dbPath) ? `${dbPath} (${describeStore(dbPath)})` : "none yet"}`);
929
+ for (const [name, scopes] of [["Claude Code", claude], ["Cursor", cursor], ["Codex", codex]]) {
930
+ for (const file of [scopes.project, scopes.local, scopes.user])
931
+ if (file)
932
+ console.log(` ${name}: ${file}`);
933
+ }
443
934
  }
444
935
  async function runDoctor() {
445
936
  const problems = [];
@@ -453,16 +944,55 @@ async function runDoctor() {
453
944
  console.log(` cli ${cli} ${existsSync(cli) ? "ok" : "MISSING"}`);
454
945
  const active = resolveConfigDir(process.cwd());
455
946
  const hasPolicy = existsSync(join(active, "policy.json"));
456
- console.log(` active config ${active} ${hasPolicy ? "ok" : "no policy (run `scopebond install` or `init`)"}`);
947
+ console.log(` active config ${active} ${hasPolicy ? "ok" : `no policy (run \`${cliCommand("init")}\` here, or \`${cliCommand("install")}\` once for your user)`}`);
457
948
  if (!hasPolicy)
458
949
  problems.push("no policy found in the active config dir");
459
950
  const ignored = untrustedProjectPolicy(process.cwd());
460
951
  if (ignored)
461
952
  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`);
953
+ // Every harness, in both scopes, plus a check that each configured command can
954
+ // actually start. A pinned path that has gone missing is the one failure mode of
955
+ // the fast absolute-path install, so doctor is where it must surface.
956
+ let anyHarness = false;
957
+ for (const harness of ["claude", "cursor", "codex"]) {
958
+ const scopes = harnessScopes(harness, process.cwd());
959
+ const label = harnessScopeLabel(scopes);
960
+ const name = harnessName(harness);
961
+ if (!label) {
962
+ const detected = harness === "claude" || (harness === "cursor" ? cursorDetected() : codexDetected());
963
+ console.log(` ${name.padEnd(15)} ${detected ? `not configured — run \`${cliCommand(`init${harness === "claude" ? "" : ` --${harness}`}`)}\`` : "not detected"}`);
964
+ continue;
965
+ }
966
+ anyHarness = true;
967
+ console.log(` ${name.padEnd(15)} ${label}`);
968
+ for (const file of [scopes.project, scopes.local, scopes.user]) {
969
+ if (!file)
970
+ continue;
971
+ // A project file git shares must not name a path on this machine: it starts here,
972
+ // so the resolve check below passes, but on every teammate's machine it cannot
973
+ // start — and a hook that cannot start is a non-blocking error, so their agent runs
974
+ // unchecked. Only the doctor on the machine that wrote it can see this coming.
975
+ const share = file === scopes.project ? gitShareState(file) : "none";
976
+ const shared = share === "tracked" || share === "untracked";
977
+ for (const command of configuredHookCommands(file)) {
978
+ const ok = hookCommandResolves(command);
979
+ const leaks = ok && shared && isMachineSpecificCommand(command);
980
+ console.log(` ${ok && !leaks ? "ok " : "BAD "} ${file}`);
981
+ if (!ok) {
982
+ console.log(` command cannot start: ${command}`);
983
+ problems.push(`${name} hook command no longer resolves in ${file} — run \`${cliCommand("init")}\` to repair it`);
984
+ }
985
+ else if (leaks) {
986
+ console.log(` machine-specific command in a file git shares: ${command}`);
987
+ problems.push(`${name} hook in ${file} names a path on this machine and git shares that file — anyone who clones it gets a hook that cannot start, and their agent runs unchecked. Run \`${cliCommand(`init${harness === "claude" ? "" : ` --${harness}`}`)}\` to move it, then commit the change`);
988
+ }
989
+ }
990
+ }
991
+ if (harness === "codex")
992
+ console.log(` run /hooks in Codex and approve Scopebond once`);
993
+ }
994
+ if (!anyHarness)
995
+ problems.push(`no coding agent is configured — run \`${cliCommand("init")}\` in your project root`);
466
996
  const connection = loadConnection(active);
467
997
  if (!connection) {
468
998
  console.log(` cloud not connected (local only) — receipts stay on this machine`);
@@ -484,20 +1014,29 @@ async function runDoctor() {
484
1014
  function runUninstall(args) {
485
1015
  requireInteractive("uninstall", args);
486
1016
  let removed = 0;
1017
+ // Both scopes. Checking only the user config meant that after a per-project `init` —
1018
+ // the install the site actually tells people to run — `uninstall` reported "no
1019
+ // user-level harness config found" and left the project hook in place.
487
1020
  for (const h of ["claude", "cursor", "codex"]) {
488
- if (removeHarnessConfig(userHarnessFile(h))) {
489
- console.log(`✓ removed the Scopebond hook from ${userHarnessFile(h)}`);
490
- removed++;
1021
+ for (const file of [projectHarnessFile(h, process.cwd()), localHarnessFile(h, process.cwd()), userHarnessFile(h)]) {
1022
+ if (file && removeHarnessConfig(file)) {
1023
+ console.log(`✓ removed the Scopebond hook from ${file}`);
1024
+ removed++;
1025
+ }
491
1026
  }
492
1027
  }
493
1028
  if (removed === 0)
494
- console.log("no user-level harness config found.");
1029
+ console.log("no Scopebond hook found in this project or your user config.");
495
1030
  if (args.includes("--purge")) {
496
1031
  purgeHome();
497
1032
  console.log(`✓ purged ${userHome()} (keys, policy, receipts)`);
498
1033
  }
499
1034
  else
500
1035
  console.log(`Kept ${userHome()} (keys, policy, receipts). Use --purge to remove it too.`);
1036
+ const projectDir = resolveConfigDir(process.cwd());
1037
+ if (existsSync(join(projectDir, "policy.json"))) {
1038
+ console.log(`Kept ${projectDir} (this project's keys, policy and receipts) — delete it by hand if you want it gone.`);
1039
+ }
501
1040
  }
502
1041
  /** `trust` — let this project's .scopebond policy govern here instead of the user
503
1042
  * home, pinned to its current contents. Run by the user, not the agent: the file it
@@ -513,11 +1052,195 @@ function runTrust(args) {
513
1052
  console.log(`✓ trusted ${join(dir, "policy.json")} (sha256 ${digest.slice(0, 12)}…)`);
514
1053
  console.log("It governs agents in this project until it changes; after any edit, review it and run trust again.");
515
1054
  }
516
- function runLogin() {
517
- console.log("Device-code login is not available yet.");
518
- console.log(`For now, connect with a one-time enrollment from your workspace:`);
519
- console.log(` ${cliCommand("connect <workspace-url> <enrollment>")}`);
520
- process.exit(0);
1055
+ /** `login <workspace-url>` — connect this computer without pasting anything. It asks
1056
+ * the workspace for a short code, shows it with the page to open, and waits while a
1057
+ * person who can manage the workspace approves it there for an environment and agent.
1058
+ * The approval hands back a single-use enrollment, which completes exactly as
1059
+ * `connect` does. Nothing secret is printed: the device code stays in memory. */
1060
+ async function runLogin(args) {
1061
+ const positional = args.filter((a) => !a.startsWith("--"));
1062
+ const harness = selectedHarness(args);
1063
+ let origin;
1064
+ try {
1065
+ const parsed = new URL(positional[0] ?? "");
1066
+ const local = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]";
1067
+ if (parsed.protocol !== "https:" && !(local && parsed.protocol === "http:"))
1068
+ throw new Error("https required");
1069
+ origin = parsed.origin;
1070
+ }
1071
+ catch {
1072
+ console.error(`usage: ${cliCommand("login <workspace-url> [--claude|--cursor|--codex] [--no-install]")}`);
1073
+ console.error("The workspace URL is the address of your Scopebond workspace, for example https://cloud.scopebond.com.");
1074
+ process.exit(1);
1075
+ }
1076
+ const post = async (path, body) => {
1077
+ const response = await fetch(new URL(path, origin), {
1078
+ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
1079
+ redirect: "error", signal: AbortSignal.timeout(15_000),
1080
+ });
1081
+ return { status: response.status, json: await response.json().catch(() => ({})) };
1082
+ };
1083
+ let start;
1084
+ try {
1085
+ start = await post("/v1/device/code", { client_name: hostname(), harness });
1086
+ }
1087
+ catch (error) {
1088
+ console.error(`could not reach ${origin}: ${error.message}`);
1089
+ process.exit(1);
1090
+ }
1091
+ const deviceCode = typeof start.json.device_code === "string" ? start.json.device_code : "";
1092
+ if (start.status !== 200 || !deviceCode) {
1093
+ console.error(`${origin} did not start a login (HTTP ${start.status}). Check the workspace URL, or use ${cliCommand("connect <workspace-url> <enrollment>")}.`);
1094
+ process.exit(1);
1095
+ }
1096
+ const userCode = String(start.json.user_code ?? "");
1097
+ const verify = String(start.json.verification_uri_complete ?? start.json.verification_uri ?? origin);
1098
+ let intervalMs = Math.max(1, Number(start.json.interval ?? 5)) * 1000;
1099
+ const deadline = Date.now() + Math.max(60, Number(start.json.expires_in ?? 600)) * 1000;
1100
+ console.log(`To connect this computer, open:\n\n ${verify}\n\nand check that it shows the code ${userCode}\n`);
1101
+ console.log("Waiting for approval (the code expires in 10 minutes; Ctrl+C to stop)…");
1102
+ const dir = configDir();
1103
+ scaffold(dir, {});
1104
+ while (Date.now() < deadline) {
1105
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
1106
+ let polled;
1107
+ try {
1108
+ polled = await post("/v1/device/token", { device_code: deviceCode });
1109
+ }
1110
+ catch {
1111
+ continue;
1112
+ } // a transient network error: keep waiting until the deadline
1113
+ if (polled.status === 200 && polled.json.enrollment && typeof polled.json.enrollment === "object") {
1114
+ console.log("✓ Approved");
1115
+ await finishConnect(dir, origin, polled.json.enrollment, harness, args);
1116
+ return;
1117
+ }
1118
+ const error = polled.json.error;
1119
+ if (error === "authorization_pending")
1120
+ continue;
1121
+ if (error === "slow_down") {
1122
+ intervalMs += 5_000;
1123
+ continue;
1124
+ }
1125
+ if (error === "access_denied") {
1126
+ console.error("The request was denied in the workspace. Nothing was connected.");
1127
+ process.exit(1);
1128
+ }
1129
+ if (error === "expired_token")
1130
+ break;
1131
+ console.error(`login failed (${String(error ?? `HTTP ${polled.status}`)}). Run the command again for a new code.`);
1132
+ process.exit(1);
1133
+ }
1134
+ console.error("The code expired before it was approved. Run the command again for a new one.");
1135
+ process.exit(1);
1136
+ }
1137
+ /** What each command does, its arguments, and one example. The whole help used to be a
1138
+ * single usage line listing 15 command names, which told a reader nothing about what any
1139
+ * of them did or what arguments they take. */
1140
+ const COMMANDS = [
1141
+ { name: "init", args: "[--cursor|--codex] [--shared] [--dry-run] [--no-install] [--npx] [--force] [--yes]",
1142
+ summary: "set this project up: keys, a starter policy, and your agent wired to the hook",
1143
+ detail: [
1144
+ "Writes .scopebond/ (machine key, countersigning key, starter policy, .gitignore) and",
1145
+ "wires your agent to the hook. It pins a durable copy of this package so the hook starts",
1146
+ "fast, and because that command names paths on this machine it goes where git will not",
1147
+ "share it: .claude/settings.local.json (kept out of git for this clone), or for Cursor",
1148
+ "and Codex their project file only while git does not track it. --shared writes the",
1149
+ "portable npx command to .claude/settings.json, .cursor/hooks.json or .codex/hooks.json",
1150
+ "instead, so everyone who clones the project gets the hook. --dry-run shows what it",
1151
+ "would write. --no-install prints the config snippet rather than writing it.",
1152
+ "Needs a terminal, or --yes in a script, because it changes what governs your agent.",
1153
+ ] },
1154
+ { name: "install", args: "[--claude] [--cursor] [--codex] [--dry-run] [--force] [--yes]",
1155
+ summary: "set up once for this user, so every project you open is governed",
1156
+ detail: [
1157
+ "Scaffolds ~/.scopebond and registers the hook in your user-level agent config.",
1158
+ "--dry-run prints exactly which files it would touch and changes nothing. Each config",
1159
+ "is copied to <file>.scopebond-backup before its first modification.",
1160
+ ] },
1161
+ { name: "rules", args: "[show|allow|block|protect|unprotect|protect-branch|unprotect-branch|apply] [value]",
1162
+ summary: "read and change the limits in plain terms",
1163
+ detail: [
1164
+ "With no arguments, prints what is blocked in plain English — no regular expressions.",
1165
+ "The editable lists are .scopebond/rules.json; policy.json is compiled from them, so",
1166
+ "you never hand-write a lookahead.",
1167
+ " rules allow dd stop blocking a program",
1168
+ " rules protect infra/ never write there",
1169
+ " rules protect-branch production",
1170
+ " rules apply recompile after editing rules.json by hand",
1171
+ ] },
1172
+ { name: "status", summary: "what is configured, where, and how big the local log is" },
1173
+ { name: "doctor", summary: "check the setup and whether each configured hook command can start",
1174
+ detail: ["Exits non-zero when something is wrong, so it works in a script."] },
1175
+ { name: "log", args: "[-n N] [--deny] [--since 7d]",
1176
+ summary: "the recent decisions",
1177
+ detail: [`--deny shows only blocked actions; --since takes 7d, 24h, 30m or a date.`, `e.g. ${cliCommand("log --deny --since 7d")}`] },
1178
+ { name: "verify", summary: "check every local receipt offline against the countersigning key",
1179
+ detail: ["No network, no account. Exits non-zero if any receipt fails."] },
1180
+ { name: "test", args: '"<shell command>"',
1181
+ summary: "show the decision for a command without running or recording it",
1182
+ detail: [`e.g. ${cliCommand('test "rm -rf /"')}`] },
1183
+ { name: "prune", args: "[--before 90d] [--yes]",
1184
+ summary: "report the local store's size, or bound it",
1185
+ detail: [
1186
+ "With no --before it only reports. With one, it archives the receipts it will remove",
1187
+ "to a JSONL file beside the database, then removes them. Refuses once the log has been",
1188
+ "anchored, because a receipt's position is its anchor leaf index.",
1189
+ ] },
1190
+ { name: "login", args: "<workspace-url> [--claude|--cursor|--codex] [--no-install]",
1191
+ summary: "connect this computer to a Scopebond Cloud workspace by approving a short code there",
1192
+ detail: [
1193
+ "Prints a code and a link; someone who manages the workspace opens it, checks the code",
1194
+ "and approves it for an environment and agent. Nothing is copied or pasted.",
1195
+ ] },
1196
+ { name: "connect", args: "<workspace-url> <enrollment> [--claude|--cursor|--codex]",
1197
+ summary: "send receipts to a Scopebond Cloud workspace as well as keeping them locally" },
1198
+ { name: "flush", summary: "deliver any receipts still queued for the workspace now" },
1199
+ { name: "trust", args: "[--yes]", summary: "let this project's .scopebond policy govern here (pinned by hash)" },
1200
+ { name: "uninstall", args: "[--purge] [--yes]", summary: "remove the hook from your agent config; --purge also deletes the home" },
1201
+ { name: "claude", summary: "(internal) decide one Claude Code PreToolUse call, JSON on stdin" },
1202
+ { name: "cursor", summary: "(internal) decide one Cursor hook event, JSON on stdin" },
1203
+ { name: "codex", summary: "(internal) decide one Codex PreToolUse call, JSON on stdin" },
1204
+ ];
1205
+ function printHelp(topic, toStderr = false) {
1206
+ const out = toStderr ? console.error : console.log;
1207
+ const match = topic ? COMMANDS.find((c) => c.name === topic.replace(/^--?/, "")) : undefined;
1208
+ if (match) {
1209
+ out(`scopebond-hook ${match.name}${match.args ? ` ${match.args}` : ""}`);
1210
+ out("");
1211
+ out(` ${match.summary}`);
1212
+ if (match.detail) {
1213
+ out("");
1214
+ for (const line of match.detail)
1215
+ out(` ${line}`);
1216
+ }
1217
+ return;
1218
+ }
1219
+ if (topic) {
1220
+ out(`no such command: ${topic}`);
1221
+ out("");
1222
+ }
1223
+ out(`scopebond-hook — govern a coding agent's tool calls against policy, before they run.`);
1224
+ out("");
1225
+ out(`Usage: scopebond-hook <command> [options]`);
1226
+ out("");
1227
+ out(` ${cliCommand("init")} set up this project`);
1228
+ out(` ${cliCommand('test "rm -rf /"')} see a decision without running it`);
1229
+ out(` ${cliCommand("log --deny")} what got blocked`);
1230
+ out(` ${cliCommand("rules")} what is blocked, in plain English`);
1231
+ out("");
1232
+ out("Commands:");
1233
+ const width = Math.max(...COMMANDS.map((c) => c.name.length));
1234
+ for (const command of COMMANDS) {
1235
+ if (command.summary.startsWith("(internal)"))
1236
+ continue;
1237
+ out(` ${command.name.padEnd(width)} ${command.summary}`);
1238
+ }
1239
+ out("");
1240
+ out(` ${"help".padEnd(width)} \`help <command>\` for that command's arguments and examples`);
1241
+ out("");
1242
+ out(`Receipts and keys stay in .scopebond/ in this project. Nothing leaves your machine`);
1243
+ out(`unless you run \`connect\`. Docs: https://github.com/avouro-com/scopebond`);
521
1244
  }
522
1245
  const [cmd, ...rest] = process.argv.slice(2);
523
1246
  if (cmd === "claude") {
@@ -560,13 +1283,23 @@ else if (cmd === "uninstall") {
560
1283
  runUninstall(rest);
561
1284
  }
562
1285
  else if (cmd === "login") {
563
- runLogin();
1286
+ await runLogin(rest);
564
1287
  }
565
1288
  else if (cmd === "trust") {
566
1289
  runTrust(rest);
567
1290
  }
1291
+ else if (cmd === "prune") {
1292
+ await runPrune(rest);
1293
+ }
1294
+ else if (cmd === "rules") {
1295
+ runRules(rest);
1296
+ }
1297
+ else if (cmd === "help" || cmd === "--help" || cmd === "-h" || cmd === undefined) {
1298
+ printHelp(rest[0]);
1299
+ }
568
1300
  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]");
1301
+ console.error(`unknown command: ${cmd}`);
1302
+ printHelp(undefined, true);
570
1303
  process.exit(1);
571
1304
  }
572
1305
  //# sourceMappingURL=cli.js.map