@scopebond/hook 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +210 -0
  2. package/dist/budget-load.d.ts +68 -0
  3. package/dist/budget-load.d.ts.map +1 -0
  4. package/dist/budget-load.js +164 -0
  5. package/dist/budget-load.js.map +1 -0
  6. package/dist/capabilities.d.ts +103 -0
  7. package/dist/capabilities.d.ts.map +1 -0
  8. package/dist/capabilities.js +212 -0
  9. package/dist/capabilities.js.map +1 -0
  10. package/dist/classify.d.ts +17 -0
  11. package/dist/classify.d.ts.map +1 -0
  12. package/dist/classify.js +78 -0
  13. package/dist/classify.js.map +1 -0
  14. package/dist/cli.d.ts.map +1 -1
  15. package/dist/cli.js +546 -16
  16. package/dist/cli.js.map +1 -1
  17. package/dist/cloud.d.ts +8 -0
  18. package/dist/cloud.d.ts.map +1 -1
  19. package/dist/cloud.js.map +1 -1
  20. package/dist/dispatch-cli.d.ts +2 -0
  21. package/dist/dispatch-cli.d.ts.map +1 -0
  22. package/dist/dispatch-cli.js +128 -0
  23. package/dist/dispatch-cli.js.map +1 -0
  24. package/dist/explain.d.ts.map +1 -1
  25. package/dist/explain.js +2 -1
  26. package/dist/explain.js.map +1 -1
  27. package/dist/group.d.ts +12 -0
  28. package/dist/group.d.ts.map +1 -0
  29. package/dist/group.js +55 -0
  30. package/dist/group.js.map +1 -0
  31. package/dist/index.d.ts +25 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +17 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/install.d.ts +10 -0
  36. package/dist/install.d.ts.map +1 -1
  37. package/dist/install.js +56 -0
  38. package/dist/install.js.map +1 -1
  39. package/dist/map.d.ts +4 -0
  40. package/dist/map.d.ts.map +1 -1
  41. package/dist/map.js +29 -1
  42. package/dist/map.js.map +1 -1
  43. package/dist/obs-emitter.d.ts +132 -0
  44. package/dist/obs-emitter.d.ts.map +1 -0
  45. package/dist/obs-emitter.js +415 -0
  46. package/dist/obs-emitter.js.map +1 -0
  47. package/dist/obs-store.d.ts +160 -0
  48. package/dist/obs-store.d.ts.map +1 -0
  49. package/dist/obs-store.js +367 -0
  50. package/dist/obs-store.js.map +1 -0
  51. package/dist/obs-upload.d.ts +24 -0
  52. package/dist/obs-upload.d.ts.map +1 -0
  53. package/dist/obs-upload.js +231 -0
  54. package/dist/obs-upload.js.map +1 -0
  55. package/dist/observation.d.ts +232 -0
  56. package/dist/observation.d.ts.map +1 -0
  57. package/dist/observation.js +308 -0
  58. package/dist/observation.js.map +1 -0
  59. package/dist/paths.d.ts +15 -0
  60. package/dist/paths.d.ts.map +1 -0
  61. package/dist/paths.js +79 -0
  62. package/dist/paths.js.map +1 -0
  63. package/dist/policy-load.d.ts +60 -0
  64. package/dist/policy-load.d.ts.map +1 -0
  65. package/dist/policy-load.js +151 -0
  66. package/dist/policy-load.js.map +1 -0
  67. package/dist/proof.d.ts +30 -0
  68. package/dist/proof.d.ts.map +1 -0
  69. package/dist/proof.js +186 -0
  70. package/dist/proof.js.map +1 -0
  71. package/dist/rules.d.ts +20 -0
  72. package/dist/rules.d.ts.map +1 -1
  73. package/dist/rules.js +50 -0
  74. package/dist/rules.js.map +1 -1
  75. package/dist/runtime.d.ts +18 -1
  76. package/dist/runtime.d.ts.map +1 -1
  77. package/dist/runtime.js +74 -10
  78. package/dist/runtime.js.map +1 -1
  79. package/dist/scan.d.ts +7 -0
  80. package/dist/scan.d.ts.map +1 -0
  81. package/dist/scan.js +38 -0
  82. package/dist/scan.js.map +1 -0
  83. package/dist/shell.d.ts.map +1 -1
  84. package/dist/shell.js +12 -3
  85. package/dist/shell.js.map +1 -1
  86. package/dist/sql-classify.d.ts +11 -0
  87. package/dist/sql-classify.d.ts.map +1 -0
  88. package/dist/sql-classify.js +251 -0
  89. package/dist/sql-classify.js.map +1 -0
  90. package/dist/typed-infra.d.ts +73 -0
  91. package/dist/typed-infra.d.ts.map +1 -0
  92. package/dist/typed-infra.js +758 -0
  93. package/dist/typed-infra.js.map +1 -0
  94. package/dist/typed-ops.d.ts +140 -0
  95. package/dist/typed-ops.d.ts.map +1 -0
  96. package/dist/typed-ops.js +549 -0
  97. package/dist/typed-ops.js.map +1 -0
  98. package/dist/vectors.d.ts +40 -0
  99. package/dist/vectors.d.ts.map +1 -0
  100. package/dist/vectors.js +219 -0
  101. package/dist/vectors.js.map +1 -0
  102. package/package.json +4 -4
package/dist/cli.js CHANGED
@@ -24,22 +24,33 @@
24
24
  });
25
25
  }
26
26
  import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
27
- import { join } from "node:path";
27
+ import { join, resolve } from "node:path";
28
28
  import { hostname, tmpdir } from "node:os";
29
29
  import { execFileSync } from "node:child_process";
30
30
  import { verifyReceipt } from "@scopebond/gateway";
31
31
  import { openReceiptStore, loadOrCreateAttester } from "@scopebond/gateway/node";
32
+ import { callRequestOf, keyedIdFor, TYPED_ACTION_TYPES } from "./typed-ops.js";
33
+ import { databaseGuardActions } from "./typed-infra.js";
32
34
  import { mapClaudeToolUse, mapCodexToolUse, mapCursorEvent, fillPushBranch } from "./map.js";
