@scopebond/hook 0.9.0 → 0.10.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,35 @@ 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
+ ### Rules set by your workspace
220
+
221
+ A connected computer keeps its own rules (`.scopebond/rules.json`) until someone who manages
222
+ the workspace changes a rule for it there. From then on the workspace decides, rule by rule,
223
+ whether a matching action is **blocked** or only **recorded**, and can add entries to this
224
+ computer's lists (protected branches, programs, allowed sites). The workspace never sends
225
+ patterns: the hook compiles its choices with the same compiler as `rules apply`.
226
+
227
+ - **When it applies.** At most once every five minutes, a tool call also checks for changes,
228
+ alongside sending its activity record and capped at about one and a half seconds
229
+ (`SCOPEBOND_POLICY_SYNC_MS`); every other call only reads two small files. A change therefore
230
+ applies within a few minutes of the agent's next action, and a workspace that is slow or
231
+ unreachable never holds up the agent for longer than the cap. The new rules govern from the
232
+ next action. `npx @scopebond/hook policy sync` checks right now.
233
+ - **What is checked.** A rules document must be complete, issued for this computer, match its
234
+ digest and be newer than the one in force; the resulting policy must load. Anything else is
235
+ refused, the rules already in force stay, and the refusal is reported to the workspace.
236
+ - **What is confirmed.** After loading, the hook tells the workspace exactly which version it
237
+ loaded, so the workspace shows *Applied* only for computers that confirmed it.
238
+ - **What the workspace cannot change.** Protection of Scopebond's own settings and of the
239
+ agents' hook settings, the machine key policy, fail-closed handling of anything unreadable,
240
+ and this computer's own opt-ins (`allowed_roots`, `protect_remote_database`).
241
+ - **Going back.** While the workspace sets the rules, `rules` edits and `policy load` are
242
+ refused here. If the connection is revoked, or the workspace stops setting rules for this
243
+ computer, the hook recompiles `policy.json` from `rules.json`: a computer is never left
244
+ without rules. `status` shows which rules are in force and when they were last checked.
245
+
246
+ Set `SCOPEBOND_POLICY_SYNC=off` to stop the five-minute check (the rules in force stay).
247
+
219
248
  ### Session, health and action observations (opt-in)
220
249
 
221
250
  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":";AAgqBA,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,15 @@ 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
43
  import { connectCloud, loadConnection } from "./cloud.js";
44
- import { loadPolicyExport } from "./policy-load.js";
44
+ import { loadPolicyExport, policyBuilds } from "./policy-load.js";
45
+ import { isManaged, readMeta, MANAGED_DOC_FILE } from "./managed.js";
46
+ import { syncIfDue, syncPolicy } from "./policy-sync.js";
45
47
  import { loadBudgetExport } from "./budget-load.js";
46
48
  import { compile, defaultRules, describeRules, loadRules, saveRules, rulesPath, pathRuleFor } from "./rules.js";
47
49
  import { createSigner } from "@scopebond/sdk";
@@ -215,7 +217,8 @@ async function runPreToolUse(mapper, deny = denyClaude, raw, harness = "claude")
215
217
  useDigestKey(loadOrCreateDigestKey(dir));
216
218
  const decision = await runtime.evaluate([...fillPushBranch(mapper(input), currentBranch(cwd)), ...databaseGuard(dir, cwd, input)], { groupKey: callId(input) });
217
219
  const observer = recordObservations(dir, cwd, input, decision, harness);
218
- await Promise.all([runtime.flush(), observer?.flush() ?? Promise.resolve()]);
220
+ // Workspace rules: at most every five minutes, a capped check alongside the record delivery. It never fails the call.
221
+ await Promise.all([runtime.flush(), observer?.flush() ?? Promise.resolve(), syncIfDue(dir, () => syncOptionsFor(dir))]);
219
222
  observer?.close();
220
223
  // Close before deciding: the receipt is already committed, and leaving the handle
221
224
  // open is what made the write-ahead log grow without bound.
