@scopebond/hook 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -216,6 +216,48 @@ locally and retried if the workspace is unreachable. `npx @scopebond/hook flush`
216
216
  delivers anything still queued — run it on a session-end hook (and set
217
217
  `SCOPEBOND_HOOK_FLUSH_MS=0`) if you want zero per-call latency.
218
218
 
219
+ **Reconnecting.** Run `login` again from any folder: unless that folder has its own project
220
+ setup, it repairs the connection the hook actually uses (usually the user-level one) instead
221
+ of creating a second one; pass `--project` to set up the folder you are in. If the workspace
222
+ no longer accepts this computer's countersigning key (the computer was replaced or
223
+ disconnected there), `login` replaces the key and keeps the old one in
224
+ `.scopebond/retired-keys/`.
225
+
226
+ **Records signed by an earlier key.** The new connection cannot deliver records the earlier
227
+ key signed, so they leave the delivery queue but stay in the local log.
228
+ `npx @scopebond/hook recover` asks the workspace to accept them, waits while an owner or
229
+ admin approves it there, then sends them; the workspace checks each signature against the
230
+ key it kept and labels the records as recovered.
231
+
232
+ ### Rules set by your workspace
233
+
234
+ A connected computer keeps its own rules (`.scopebond/rules.json`) until someone who manages
235
+ the workspace changes a rule for it there. From then on the workspace decides, rule by rule,
236
+ whether a matching action is **blocked** or only **recorded**, and can add entries to this
237
+ computer's lists (protected branches, programs, allowed sites). The workspace never sends
238
+ patterns: the hook compiles its choices with the same compiler as `rules apply`.
239
+
240
+ - **When it applies.** At most once every five minutes, a tool call also checks for changes,
241
+ alongside sending its activity record and capped at about one and a half seconds
242
+ (`SCOPEBOND_POLICY_SYNC_MS`); every other call only reads two small files. A change therefore
243
+ applies within a few minutes of the agent's next action, and a workspace that is slow or
244
+ unreachable never holds up the agent for longer than the cap. The new rules govern from the
245
+ next action. `npx @scopebond/hook policy sync` checks right now.
246
+ - **What is checked.** A rules document must be complete, issued for this computer, match its
247
+ digest and be newer than the one in force; the resulting policy must load. Anything else is
248
+ refused, the rules already in force stay, and the refusal is reported to the workspace.
249
+ - **What is confirmed.** After loading, the hook tells the workspace exactly which version it
250
+ loaded, so the workspace shows *Applied* only for computers that confirmed it.
251
+ - **What the workspace cannot change.** Protection of Scopebond's own settings and of the
252
+ agents' hook settings, the machine key policy, fail-closed handling of anything unreadable,
253
+ and this computer's own opt-ins (`allowed_roots`, `protect_remote_database`).
254
+ - **Going back.** While the workspace sets the rules, `rules` edits and `policy load` are
255
+ refused here. If the connection is revoked, or the workspace stops setting rules for this
256
+ computer, the hook recompiles `policy.json` from `rules.json`: a computer is never left
257
+ without rules. `status` shows which rules are in force and when they were last checked.
258
+
259
+ Set `SCOPEBOND_POLICY_SYNC=off` to stop the five-minute check (the rules in force stay).
260
+
219
261
  ### Session, health and action observations (opt-in)
220
262
 
221
263
  Beyond receipts, the hook can send a second kind of signed record, an *observation*,
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAwpBA,6EAA6E;AAC7E,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,EAAE,GAAG,GAAE,MAAmB,GAAG,MAAM,GAAG,IAAI,CAS7F"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAiqBA,6EAA6E;AAC7E,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,EAAE,GAAG,GAAE,MAAmB,GAAG,MAAM,GAAG,IAAI,CAS7F"}
package/dist/cli.js CHANGED
@@ -35,13 +35,16 @@ import { mapClaudeToolUse, mapCodexToolUse, mapCursorEvent, fillPushBranch } fro
35
35
  import { createHookRuntime } from "./runtime.js";
36
36
  import { useDigestKey, loadOrCreateDigestKey } from "./minimize.js";
37
37
  import { scaffold, harnessSnippet, placeHook } from "./init.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";