33
35
  import { createHookRuntime } from "./runtime.js";
34
36
  import { useDigestKey, loadOrCreateDigestKey } from "./minimize.js";
35
37
  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";
38
+ import { userHome, userHarnessFile, resolveConfigDir, writeHarnessConfig, removeHarnessConfig, cursorDetected, codexDetected, absoluteHookCommand, isHarnessConfigured, purgeHome, harnessScopes, harnessScopeLabel, configuredHookCommands, hookCommandResolves, projectHarnessFile, localHarnessFile, gitShareState, isMachineSpecificCommand, trustProjectPolicy, untrustedProjectPolicy, wireLifecycleHooks, unwireLifecycleHooks, } from "./install.js";
39
+ import { openObservations, describeObservations, observationStatus, stopReasonFromClaude, exitFromClaudeFailure, HEARTBEAT_INTERVAL_MS, OBSERVATIONS_SCOPE, } from "./obs-emitter.js";
40
+ import { OBSERVATION_DB, ObservationStore } from "./obs-store.js";
41
+ import { loadOrCreateBindingKey } from "./observation.js";
42
+ import { uploadPending } from "./obs-upload.js";
37
43
  import { connectCloud, loadConnection } from "./cloud.js";
44
+ import { loadPolicyExport } from "./policy-load.js";
45
+ import { loadBudgetExport } from "./budget-load.js";
38
46
  import { compile, defaultRules, describeRules, loadRules, saveRules, rulesPath, pathRuleFor } from "./rules.js";
39
47
  import { createSigner } from "@scopebond/sdk";
48
+ import { runDispatchCommand } from "./dispatch-cli.js";
40
49
  import { describeAction } from "./explain.js";
41
50
  import { ensureDurableRuntime, pinnedCliPath, isEphemeralPath } from "./runtime-install.js";
42
51
  import { cliCommand, hookCommand, hookVersion } from "./version.js";
52
+ import { computeManifest, renderManifest } from "./capabilities.js";
53
+ import { runProofFixtures, loadProofs, saveProofs, proofPassed, deliverProofReceipts } from "./proof.js";
43
54
  import { fileURLToPath } from "node:url";