@@ -326,7 +329,7 @@ async function runCursor() {
326
329
  const mapped = fillPushBranch(mapCursorEvent(event, input), currentBranch(cwd));
327
330
  const decision = await runtime.evaluate(mapped, { groupKey: callId(input) });
328
331
  const observer = recordObservations(dir, cwd, input, decision, "cursor");
329
- await Promise.all([runtime.flush(), observer?.flush() ?? Promise.resolve()]);
332
+ await Promise.all([runtime.flush(), observer?.flush() ?? Promise.resolve(), syncIfDue(dir, () => syncOptionsFor(dir))]);
330
333
  observer?.close();
331
334
  // An `afterFileEdit` violation is real and recorded, but the edit has already
332
335
  // landed. Say so rather than letting "blocked" imply it was stopped.
@@ -677,6 +680,11 @@ function runRules(args) {
677
680
  printHelp("rules", true);
678
681
  process.exit(1);
679
682
  }
683
+ if (isManaged(dir)) {
684
+ console.error("The rules on this computer are set by your Scopebond workspace, so they cannot be changed here.");
685
+ console.error("Change them in the workspace (Rules), or disconnect this computer to manage its rules locally again.");
686
+ process.exit(1);
687
+ }
680
688
  // Changing what governs the agent is the same class of action as `init`.
681
689
  requireInteractive("rules", args);
682
690
  const policyPath = join(dir, "policy.json");
@@ -970,12 +978,21 @@ async function runFlush() {
970
978
  * `--yes`, make it the active policy here; then acknowledge it (or its refusal) to the workspace. */
971
979
  async function runPolicy(args) {
972
980
  const [sub, file] = args;
981
+ if (sub === "sync") {
982
+ await runPolicySync(args.includes("--background"));
983
+ return;
984
+ }
973
985
  if (sub !== "load" || !file) {
974
- console.error(`usage: ${cliCommand("policy load <export.json> [--yes]")}`);
986
+ console.error(`usage: ${cliCommand("policy sync")} | ${cliCommand("policy load <export.json> [--yes]")}`);
975
987
  process.exitCode = 2;
976
988
  return;
977
989
  }
978
990
  const dir = resolveConfigDir(process.cwd());
991
+ if (isManaged(dir)) {
992
+ console.error("The rules on this computer are set by your Scopebond workspace; a policy file cannot replace them here.");
993
+ process.exitCode = 1;
994
+ return;
995
+ }
979
996
  const apply = args.includes("--yes");
980
997
  const connection = loadConnection(dir);
981
998
  const outcome = loadPolicyExport(dir, file, { apply, environmentId: connection?.environment_id });
@@ -1002,6 +1019,41 @@ async function runPolicy(args) {
1002
1019
  return;
1003
1020
  await sendPolicyAck(dir, ack);
1004
1021
  }
1022
+ /** What a rules check needs for this config directory. A project policy governs only once trusted, so it is re-pinned after a
1023
+ * write, exactly when `rules apply` would. */
1024
+ function syncOptionsFor(dir) {
1025
+ const home = userHome();
1026
+ const repin = dir !== home && existsSync(join(home, "policy.json")) && isTrustedProject(dir);
1027
+ const agentKid = createSigner({ privateKeyPem: readFileSync(join(dir, "agent.key"), "utf8") }).kid;
1028
+ return { agentKid, hookVersion: hookVersion(), policyBuilds, afterPolicyWrite: repin ? (d) => { trustProjectPolicy(d); } : undefined };
1029
+ }
1030
+ /** `policy sync`: bring this computer's rules in line with its workspace now (the hook also checks every five minutes). */
1031
+ async function runPolicySync(background) {
1032
+ const dir = resolveConfigDir(process.cwd());
1033
+ let outcome;
1034
+ try {
1035
+ outcome = await syncPolicy(dir, syncOptionsFor(dir));
1036
+ }
1037
+ catch (error) {
1038
+ outcome = { state: "unavailable", message: error.message };
1039
+ }
1040
+ if (background)
1041
+ return;
1042
+ const lines = {
1043
+ not_connected: "This computer is not connected to a Scopebond workspace; it uses its own rules.",
1044
+ own_rules: "Your workspace does not set rules for this computer; it uses its own rules.",
1045
+ unchanged: "Up to date with your workspace.",
1046
+ applied: "Updated to your workspace's latest rules.",
1047
+ refused: "Could not apply your workspace's rules; the rules already in force stay.",
1048
+ disconnected: "The workspace connection is no longer valid; this computer now uses its own rules.",
1049
+ unavailable: "Could not reach your workspace; the rules already in force stay.",
1050
+ };
1051
+ console.log(lines[outcome.state]);
1052
+ if (outcome.state === "refused" || outcome.state === "unavailable") {
1053
+ console.log(` ${outcome.message}`);
1054
+ process.exitCode = 1;
1055
+ }
1056
+ }
1005
1057
  /** Queue a `policy_ack` (loaded or rejected) through the observation outbox and try to deliver it now. */
1006
1058
  async function sendPolicyAck(dir, ack) {
1007
1059
  const observed = openObservations(dir, { adapterVersion: hookVersion(), spawnHeartbeat: false });
@@ -1299,6 +1351,16 @@ function runStatus() {
1299
1351
  console.log(` Cursor ${harnessScopeLabel(cursor) || (cursorDetected() ? "detected, not configured" : "not detected")}`);
1300
1352
  console.log(` Codex ${codex.project || codex.user ? `${harnessScopeLabel(codex)} — approve once with /hooks` : codexDetected() ? "detected, not configured" : "not detected"}`);
1301
1353
  console.log(` cloud workspace ${connected ? "connected" : "not connected (local only)"}`);
1354
+ {
1355
+ const activeDir = resolveConfigDir(process.cwd());
1356
+ const meta = readMeta(activeDir);
1357
+ const managed = existsSync(join(activeDir, MANAGED_DOC_FILE));
1358
+ const checked = meta.checked_at ? `, last checked ${meta.checked_at}` : "";
1359
+ console.log(` rules ${managed ? `set by your workspace (version ${meta.revision})${checked}` : `this computer's own (${rulesPath(activeDir)})${connected ? checked : ""}`}`);
1360
+ console.log(` always on: protection of Scopebond's own settings and the agents' hook settings`);
1361
+ if (meta.last_error)
1362
+ console.log(` last problem: ${meta.last_error}`);
1363
+ }
1302
1364
  const observationLines = describeObservations(resolveConfigDir(process.cwd()));
1303
1365
  console.log(` observations ${observationLines[0]}`);
1304
1366
  for (const line of observationLines.slice(1))