38
+ import { userHome, userHarnessFile, resolveConfigDir, writeHarnessConfig, removeHarnessConfig, cursorDetected, codexDetected, absoluteHookCommand, isHarnessConfigured, purgeHome, harnessScopes, harnessScopeLabel, configuredHookCommands, hookCommandResolves, projectHarnessFile, localHarnessFile, gitShareState, isMachineSpecificCommand, trustProjectPolicy, untrustedProjectPolicy, isTrustedProject, wireLifecycleHooks, unwireLifecycleHooks, } from "./install.js";
39
39
  import { openObservations, describeObservations, observationStatus, stopReasonFromClaude, exitFromClaudeFailure, HEARTBEAT_INTERVAL_MS, OBSERVATIONS_SCOPE, } from "./obs-emitter.js";
40
40
  import { OBSERVATION_DB, ObservationStore } from "./obs-store.js";
41
41
  import { loadOrCreateBindingKey } from "./observation.js";
42
42
  import { uploadPending } from "./obs-upload.js";
43
- import { connectCloud, loadConnection } from "./cloud.js";
44
- import { loadPolicyExport } from "./policy-load.js";
43
+ import { connectCloud, ingestUrl, loadConnection } from "./cloud.js";
44
+ import { recoverEarlierReceipts } from "./recover.js";
45
+ import { loadPolicyExport, policyBuilds } from "./policy-load.js";
46
+ import { isManaged, readMeta, MANAGED_DOC_FILE } from "./managed.js";
47
+ import { syncIfDue, syncPolicy } from "./policy-sync.js";
45
48
  import { loadBudgetExport } from "./budget-load.js";
46
49
  import { compile, defaultRules, describeRules, loadRules, saveRules, rulesPath, pathRuleFor } from "./rules.js";
47
50
  import { createSigner } from "@scopebond/sdk";
@@ -215,7 +218,8 @@ async function runPreToolUse(mapper, deny = denyClaude, raw, harness = "claude")
215
218
  useDigestKey(loadOrCreateDigestKey(dir));
216
219
  const decision = await runtime.evaluate([...fillPushBranch(mapper(input), currentBranch(cwd)), ...databaseGuard(dir, cwd, input)], { groupKey: callId(input) });
217
220
  const observer = recordObservations(dir, cwd, input, decision, harness);
218
- await Promise.all([runtime.flush(), observer?.flush() ?? Promise.resolve()]);
221
+ // Workspace rules: at most every five minutes, a capped check alongside the record delivery. It never fails the call.
222
+ await Promise.all([runtime.flush(), observer?.flush() ?? Promise.resolve(), syncIfDue(dir, () => syncOptionsFor(dir))]);
219
223
  observer?.close();
220
224
  // Close before deciding: the receipt is already committed, and leaving the handle
221
225
  // open is what made the write-ahead log grow without bound.