44
55
  /** The current git branch in `cwd` (best-effort). A bare `git push` pushes it, so
45
56
  * the runtime fills it in before evaluating; on failure the ref stays absent and
@@ -77,13 +88,14 @@ function readBundleArg(bundleArg, stdin) {
77
88
  function configDir() {
78
89
  return process.env.SCOPEBOND_HOOK_DIR ?? join(process.cwd(), ".scopebond");
79
90
  }
80
- function runtimePaths(dir) {
91
+ function runtimePaths(dir, cwd) {
81
92
  const connection = loadConnection(dir);
82
93
  return {
83
94
  policyPath: join(dir, "policy.json"),
84
95
  keyPath: join(dir, "agent.key"),
85
96
  attesterPath: join(dir, "attester.key"),
86
97
  dbPath: join(dir, "receipts.db"),
98
+ ...(cwd ? { cwd } : {}),
87
99
  // Strict: deny (not just observe) tools with no taxonomy mapping.
88
100
  strict: process.argv.includes("--strict") || process.env.SCOPEBOND_HOOK_STRICT === "1",
89
101
  // When connected, auto-export receipts. A short bounded flush keeps the hot path
@@ -92,6 +104,16 @@ function runtimePaths(dir) {
92
104
  ...(connection ? { cloud: { connection, flushTimeoutMs: Number(process.env.SCOPEBOND_HOOK_FLUSH_MS ?? 800) } } : {}),
93
105
  };
94
106
  }
107
+ /** The harness's own id for this tool call, when it gives one (Claude Code and Codex send
108
+ * `tool_use_id`), so the receipts of one call share a stable action group. */
109
+ function callId(input) {
110
+ for (const key of ["tool_use_id", "tool_call_id", "call_id"]) {
111
+ const value = input?.[key];
112
+ if (typeof value === "string" && value !== "")
113
+ return value;
114
+ }
115
+ return undefined;
116
+ }
95
117
  function readStdin() {
96
118
  try {
97
119
  return readFileSync(0, "utf8");
@@ -134,10 +156,53 @@ const cursorCoverageNote = [
134
156
  "For edits that must be blocked before they land, use the GitHub Action as a required",
135
157
  "check on pull requests.",
136
158
  ].join("\n");
137
- async function runPreToolUse(mapper, deny = denyClaude) {
159
+ /** Record what was dispatched as observations, when this workspace enrolled for them. Purely
160
+ * additive: it runs after the decision is made and can neither change it nor fail it. */
161
+ function recordObservations(dir, cwd, input, decision, harness) {
162
+ try {
163
+ const { emitter } = openObservations(dir, { adapterVersion: hookVersion() });
164
+ if (!emitter)
165
+ return undefined;
166
+ const sessionId = typeof input.session_id === "string" && input.session_id !== "" ? input.session_id : undefined;
167
+ // Session lifecycle is Claude Code only: its SessionStart/SessionEnd hooks are the ones
168
+ // the tested mapping covers. Other hosts still get tool intents from their PreToolUse.
169
+ if (harness === "claude" && sessionId)
170
+ emitter.activity(sessionId, cwd);
171
+ emitter.toolIntents({ harnessSessionId: sessionId, callId: callId(input), cwd, dispatched: decision.dispatched ?? [], request: callRequestOf(input) });
172
+ return emitter;
173
+ }
174
+ catch {
175
+ return undefined;
176
+ }
177
+ }
178
+ /** The remote-database actions of a shell call, only when the rule set opts in (`protect_remote_database`).
179
+ * They are extra evaluated intents read from the raw command, so the deny happens before it runs. */
180
+ function databaseGuard(dir, cwd, input) {
181
+ let enabled = false;
182
+ try {
183
+ enabled = loadRules(dir)?.protect_remote_database === true;
184
+ }
185
+ catch {
186
+ return [];
187
+ }
188
+ if (!enabled)
189
+ return [];
190
+ const request = callRequestOf(input);
191
+ if (request?.command === undefined)
192
+ return [];
193
+ const intent = (params) => ({ intent: { action_type: "db.exec", params }, evaluated: true, source: "shell" });
194
+ try {
195
+ return databaseGuardActions(request.command, request.dialect ?? "posix", { cwd }).map((action) => intent({ ...action }));
196
+ }
197
+ catch {
198
+ // The rule is on and the reader failed: a command that names a database tool is not waved through.
199
+ return /(?:wrangler|psql)/i.test(request.command) ? [intent({ provider: "unknown", verb: "unknown", scope: "unknown", risk: "unknown" })] : [];
200
+ }
201
+ }
202
+ async function runPreToolUse(mapper, deny = denyClaude, raw, harness = "claude") {
138
203
  let input;
139
204
  try {
140
- input = JSON.parse(readStdin());
205
+ input = JSON.parse(raw ?? readStdin());
141
206
  }
142
207
  catch {
143
208
  deny("hook received invalid JSON on stdin");
@@ -146,10 +211,12 @@ async function runPreToolUse(mapper, deny = denyClaude) {
146
211
  try {
147
212
  const cwd = input?.cwd ? String(input.cwd) : process.cwd();
148
213
  const dir = resolveConfigDir(cwd);
149
- runtime = createHookRuntime(runtimePaths(dir));
214
+ runtime = createHookRuntime(runtimePaths(dir, cwd));
150
215
  useDigestKey(loadOrCreateDigestKey(dir));
151
- const decision = await runtime.evaluate(fillPushBranch(mapper(input), currentBranch(cwd)));
152
- await runtime.flush();
216
+ const decision = await runtime.evaluate([...fillPushBranch(mapper(input), currentBranch(cwd)), ...databaseGuard(dir, cwd, input)], { groupKey: callId(input) });
217
+ const observer = recordObservations(dir, cwd, input, decision, harness);
218
+ await Promise.all([runtime.flush(), observer?.flush() ?? Promise.resolve()]);
219
+ observer?.close();
153
220
  // Close before deciding: the receipt is already committed, and leaving the handle
154
221
  // open is what made the write-ahead log grow without bound.
155
222
  runtime.close();
@@ -168,11 +235,63 @@ async function runPreToolUse(mapper, deny = denyClaude) {
168
235
  deny(`Scopebond hook failed closed: ${error.message}. Repair: run \`${cliCommand("init")}\`.`);
169
236
  }
170
237
  }
238
+ /** Claude Code events other than PreToolUse: session start and end, and the after-action
239
+ * events. They only feed observations; they never print a decision and always exit 0. */
240
+ async function runClaudeLifecycle(input) {
241
+ try {
242
+ const cwd = input.cwd ? String(input.cwd) : process.cwd();
243
+ const { emitter } = openObservations(resolveConfigDir(cwd), { adapterVersion: hookVersion() });
244
+ const sessionId = typeof input.session_id === "string" && input.session_id !== "" ? input.session_id : undefined;
245
+ if (emitter) {
246
+ try {
247
+ switch (input.hook_event_name) {
248
+ case "SessionStart":
249
+ if (sessionId)
250
+ emitter.sessionStart(sessionId, cwd);
251
+ break;
252
+ case "SessionEnd":
253
+ if (sessionId)
254
+ emitter.sessionStop(sessionId, stopReasonFromClaude(input.reason));
255
+ break;
256
+ case "PostToolUse": {
257
+ const id = callId(input);
258
+ if (id)
259
+ emitter.toolOutcomes(sessionId, id, "ok");
260
+ break;
261
+ }
262
+ case "PostToolUseFailure": {
263
+ const id = callId(input);
264
+ if (id)
265
+ emitter.toolOutcomes(sessionId, id, exitFromClaudeFailure(input.is_interrupt));
266
+ break;
267
+ }
268
+ }
269
+ await emitter.flush(input.hook_event_name === "SessionEnd" ? 1500 : 800);
270
+ }
271
+ finally {
272
+ emitter.close();
273
+ }
274
+ }
275
+ }
276
+ catch { /* observations are best effort; nothing here may affect the agent */ }
277
+ process.exit(0);
278
+ }
279
+ const CLAUDE_OBSERVATION_EVENTS = new Set(["SessionStart", "SessionEnd", "PostToolUse", "PostToolUseFailure"]);
171
280
  async function runClaude() {
172
- await runPreToolUse(mapClaudeToolUse);
281
+ const raw = readStdin();
282
+ let event;
283
+ let input;
284
+ try {
285
+ input = JSON.parse(raw);
286
+ event = input?.hook_event_name;
287
+ }
288
+ catch { /* PreToolUse reports invalid input */ }
289
+ if (input && typeof event === "string" && CLAUDE_OBSERVATION_EVENTS.has(event))
290
+ await runClaudeLifecycle(input);
291
+ await runPreToolUse(mapClaudeToolUse, denyClaude, raw, "claude");
173
292
  }
174
293
  async function runCodex() {
175
- await runPreToolUse(mapCodexToolUse, denyCodex);
294
+ await runPreToolUse(mapCodexToolUse, denyCodex, undefined, "codex");
176
295
  }
177
296
  function denyCursor(reason) {
178
297
  process.stdout.write(JSON.stringify({ permission: "deny", agentMessage: reason }) + "\n");
@@ -202,11 +321,13 @@ async function runCursor() {
202
321
  try {
203
322
  const cwd = input?.cwd ? String(input.cwd) : process.cwd();
204
323
  const dir = resolveConfigDir(cwd);
205
- runtime = createHookRuntime(runtimePaths(dir));
324
+ runtime = createHookRuntime(runtimePaths(dir, cwd));
206
325
  useDigestKey(loadOrCreateDigestKey(dir));
207
326
  const mapped = fillPushBranch(mapCursorEvent(event, input), currentBranch(cwd));
208
- const decision = await runtime.evaluate(mapped);
209
- await runtime.flush();
327
+ const decision = await runtime.evaluate(mapped, { groupKey: callId(input) });
328
+ const observer = recordObservations(dir, cwd, input, decision, "cursor");
329
+ await Promise.all([runtime.flush(), observer?.flush() ?? Promise.resolve()]);
330
+ observer?.close();
210
331
  // An `afterFileEdit` violation is real and recorded, but the edit has already
211
332
  // landed. Say so rather than letting "blocked" imply it was stopped.
212
333
  postHoc = mapped.some((m) => m.postHoc);
@@ -458,6 +579,7 @@ function runRules(args) {
458
579
  console.log(` ${cliCommand("rules unprotect <path>")} allow writing there again`);
459
580
  console.log(` ${cliCommand("rules protect-branch <name>")} never push there`);
460
581
  console.log(` ${cliCommand("rules unprotect-branch <name>")}`);
582
+ console.log(` ${cliCommand("rules protect-remote-database")} block destructive or unreadable SQL on a remote database (off by default)`);
461
583
  console.log(`Or edit ${rulesPath(dir)} directly, then \`${cliCommand("rules apply")}\`.`);
462
584
  process.exit(0);
463
585
  }
@@ -531,6 +653,22 @@ function runRules(args) {
531
653
  changed = `pushes to ${value} are allowed again`;
532
654
  break;
533
655
  }
656
+ case "protect-remote-database":
657
+ if (rules.protect_remote_database === true) {
658
+ console.log("Remote databases are already protected.");
659
+ process.exit(0);
660
+ }
661
+ rules.protect_remote_database = true;
662
+ changed = "remote SQL that drops, deletes or updates every row, or cannot be read, is now blocked";
663
+ break;
664
+ case "unprotect-remote-database":
665
+ if (rules.protect_remote_database !== true) {
666
+ console.log("Remote databases were not protected; nothing to change.");
667
+ process.exit(0);
668
+ }
669
+ delete rules.protect_remote_database;
670
+ changed = "remote SQL is no longer checked";
671
+ break;
534
672
  case "apply":
535
673
  changed = `recompiled from ${rulesPath(dir)}`;
536
674
  break;
@@ -751,6 +889,14 @@ async function finishConnect(dir, url, bundle, harness, args) {
751
889
  try {
752
890
  const c = await connectCloud(dir, url, bundle);
753
891
  console.log(`✓ Connected to ${c.url}`);
892
+ // With a user-level install present, the hook ignores a project policy until it is
893
+ // trusted, and would fall back to the user home, which holds no cloud.json: the agent
894
+ // stays governed, but nothing reaches the workspace. Connecting this project is the
895
+ // user's decision to use it, exactly as running `init` here is, so pin it the same way.
896
+ if (!process.env.SCOPEBOND_HOOK_DIR && resolve(dir) !== resolve(userHome()) && existsSync(join(userHome(), "policy.json"))) {
897
+ trustProjectPolicy(dir);
898
+ console.log(`✓ This project's rules are trusted (they override ${userHome()} here)`);
899
+ }
754
900
  // Configure the agent automatically (merges into the existing config), unless the
755
901
  // caller opts out. This removes the "paste this snippet" step. A hook that is already
756
902
  // configured — pinned by `init`, or user-level by `install` — is left as it is:
@@ -770,6 +916,21 @@ async function finishConnect(dir, url, bundle, harness, args) {
770
916
  console.log(`Add this to your ${harnessFileName(harness)}:`);
771
917
  console.log(harnessSnippet(harness));
772
918
  }
919
+ // Session and after-action observations are opt-in through the enrollment: wired only
920
+ // when this workspace granted observations:write and Claude Code is the host. Off is
921
+ // silent here (`status` says why); a granted scope the hook cannot use is worth a line.
922
+ const observing = observationStatus(loadConnection(dir));
923
+ if (observing.state === "on" && harness === "claude" && !args.includes("--no-install")) {
924
+ const placed = harnessScopes("claude", process.cwd());
925
+ const target = placed.local ?? placed.project ?? placed.user;
926
+ const command = target ? configuredHookCommands(target)[0] : undefined;
927
+ if (target && command) {
928
+ wireLifecycleHooks(target, command);
929
+ console.log(`✓ Session and after-action observations enabled in ${target}`);
930
+ }
931
+ }
932
+ else if (observing.state === "unsupported")
933
+ console.log(`Observations: ${observing.reason}`);
773
934
  console.log("");
774
935
  console.log("Run your agent — the first action appears in your workspace within seconds.");
775
936
  }
@@ -804,6 +965,219 @@ async function runFlush() {
804
965
  // Let pending HTTP handles close normally (forced exit can abort on Windows).
805
966
  process.exitCode = status && status.pending > 0 ? 1 : 0;
806
967
  }
968
+ /** `observations`: what the observation emitters are doing, and the local queue. */
969
+ /** `policy load <export.json> [--yes]`: check a policy exported from the workspace and, with
970
+ * `--yes`, make it the active policy here; then acknowledge it (or its refusal) to the workspace. */
971
+ async function runPolicy(args) {
972
+ const [sub, file] = args;
973
+ if (sub !== "load" || !file) {
974
+ console.error(`usage: ${cliCommand("policy load <export.json> [--yes]")}`);
975
+ process.exitCode = 2;
976
+ return;
977
+ }
978
+ const dir = resolveConfigDir(process.cwd());
979
+ const apply = args.includes("--yes");
980
+ const connection = loadConnection(dir);
981
+ const outcome = loadPolicyExport(dir, file, { apply, environmentId: connection?.environment_id });
982
+ let ack;
983
+ if (outcome.state === "rejected") {
984
+ console.error(`refused: ${outcome.message} (${outcome.error})`);
985
+ if (outcome.ack)
986
+ ack = { ...outcome.ack, error: outcome.error };
987
+ process.exitCode = 1;
988
+ }
989
+ else if (outcome.state === "would_load") {
990
+ console.log(`This export checks out (policy ${outcome.facts.policyId} v${outcome.facts.policyVersion}, digest ${outcome.facts.policyDigest.slice(0, 12)}...).`);
991
+ console.log(`Loading it REPLACES the active policy at ${join(dir, "policy.json")} (the old one is kept as policy.previous.json), so the starter protections apply only if the export includes them.`);
992
+ console.log("Run again with --yes to load it.");
993
+ return;
994
+ }
995
+ else {
996
+ console.log(`loaded policy ${outcome.facts.policyId} v${outcome.facts.policyVersion} (digest ${outcome.facts.policyDigest.slice(0, 12)}...) from export ${outcome.facts.exportId}`);
997
+ console.log(` active policy ${outcome.policyPath}${outcome.previous ? ` (previous kept at ${outcome.previous})` : ""}`);
998
+ console.log(` note \`${cliCommand("rules apply")}\` recompiles policy.json from rules.json and would replace it`);
999
+ ack = { exportId: outcome.facts.exportId, policyId: outcome.facts.policyId, policyVersion: outcome.facts.policyVersion, policyDigest: outcome.facts.policyDigest, scopeDigest: outcome.facts.scopeDigest };
1000
+ }
1001
+ if (!ack)
1002
+ return;
1003
+ await sendPolicyAck(dir, ack);
1004
+ }
1005
+ /** Queue a `policy_ack` (loaded or rejected) through the observation outbox and try to deliver it now. */
1006
+ async function sendPolicyAck(dir, ack) {
1007
+ const observed = openObservations(dir, { adapterVersion: hookVersion(), spawnHeartbeat: false });
1008
+ if (!observed.emitter) {
1009
+ console.error(`not acknowledged to the workspace: observations are ${observed.status.state}${"reason" in observed.status ? ` (${observed.status.reason})` : ""}`);
1010
+ return;
1011
+ }
1012
+ try {
1013
+ const queued = observed.emitter.policyAck(ack);
1014
+ await observed.emitter.flush(3000);
1015
+ console.error(queued?.queued ? `queued the ${ack.error ? "rejection" : "load"} acknowledgement for the workspace` : "could not queue the acknowledgement");
1016
+ }
1017
+ finally {
1018
+ observed.emitter.close();
1019
+ }
1020
+ }
1021
+ /** `budget load <export.json> [--yes]`: check an action budget exported from the workspace and, with `--yes`,
1022
+ * make it the budget this machine enforces; then acknowledge it (or its refusal) to the workspace. */
1023
+ async function runBudgetLoad(args) {
1024
+ const file = args.find((a) => !a.startsWith("--"));
1025
+ if (!file) {
1026
+ console.error(`usage: ${cliCommand("budget load <export.json> [--yes]")}`);
1027
+ process.exitCode = 2;
1028
+ return;
1029
+ }
1030
+ const dir = resolveConfigDir(process.cwd());
1031
+ const agentKid = (() => { try {
1032
+ return createSigner({ privateKeyPem: readFileSync(join(dir, "agent.key"), "utf8") }).kid;
1033
+ }
1034
+ catch {
1035
+ return "";
1036
+ } })();
1037
+ const outcome = loadBudgetExport(dir, file, { apply: args.includes("--yes"), agentKid, environmentId: loadConnection(dir)?.environment_id });
1038
+ let ack;
1039
+ if (outcome.state === "rejected") {
1040
+ console.error(`refused: ${outcome.message} (${outcome.error})`);
1041
+ if (outcome.ack && outcome.error !== "expired")
1042
+ ack = { ...outcome.ack, error: outcome.error };
1043
+ process.exitCode = 1;
1044
+ }
1045
+ else if (outcome.state === "would_load") {
1046
+ const p = outcome.facts.policy;
1047
+ for (const w of outcome.warnings)
1048
+ console.error(`warning: ${w}`);
1049
+ console.log(`This export checks out: ${p.mode} budget ${outcome.facts.budgetId} v${outcome.facts.budgetVersion}, ${p.max_dispatch} dispatches in ${p.window_seconds}s for this agent on this installation.`);
1050
+ console.log(`Loading it writes the budget to ${join(dir, "dispatch.json")} as acknowledged, and replaces an older workspace budget for this agent. Run again with --yes to load it.`);
1051
+ return;
1052
+ }
1053
+ else {
1054
+ const p = outcome.facts.policy;
1055
+ for (const w of outcome.warnings)
1056
+ console.error(`warning: ${w}`);
1057
+ console.log(`loaded ${p.mode} budget ${outcome.facts.budgetId} v${outcome.facts.budgetVersion} from export ${outcome.facts.exportId}: ${p.max_dispatch} dispatches in ${p.window_seconds}s`);
1058
+ if (outcome.replaced.length > 0)
1059
+ console.log(` replaced ${outcome.replaced.join(", ")}`);
1060
+ console.log(` valid until ${new Date(outcome.facts.validUntil).toISOString()} (after that an enforced budget denies new dispatch until you load a new export)`);
1061
+ ack = { exportId: outcome.facts.exportId, policyId: outcome.facts.budgetId, policyVersion: outcome.facts.budgetVersion, policyDigest: outcome.facts.policyDigest, scopeDigest: outcome.facts.scopeDigest };
1062
+ }
1063
+ if (!ack)
1064
+ return;
1065
+ await sendPolicyAck(dir, ack);
1066
+ }
1067
+ async function runObservations(args) {
1068
+ const sub = args[0] ?? "status";
1069
+ const dir = resolveConfigDir(process.cwd());
1070
+ if (sub === "heartbeat") {
1071
+ await runHeartbeatLoop(args[1] ?? "");
1072
+ return;
1073
+ }
1074
+ if (sub === "status") {
1075
+ const lines = describeObservations(dir);
1076
+ console.log(`observations: ${lines[0]}`);
1077
+ for (const line of lines.slice(1))
1078
+ console.log(` ${line}`);
1079
+ const file = join(dir, OBSERVATION_DB);
1080
+ if (args.includes("--refused") && existsSync(file)) {
1081
+ const store = new ObservationStore(file);
1082
+ try {
1083
+ for (const row of store.terminal())
1084
+ console.log(` refused #${row.sequence} gen ${row.generation} ${row.kind} ${row.observation_id} ${row.code} ${new Date(row.at).toISOString()}`);
1085
+ }
1086
+ finally {
1087
+ store.close();
1088
+ }
1089
+ }
1090
+ return;
1091
+ }
1092
+ if (sub === "id") {
1093
+ // The opaque id this installation gives a ref, remote or repository, for writing reference
1094
+ // sets. Local only: it reads the binding key and prints; nothing is sent anywhere.
1095
+ const id = keyedIdFor(loadOrCreateBindingKey(dir), args[1] ?? "", args.slice(2));
1096
+ if (!id) {
1097
+ console.error("usage: observations id <ref <name> | remote <url> | ghrepo <owner/name> | repo <workspace-path> | mcp <server> <tool> | mcp-resource <kind> <value> | net-dest <host> <port> | cf <kind> <name> | database <pg|sqlite> <key>>");
1098
+ process.exit(1);
1099
+ }
1100
+ console.log(id);
1101
+ return;
1102
+ }
1103
+ if (sub === "wire" || sub === "unwire") {
1104
+ const scopes = harnessScopes("claude", process.cwd());
1105
+ if (sub === "unwire") {
1106
+ let removed = 0;
1107
+ for (const file of [scopes.project, scopes.local, scopes.user])
1108
+ if (file)
1109
+ removed += unwireLifecycleHooks(file);
1110
+ console.log(`removed ${removed} lifecycle hook entr${removed === 1 ? "y" : "ies"}`);
1111
+ return;
1112
+ }
1113
+ const target = scopes.local ?? scopes.project ?? scopes.user;
1114
+ const command = target ? configuredHookCommands(target)[0] : undefined;
1115
+ if (!target || !command) {
1116
+ console.error("Claude Code is not configured with this hook; run install or init first");
1117
+ process.exit(1);
1118
+ }
1119
+ wireLifecycleHooks(target, command);
1120
+ console.log(`session and after-action hooks wired in ${target}`);
1121
+ return;
1122
+ }
1123
+ const opened = openObservations(dir, { adapterVersion: hookVersion(), spawnHeartbeat: false });
1124
+ if (!opened.emitter) {
1125
+ console.error(`observations are ${opened.status.state}${"reason" in opened.status ? `: ${opened.status.reason}` : ""}`);
1126
+ process.exit(1);
1127
+ }
1128
+ const emitter = opened.emitter;
1129
+ try {
1130
+ if (sub === "flush") {
1131
+ const outcome = await uploadPending(emitter.store, { url: emitter.connection.url, credential: emitter.connection.credential, timeoutMs: 10_000 });
1132
+ const left = emitter.store.pendingSummary().count;
1133
+ console.log(`${outcome.result}: ${outcome.acknowledged} acknowledged, ${outcome.deferred} deferred, ${outcome.rejected} refused; ${left} still pending${outcome.detail ? ` (${outcome.detail})` : ""}`);
1134
+ process.exitCode = left > 0 ? 1 : 0;
1135
+ }
1136
+ else if (sub === "retry") {
1137
+ const state = emitter.store.state();
1138
+ if (state?.capability === "unsupported") {
1139
+ emitter.store.setCapability("active", null);
1140
+ console.log("will try the workspace again");
1141
+ }
1142
+ else
1143
+ console.log("nothing to retry (a stale generation clears when you reconnect)");
1144
+ }
1145
+ else {
1146
+ console.error("usage: observations [status [--refused]|flush|retry|wire|unwire]");
1147
+ process.exit(1);
1148
+ }
1149
+ }
1150
+ finally {
1151
+ emitter.close();
1152
+ }
1153
+ }
1154
+ /** The single heartbeat helper for one active session (started by the hook, never by hand). */
1155
+ async function runHeartbeatLoop(sessionId) {
1156
+ if (!sessionId)
1157
+ return;
1158
+ const dir = resolveConfigDir(process.cwd());
1159
+ const { emitter } = openObservations(dir, { adapterVersion: hookVersion(), spawnHeartbeat: false, flushTimeoutMs: 3000 });
1160
+ if (!emitter)
1161
+ return;
1162
+ const override = Number(process.env.SCOPEBOND_HEARTBEAT_INTERVAL_MS);
1163
+ const interval = Number.isFinite(override) && override >= 100 ? override : HEARTBEAT_INTERVAL_MS;
1164
+ const endsAt = Date.now() + 24 * 60 * 60 * 1000;
1165
+ let last = Date.now();
1166
+ try {
1167
+ for (;;) {
1168
+ const verdict = emitter.heartbeatTick(sessionId, last);
1169
+ await emitter.flush();
1170
+ if (verdict === "stop" || Date.now() > endsAt)
1171
+ break;
1172
+ last = Date.now();
1173
+ await new Promise((resolve) => setTimeout(resolve, interval));
1174
+ }
1175
+ }
1176
+ finally {
1177
+ emitter.store.releaseHeartbeat(sessionId);
1178
+ emitter.close();
1179
+ }
1180
+ }
807
1181
  /** The absolute path to this CLI file, for registering the hook by absolute path. */
808
1182
  function cliPath() {
809
1183
  return fileURLToPath(import.meta.url);
@@ -925,6 +1299,10 @@ function runStatus() {
925
1299
  console.log(` Cursor ${harnessScopeLabel(cursor) || (cursorDetected() ? "detected, not configured" : "not detected")}`);
926
1300
  console.log(` Codex ${codex.project || codex.user ? `${harnessScopeLabel(codex)} — approve once with /hooks` : codexDetected() ? "detected, not configured" : "not detected"}`);
927
1301
  console.log(` cloud workspace ${connected ? "connected" : "not connected (local only)"}`);
1302
+ const observationLines = describeObservations(resolveConfigDir(process.cwd()));
1303
+ console.log(` observations ${observationLines[0]}`);
1304
+ for (const line of observationLines.slice(1))
1305
+ console.log(` ${line}`);
928
1306
  console.log(` local receipts ${existsSync(dbPath) ? `${dbPath} (${describeStore(dbPath)})` : "none yet"}`);
929
1307
  for (const [name, scopes] of [["Claude Code", claude], ["Cursor", cursor], ["Codex", codex]]) {
930
1308
  for (const file of [scopes.project, scopes.local, scopes.user])
@@ -932,10 +1310,78 @@ function runStatus() {
932
1310
  console.log(` ${name}: ${file}`);
933
1311
  }
934
1312
  }
1313
+ /** `capabilities`: the manifest of what this hook can honestly claim, cell by cell.
1314
+ * `--prove` runs the safe fixtures in temp directories (never touching agent settings);
1315
+ * `--save` records the result beside the policy; `--json` prints the manifest. */
1316
+ async function runCapabilities(args) {
1317
+ const cwd = process.cwd();
1318
+ const dir = resolveConfigDir(cwd);
1319
+ const configured = {
1320
+ claude: !!harnessScopeLabel(harnessScopes("claude", cwd)),
1321
+ cursor: !!harnessScopeLabel(harnessScopes("cursor", cwd)),
1322
+ codex: !!harnessScopeLabel(harnessScopes("codex", cwd)),
1323
+ };
1324
+ const version = hookVersion();
1325
+ let proofs = loadProofs(dir);
1326
+ let failed = false;
1327
+ if (args.includes("--prove")) {
1328
+ // When the workspace accepts observations, the fixtures are signed with this machine's
1329
+ // own keys (copied into the temp home) and their receipts are delivered first, so the
1330
+ // proof can name them and the workspace can resolve them. Otherwise a fixture stays local.
1331
+ const observed = openObservations(dir, { adapterVersion: version, spawnHeartbeat: false });
1332
+ const collected = [];
1333
+ const fresh = await runProofFixtures(version, observed.emitter ? { identityDir: dir, collect: collected } : {});
1334
+ const proven = computeManifest({ adapterVersion: version, configured: { claude: true, codex: true, cursor: true }, proofs: fresh });
1335
+ for (const cell of proven.cells) {
1336
+ const proof = fresh[cell.key];
1337
+ if (proof && !proofPassed(proof, cell)) {
1338
+ failed = true;
1339
+ console.error(`fixture failed: ${cell.key} (allow ${proof.safe_allow}, deny ${proof.safe_deny}, signature ${proof.signature}, grouping ${proof.grouping})`);
1340
+ }
1341
+ }
1342
+ if (observed.emitter) {
1343
+ try {
1344
+ const delivered = await deliverProofReceipts(observed.emitter.connection, collected);
1345
+ if (!delivered)
1346
+ console.error("could not deliver the fixture receipts to the workspace; the capability proofs were not sent (run it again when it is reachable)");
1347
+ else {
1348
+ const queued = observed.emitter.capabilityProofs(proven.cells.flatMap((cell) => {
1349
+ // A typed-operation cell has no receipt of its own action type for a proof to name, so no proof is sent for it.
1350
+ if (TYPED_ACTION_TYPES.has(cell.action_type))
1351
+ return [];
1352
+ const proof = cell.state === "unsupported" ? undefined : fresh[cell.key];
1353
+ return proof ? [{ adapterVersion: cell.adapter_version, hostVariant: cell.host_variant, actionType: cell.action_type, phase: cell.event_phase, requiredFields: cell.emitted_required_fields, fixtureVersion: `fixture/${proof.test_vector_digest}`, passed: proofPassed(proof, cell), proofDigests: proof.proof_digests }] : [];
1354
+ }));
1355
+ await observed.emitter.flush(3000);
1356
+ console.error(`queued ${queued} capability proof observation(s) (fixture origin, ${collected.length} fixture receipt(s) delivered)`);
1357
+ }
1358
+ }
1359
+ finally {
1360
+ observed.emitter.close();
1361
+ }
1362
+ }
1363
+ if (args.includes("--save")) {
1364
+ if (!existsSync(dir)) {
1365
+ console.error(`no ${dir} to record into - run \`${cliCommand("init")}\` first.`);
1366
+ process.exit(1);
1367
+ }
1368
+ console.error(`recorded fixture proofs in ${saveProofs(dir, fresh)}`);
1369
+ }
1370
+ proofs = fresh;
1371
+ }
1372
+ const manifest = computeManifest({ adapterVersion: version, configured, proofs });
1373
+ if (args.includes("--json"))
1374
+ console.log(JSON.stringify(manifest, null, 2));
1375
+ else {
1376
+ console.log(renderManifest(manifest));
1377
+ if (args.includes("--prove"))
1378
+ console.log(`\nFixture run ${failed ? "FAILED - see degraded cells" : "passed"}. It used temporary directories only; no agent setting or policy was changed and no key was modified.`);
1379
+ }
1380
+ process.exitCode = failed ? 1 : 0;
1381
+ }
935
1382
  async function runDoctor() {
936
1383
  const problems = [];
937
- const [major, minor] = process.versions.node.split(".").map(Number);
938
- const nodeOk = major > 22 || (major === 22 && minor >= 13);
1384
+ const nodeOk = nodeSupported();
939
1385
  console.log(`Scopebond doctor`);
940
1386
  console.log(` node ${process.versions.node} ${nodeOk ? "ok" : "TOO OLD (need >=22.13)"}`);
941
1387
  if (!nodeOk)
@@ -1158,7 +1604,7 @@ const COMMANDS = [
1158
1604
  "--dry-run prints exactly which files it would touch and changes nothing. Each config",
1159
1605
  "is copied to <file>.scopebond-backup before its first modification.",
1160
1606
  ] },
1161
- { name: "rules", args: "[show|allow|block|protect|unprotect|protect-branch|unprotect-branch|apply] [value]",
1607
+ { name: "rules", args: "[show|allow|block|protect|unprotect|protect-branch|unprotect-branch|protect-remote-database|unprotect-remote-database|apply] [value]",
1162
1608
  summary: "read and change the limits in plain terms",
1163
1609
  detail: [
1164
1610
  "With no arguments, prints what is blocked in plain English — no regular expressions.",
@@ -1170,6 +1616,52 @@ const COMMANDS = [
1170
1616
  " rules apply recompile after editing rules.json by hand",
1171
1617
  ] },
1172
1618
  { name: "status", summary: "what is configured, where, and how big the local log is" },
1619
+ { name: "capabilities", args: "[--prove [--save]] [--json]",
1620
+ summary: "what this hook can honestly claim, per agent host, action and phase",
1621
+ detail: [
1622
+ "Prints the capability manifest: for Claude Code, Codex and Cursor, each action type and",
1623
+ "phase is unsupported, inactive, configured_unverified, degraded or verified_reporting.",
1624
+ "Unsupported stays unsupported. A cell is never verified from a local run alone.",
1625
+ "--prove runs safe fixtures (allow, deny, signature, action group) in temporary",
1626
+ "directories; it does not read or change any agent settings. --save records the result.",
1627
+ ] },
1628
+ { name: "policy", args: "load <export.json> [--yes]",
1629
+ summary: "check a policy exported from your workspace and make it the active policy here",
1630
+ detail: [
1631
+ "Without --yes it only checks the export (its policy hash and scope digest, and that the",
1632
+ "gateway can load it) and says what loading would replace. With --yes the policy is",
1633
+ "written atomically as policy.json, the old one kept as policy.previous.json. If this",
1634
+ "machine is enrolled for observations, the load (or the refusal) is then acknowledged to",
1635
+ "the workspace, echoing the export's digests exactly. An export carries no signature;",
1636
+ "get the file from your workspace.",
1637
+ ] },
1638
+ { name: "observations", args: "[status [--refused]|flush|retry|wire|unwire|id <kind> <value>]",
1639
+ summary: "session, health and action observations sent to your workspace (opt-in)",
1640
+ detail: [
1641
+ `On only when your workspace enrollment grants ${OBSERVATIONS_SCOPE}; otherwise off, and status says why.`,
1642
+ "Local enforcement never depends on it: uploads are best effort and bounded. status shows",
1643
+ "what is pending and what the workspace refused (kept locally, never retried). flush",
1644
+ "sends now; retry re-checks a workspace that lacked the route; wire and unwire add or",
1645
+ "remove the Claude Code session and after-action hook entries.",
1646
+ ] },
1647
+ { name: "budget", args: "init|ack <id>|status|load <export.json>",
1648
+ summary: "per-agent action budgets: how many actions this agent may dispatch in a window",
1649
+ detail: [
1650
+ "load checks a budget exported from your workspace (digests, environment, validity, fail-closed contract) and, with --yes,",
1651
+ "makes it the acknowledged budget here, then acknowledges it to the workspace so its export stops showing as pending.",
1652
+ "init writes the suggested 100 actions per 60 seconds, monitor-only (nothing is limited).",
1653
+ "Enforcing needs the mode set to enforce and an acknowledgement of the exact policy (ack).",
1654
+ "Counters persist across hook processes and restarts. They count dispatched parent actions on",
1655
+ "THIS installation only; a limit shared across installations needs a shared in-path gateway,",
1656
+ "which an independent hook is not, so it refuses to enforce one.",
1657
+ ] },
1658
+ { name: "delegation", args: "add <file>|list|revoke <id>|import <file>",
1659
+ summary: "delegated child scopes: a child may do no more than its parent and never outlives it",
1660
+ detail: [
1661
+ "A session runs under one with SCOPEBOND_DELEGATION=<id>. Every action is checked against it,",
1662
+ "and against every ancestor, for scope, expiry and revocation. revoke takes effect on the next action.",
1663
+ "import adds revoked ids from a file exported from your workspace (add-only).",
1664
+ ] },
1173
1665
  { name: "doctor", summary: "check the setup and whether each configured hook command can start",
1174
1666
  detail: ["Exits non-zero when something is wrong, so it works in a script."] },
1175
1667
  { name: "log", args: "[-n N] [--deny] [--since 7d]",
@@ -1242,7 +1734,20 @@ function printHelp(topic, toStderr = false) {
1242
1734
  out(`Receipts and keys stay in .scopebond/ in this project. Nothing leaves your machine`);
1243
1735
  out(`unless you run \`connect\`. Docs: https://github.com/avouro-com/scopebond`);
1244
1736
  }
1737
+ /** Whether this Node can run the receipt store (`node:sqlite`, 22.13+). */
1738
+ function nodeSupported(version = process.versions.node) {
1739
+ const [major, minor] = version.split(".").map(Number);
1740
+ return major > 22 || (major === 22 && minor >= 13);
1741
+ }
1245
1742
  const [cmd, ...rest] = process.argv.slice(2);
1743
+ // A setup command on an older Node would scaffold and wire the agent, then fail on the
1744
+ // first action with an error about a missing module. Stop before changing anything, and
1745
+ // say what to do. The hook subcommands are left alone: they already fail closed.
1746
+ if (["init", "install", "connect", "login"].includes(cmd ?? "") && !nodeSupported()) {
1747
+ console.error(`Scopebond needs Node.js 22.13 or later; this is Node ${process.versions.node}.`);
1748
+ console.error("Install the current Node.js LTS from https://nodejs.org, open a new terminal, and run the command again.");
1749
+ process.exit(1);
1750
+ }
1246
1751
  if (cmd === "claude") {
1247
1752
  await runClaude();
1248
1753
  }
@@ -1279,6 +1784,31 @@ else if (cmd === "status") {
1279
1784
  else if (cmd === "doctor") {
1280
1785
  await runDoctor();
1281
1786
  }
1787
+ else if (cmd === "capabilities") {
1788
+ await runCapabilities(rest);
1789
+ }
1790
+ else if (cmd === "observations") {
1791
+ await runObservations(rest);
1792
+ }
1793
+ else if (cmd === "policy") {
1794
+ await runPolicy(rest);
1795
+ }
1796
+ else if (cmd === "budget" && rest[0] === "load") {
1797
+ await runBudgetLoad(rest.slice(1));
1798
+ }
1799
+ else if (cmd === "budget" || cmd === "delegation") {
1800
+ // These change what the agent may do, so they are for a person at a terminal.
1801
+ if (rest[0] !== "status" && rest[0] !== "list")
1802
+ requireInteractive(cmd, rest);
1803
+ const dir = resolveConfigDir(process.cwd());
1804
+ const kid = (() => { try {
1805
+ return createSigner({ privateKeyPem: readFileSync(join(dir, "agent.key"), "utf8") }).kid;
1806
+ }
1807
+ catch {
1808
+ return "";
1809
+ } })();
1810
+ process.exit(runDispatchCommand(cmd, rest, dir, kid));
1811
+ }
1282
1812
  else if (cmd === "uninstall") {
1283
1813
  runUninstall(rest);
1284
1814
  }