@@ -326,7 +330,7 @@ async function runCursor() {
326
330
  const mapped = fillPushBranch(mapCursorEvent(event, input), currentBranch(cwd));
327
331
  const decision = await runtime.evaluate(mapped, { groupKey: callId(input) });
328
332
  const observer = recordObservations(dir, cwd, input, decision, "cursor");
329
- await Promise.all([runtime.flush(), observer?.flush() ?? Promise.resolve()]);
333
+ await Promise.all([runtime.flush(), observer?.flush() ?? Promise.resolve(), syncIfDue(dir, () => syncOptionsFor(dir))]);
330
334
  observer?.close();
331
335
  // An `afterFileEdit` violation is real and recorded, but the edit has already
332
336
  // landed. Say so rather than letting "blocked" imply it was stopped.
@@ -677,6 +681,11 @@ function runRules(args) {
677
681
  printHelp("rules", true);
678
682
  process.exit(1);
679
683
  }
684
+ if (isManaged(dir)) {
685
+ console.error("The rules on this computer are set by your Scopebond workspace, so they cannot be changed here.");
686
+ console.error("Change them in the workspace (Rules), or disconnect this computer to manage its rules locally again.");
687
+ process.exit(1);
688
+ }
680
689
  // Changing what governs the agent is the same class of action as `init`.
681
690
  requireInteractive("rules", args);
682
691
  const policyPath = join(dir, "policy.json");
@@ -863,11 +872,11 @@ async function runConnect(args) {
863
872
  const bundleArg = positional[1];
864
873
  const harness = selectedHarness(args);
865
874
  if (!url) {
866
- console.error(`usage: ${cliCommand("connect <workspace-url> <enrollment> [--claude|--cursor|--codex] [--no-install]")}`);
875
+ console.error(`usage: ${cliCommand("connect <workspace-url> <enrollment> [--claude|--cursor|--codex] [--no-install] [--project]")}`);
867
876
  console.error(enrollmentHelp);
868
877
  process.exit(1);
869
878
  }
870
- const dir = configDir();
879
+ const dir = connectDir(args);
871
880
  // One command sets everything up: scaffold the key, attester and starter policy if
872
881
  // they do not exist, then enroll and persist the scoped machine credential. The
873
882
  // enrollment can be a file, an inline base64 blob (what the portal hands out) or
@@ -889,6 +898,10 @@ async function finishConnect(dir, url, bundle, harness, args) {
889
898
  try {
890
899
  const c = await connectCloud(dir, url, bundle);
891
900
  console.log(`✓ Connected to ${c.url}`);
901
+ if (c.rotatedFrom)
902
+ console.log(`✓ This computer's earlier key (${c.rotatedFrom}) was no longer accepted, so it was replaced; the old key is kept in ${join(dir, "retired-keys")}`);
903
+ if (c.setAside)
904
+ console.log(` ${c.setAside.toLocaleString()} queued record(s) signed by an earlier key cannot go through this connection. To deliver them, run: ${cliCommand("recover")}`);
892
905
  // With a user-level install present, the hook ignores a project policy until it is
893
906
  // trusted, and would fall back to the user home, which holds no cloud.json: the agent
894
907
  // stays governed, but nothing reaches the workspace. Connecting this project is the
@@ -951,6 +964,43 @@ const enrollmentHelp = [
951
964
  "Each enrollment is single-use and expires soon after it is created; if this one was",
952
965
  "used or has expired, create a new one there.",
953
966
  ].join("\n");
967
+ /** Where `connect` and `login` write. A project someone set up here with `init` (it has a
968
+ * policy) is connected, as before. Otherwise the configuration the hook itself uses from
969
+ * here — usually the user-level install — so reconnecting from any folder repairs the
970
+ * connection that is actually failing instead of creating a second, project-level one
971
+ * beside it. `--project` asks for a new per-project setup explicitly. */
972
+ function connectDir(args) {
973
+ const project = configDir();
974
+ return args.includes("--project") || existsSync(join(project, "policy.json")) ? project : resolveConfigDir(process.cwd());
975
+ }
976
+ /** `recover [--no-wait]`: deliver receipts that an earlier, since-revoked key of this computer
977
+ * signed and the workspace never received, after an owner or admin approves it there. */
978
+ async function runRecover(args) {
979
+ const dir = resolveConfigDir(process.cwd());
980
+ const connection = loadConnection(dir);
981
+ if (!connection) {
982
+ console.error(`not connected to a workspace; run \`${cliCommand("login <workspace-url>")}\` first`);
983
+ process.exit(1);
984
+ }
985
+ console.log(`Recovering from ${dir}`);
986
+ const result = await recoverEarlierReceipts(dir, connection, {
987
+ log: (line) => console.log(line),
988
+ fetch,
989
+ now: Date.now,
990
+ sleep: (ms) => new Promise((done) => setTimeout(done, ms)),
991
+ }, { wait: !args.includes("--no-wait"), waitMs: 15 * 60_000, pollMs: 5_000 });
992
+ if (result.groups) {
993
+ const parts = [
994
+ `recovered ${result.accepted.toLocaleString()}`,
995
+ `already present ${result.duplicates.toLocaleString()}`,
996
+ `refused ${result.rejected.toLocaleString()}`,
997
+ ...(result.pending ? [`waiting for approval ${result.pending.toLocaleString()}`] : []),
998
+ ...(result.skipped ? [`skipped ${result.skipped.toLocaleString()}`] : []),
999
+ ];
1000
+ console.log(`\nDone: ${parts.join(", ")}.`);
1001
+ }
1002
+ process.exitCode = result.failed || result.pending ? 1 : 0;
1003
+ }
954
1004
  async function runFlush() {
955
1005
  const dir = resolveConfigDir(process.cwd());
956
1006
  if (!loadConnection(dir)) {
@@ -970,12 +1020,21 @@ async function runFlush() {
970
1020
  * `--yes`, make it the active policy here; then acknowledge it (or its refusal) to the workspace. */
971
1021
  async function runPolicy(args) {
972
1022
  const [sub, file] = args;
1023
+ if (sub === "sync") {
1024
+ await runPolicySync(args.includes("--background"));
1025
+ return;
1026
+ }
973
1027
  if (sub !== "load" || !file) {
974
- console.error(`usage: ${cliCommand("policy load <export.json> [--yes]")}`);
1028
+ console.error(`usage: ${cliCommand("policy sync")} | ${cliCommand("policy load <export.json> [--yes]")}`);
975
1029
  process.exitCode = 2;
976
1030
  return;
977
1031
  }
978
1032
  const dir = resolveConfigDir(process.cwd());
1033
+ if (isManaged(dir)) {
1034
+ console.error("The rules on this computer are set by your Scopebond workspace; a policy file cannot replace them here.");
1035
+ process.exitCode = 1;
1036
+ return;
1037
+ }
979
1038
  const apply = args.includes("--yes");
980
1039
  const connection = loadConnection(dir);
981
1040
  const outcome = loadPolicyExport(dir, file, { apply, environmentId: connection?.environment_id });
@@ -1002,6 +1061,41 @@ async function runPolicy(args) {
1002
1061
  return;
1003
1062
  await sendPolicyAck(dir, ack);
1004
1063
  }
1064
+ /** What a rules check needs for this config directory. A project policy governs only once trusted, so it is re-pinned after a
1065
+ * write, exactly when `rules apply` would. */
1066
+ function syncOptionsFor(dir) {
1067
+ const home = userHome();
1068
+ const repin = dir !== home && existsSync(join(home, "policy.json")) && isTrustedProject(dir);
1069
+ const agentKid = createSigner({ privateKeyPem: readFileSync(join(dir, "agent.key"), "utf8") }).kid;
1070
+ return { agentKid, hookVersion: hookVersion(), policyBuilds, afterPolicyWrite: repin ? (d) => { trustProjectPolicy(d); } : undefined };
1071
+ }
1072
+ /** `policy sync`: bring this computer's rules in line with its workspace now (the hook also checks every five minutes). */
1073
+ async function runPolicySync(background) {
1074
+ const dir = resolveConfigDir(process.cwd());
1075
+ let outcome;
1076
+ try {
1077
+ outcome = await syncPolicy(dir, syncOptionsFor(dir));
1078
+ }
1079
+ catch (error) {
1080
+ outcome = { state: "unavailable", message: error.message };
1081
+ }
1082
+ if (background)
1083
+ return;
1084
+ const lines = {
1085
+ not_connected: "This computer is not connected to a Scopebond workspace; it uses its own rules.",
1086
+ own_rules: "Your workspace does not set rules for this computer; it uses its own rules.",
1087
+ unchanged: "Up to date with your workspace.",
1088
+ applied: "Updated to your workspace's latest rules.",
1089
+ refused: "Could not apply your workspace's rules; the rules already in force stay.",
1090
+ disconnected: "The workspace connection is no longer valid; this computer now uses its own rules.",
1091
+ unavailable: "Could not reach your workspace; the rules already in force stay.",
1092
+ };
1093
+ console.log(lines[outcome.state]);
1094
+ if (outcome.state === "refused" || outcome.state === "unavailable") {
1095
+ console.log(` ${outcome.message}`);
1096
+ process.exitCode = 1;
1097
+ }
1098
+ }
1005
1099
  /** Queue a `policy_ack` (loaded or rejected) through the observation outbox and try to deliver it now. */
1006
1100
  async function sendPolicyAck(dir, ack) {
1007
1101
  const observed = openObservations(dir, { adapterVersion: hookVersion(), spawnHeartbeat: false });
@@ -1128,7 +1222,7 @@ async function runObservations(args) {
1128
1222
  const emitter = opened.emitter;
1129
1223
  try {
1130
1224
  if (sub === "flush") {
1131
- const outcome = await uploadPending(emitter.store, { url: emitter.connection.url, credential: emitter.connection.credential, timeoutMs: 10_000 });
1225
+ const outcome = await uploadPending(emitter.store, { url: ingestUrl(emitter.connection), credential: emitter.connection.credential, timeoutMs: 10_000 });
1132
1226
  const left = emitter.store.pendingSummary().count;
1133
1227
  console.log(`${outcome.result}: ${outcome.acknowledged} acknowledged, ${outcome.deferred} deferred, ${outcome.rejected} refused; ${left} still pending${outcome.detail ? ` (${outcome.detail})` : ""}`);
1134
1228
  process.exitCode = left > 0 ? 1 : 0;
@@ -1299,6 +1393,16 @@ function runStatus() {
1299
1393
  console.log(` Cursor ${harnessScopeLabel(cursor) || (cursorDetected() ? "detected, not configured" : "not detected")}`);
1300
1394
  console.log(` Codex ${codex.project || codex.user ? `${harnessScopeLabel(codex)} — approve once with /hooks` : codexDetected() ? "detected, not configured" : "not detected"}`);
1301
1395
  console.log(` cloud workspace ${connected ? "connected" : "not connected (local only)"}`);
1396
+ {
1397
+ const activeDir = resolveConfigDir(process.cwd());
1398
+ const meta = readMeta(activeDir);
1399
+ const managed = existsSync(join(activeDir, MANAGED_DOC_FILE));
1400
+ const checked = meta.checked_at ? `, last checked ${meta.checked_at}` : "";
1401
+ console.log(` rules ${managed ? `set by your workspace (version ${meta.revision})${checked}` : `this computer's own (${rulesPath(activeDir)})${connected ? checked : ""}`}`);
1402
+ console.log(` always on: protection of Scopebond's own settings and the agents' hook settings`);
1403
+ if (meta.last_error)
1404
+ console.log(` last problem: ${meta.last_error}`);
1405
+ }
1302
1406
  const observationLines = describeObservations(resolveConfigDir(process.cwd()));
1303
1407
  console.log(` observations ${observationLines[0]}`);
1304
1408
  for (const line of observationLines.slice(1))
@@ -1515,7 +1619,7 @@ async function runLogin(args) {
1515
1619
  origin = parsed.origin;
1516
1620
  }
1517
1621
  catch {
1518
- console.error(`usage: ${cliCommand("login <workspace-url> [--claude|--cursor|--codex] [--no-install]")}`);
1622
+ console.error(`usage: ${cliCommand("login <workspace-url> [--claude|--cursor|--codex] [--no-install] [--project]")}`);
1519
1623
  console.error("The workspace URL is the address of your Scopebond workspace, for example https://cloud.scopebond.com.");
1520
1624
  process.exit(1);
1521
1625
  }
@@ -1545,7 +1649,7 @@ async function runLogin(args) {
1545
1649
  const deadline = Date.now() + Math.max(60, Number(start.json.expires_in ?? 600)) * 1000;
1546
1650
  console.log(`To connect this computer, open:\n\n ${verify}\n\nand check that it shows the code ${userCode}\n`);
1547
1651
  console.log("Waiting for approval (the code expires in 10 minutes; Ctrl+C to stop)…");
1548
- const dir = configDir();
1652
+ const dir = connectDir(args);
1549
1653
  scaffold(dir, {});
1550
1654
  while (Date.now() < deadline) {
1551
1655
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
@@ -1688,6 +1792,12 @@ const COMMANDS = [
1688
1792
  { name: "connect", args: "<workspace-url> <enrollment> [--claude|--cursor|--codex]",
1689
1793
  summary: "send receipts to a Scopebond Cloud workspace as well as keeping them locally" },
1690
1794
  { name: "flush", summary: "deliver any receipts still queued for the workspace now" },
1795
+ { name: "recover", args: "[--no-wait]", summary: "deliver records an earlier, revoked key signed, once the workspace approves",
1796
+ detail: [
1797
+ "When this computer was replaced or disconnected while records were still queued, the",
1798
+ "workspace refuses them from the new connection. This asks the workspace to accept them,",
1799
+ "waits while an owner or admin approves it on Activity, then sends them, labelled Recovered.",
1800
+ ] },
1691
1801
  { name: "trust", args: "[--yes]", summary: "let this project's .scopebond policy govern here (pinned by hash)" },
1692
1802
  { name: "uninstall", args: "[--purge] [--yes]", summary: "remove the hook from your agent config; --purge also deletes the home" },
1693
1803
  { name: "claude", summary: "(internal) decide one Claude Code PreToolUse call, JSON on stdin" },
@@ -1778,6 +1888,9 @@ else if (cmd === "test") {
1778
1888
  else if (cmd === "flush") {
1779
1889
  await runFlush();
1780
1890
  }
1891
+ else if (cmd === "recover") {
1892
+ await runRecover(rest);
1893
+ }
1781
1894
  else if (cmd === "status") {
1782
1895
  runStatus();
1783
1896
  }