@scopebond/hook 0.7.0 → 0.8.1

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
@@ -55,10 +55,27 @@ npx @scopebond/hook init --codex # Codex, then approve it once with /hooks
55
55
 
56
56
  `init` scaffolds `.scopebond/` in the current project (a machine signing key, a
57
57
  countersigning key, a starter policy — "protect main and production paths" — and a
58
- `.gitignore` so none of it is committed) and configures `.claude/settings.json`,
59
- `.cursor/hooks.json`, or `.codex/hooks.json`. Then run one safe command in the agent and see the receipt in
60
- `.scopebond/receipts.db`. `init`, `trust` and `uninstall` are meant for a person at a
61
- terminal: in a script or CI, pass `--yes`.
58
+ `.gitignore` so none of it is committed) and wires your agent to the hook. Then run one
59
+ safe command in the agent and see the receipt in `.scopebond/receipts.db`. `init`, `trust`
60
+ and `uninstall` are meant for a person at a terminal: in a script or CI, pass `--yes`.
61
+ `npx @scopebond/hook init --dry-run` shows what it would write, and changes nothing.
62
+
63
+ **Where the hook goes, and why.** `init` pins a copy of the hook on this machine so it
64
+ starts fast on every tool call. That command names paths that exist only here, so it
65
+ never goes into a file your team shares: for Claude Code it goes into
66
+ `.claude/settings.local.json`, which `init` keeps out of git for this clone; for Cursor
67
+ and Codex it goes into `.cursor/hooks.json` or `.codex/hooks.json` only while git does not
68
+ already track that file, and a tracked file gets the portable command instead. A hook
69
+ command that cannot start is treated by the agent as a non-blocking error — the agent
70
+ carries on with no check — so a machine-specific path in a committed file would leave
71
+ every teammate unprotected while the file says otherwise. `doctor` reports that case, and
72
+ running `init` again moves an entry an older version wrote into `.claude/settings.json`.
73
+
74
+ To give everyone who clones the project the hook, use `init --shared`: it writes the
75
+ portable command (`npx -y @scopebond/hook@<version> claude`) to `.claude/settings.json`,
76
+ `.cursor/hooks.json` or `.codex/hooks.json`. It starts on any machine, more slowly, and
77
+ until a teammate runs `init` themselves it blocks their agent's actions with a message
78
+ saying how to set it up.
62
79
 
63
80
  Check what it did with `npx @scopebond/hook status` (which agents are configured, in
64
81
  which scope) and `npx @scopebond/hook doctor` (whether each configured command can
@@ -116,9 +133,21 @@ before they land, make `@scopebond/github-action` a required check on pull reque
116
133
 
117
134
  ## Connect it to your workspace (optional)
118
135
 
119
- To see the receipts in your hosted Scopebond workspace, create a connection from
120
- the portal's **Connect** step (it gives you a one-use enrollment bundle), save it
121
- as `scopebond-enrollment.json`, then:
136
+ To see the receipts in your hosted Scopebond workspace, sign this computer in:
137
+
138
+ ```
139
+ npx @scopebond/hook login https://<your-workspace>
140
+ ```
141
+
142
+ It prints a short code and a link. Someone who manages the workspace opens the link,
143
+ checks that the code matches, and approves it for an environment and agent. The
144
+ command then finishes connecting on its own: nothing is copied or pasted, and the
145
+ code expires after 10 minutes if nobody approves it. Add `--cursor` or `--codex`
146
+ for those agents, or `--no-install` to leave the agent's settings alone.
147
+
148
+ If your workspace does not offer sign-in codes, create a connection from the
149
+ portal's **Connect** step (it gives you a one-use enrollment bundle), save it as
150
+ `scopebond-enrollment.json`, then:
122
151
 
123
152
  ```
124
153
  npx @scopebond/hook connect https://<your-workspace> scopebond-enrollment.json
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AA8fA,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":";AAojBA,6EAA6E;AAC7E,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,EAAE,GAAG,GAAE,MAAmB,GAAG,MAAM,GAAG,IAAI,CAS7F"}
package/dist/cli.js CHANGED
@@ -8,22 +8,38 @@
8
8
  //
9
9
  // Config dir: $SCOPEBOND_HOOK_DIR, else ./.scopebond
10
10
  // Fail-closed: any error denies the action with a repair message.
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
+ }
11
26
  import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
12
- import { join } from "node:path";
13
- import { tmpdir } from "node:os";
27
+ import { join, resolve } from "node:path";
28
+ import { hostname, tmpdir } from "node:os";
14
29
  import { execFileSync } from "node:child_process";
15
30
  import { verifyReceipt } from "@scopebond/gateway";
16
31
  import { openReceiptStore, loadOrCreateAttester } from "@scopebond/gateway/node";
17
32
  import { mapClaudeToolUse, mapCodexToolUse, mapCursorEvent, fillPushBranch } from "./map.js";
18
33
  import { createHookRuntime } from "./runtime.js";
19
- import { scaffold, harnessSnippet, installHarness } from "./init.js";
20
- import { userHome, userHarnessFile, resolveConfigDir, writeHarnessConfig, removeHarnessConfig, cursorDetected, codexDetected, absoluteHookCommand, isHarnessConfigured, purgeHome, harnessScopes, harnessScopeLabel, configuredHookCommands, hookCommandResolves, projectHarnessFile, 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";
21
37
  import { connectCloud, loadConnection } from "./cloud.js";
22
38
  import { compile, defaultRules, describeRules, loadRules, saveRules, rulesPath, pathRuleFor } from "./rules.js";
23
39
  import { createSigner } from "@scopebond/sdk";
24
40
  import { describeAction } from "./explain.js";
25
- import { ensureDurableRuntime } from "./runtime-install.js";
26
- import { cliCommand, hookVersion } from "./version.js";
41
+ import { ensureDurableRuntime, pinnedCliPath, isEphemeralPath } from "./runtime-install.js";
42
+ import { cliCommand, hookCommand, hookVersion } from "./version.js";
27
43
  import { fileURLToPath } from "node:url";
28
44
  /** The current git branch in `cwd` (best-effort). A bare `git push` pushes it, so
29
45
  * the runtime fills it in before evaluating; on failure the ref stays absent and
@@ -129,7 +145,9 @@ async function runPreToolUse(mapper, deny = denyClaude) {
129
145
  let runtime;
130
146
  try {
131
147
  const cwd = input?.cwd ? String(input.cwd) : process.cwd();
132
- runtime = createHookRuntime(runtimePaths(resolveConfigDir(cwd)));
148
+ const dir = resolveConfigDir(cwd);
149
+ runtime = createHookRuntime(runtimePaths(dir));
150
+ useDigestKey(loadOrCreateDigestKey(dir));
133
151
  const decision = await runtime.evaluate(fillPushBranch(mapper(input), currentBranch(cwd)));
134
152
  await runtime.flush();
135
153
  // Close before deciding: the receipt is already committed, and leaving the handle
@@ -183,7 +201,9 @@ async function runCursor() {
183
201
  let runtime;
184
202
  try {
185
203
  const cwd = input?.cwd ? String(input.cwd) : process.cwd();
186
- runtime = createHookRuntime(runtimePaths(resolveConfigDir(cwd)));
204
+ const dir = resolveConfigDir(cwd);
205
+ runtime = createHookRuntime(runtimePaths(dir));
206
+ useDigestKey(loadOrCreateDigestKey(dir));
187
207
  const mapped = fillPushBranch(mapCursorEvent(event, input), currentBranch(cwd));
188
208
  const decision = await runtime.evaluate(mapped);
189
209
  await runtime.flush();
@@ -238,9 +258,34 @@ function requireInteractive(command, args) {
238
258
  process.exit(1);
239
259
  }
240
260
  function runInit(args) {
241
- requireInteractive("init", args);
242
261
  const harness = selectedHarness(args);
243
262
  const dir = configDir();
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);
244
289
  const { agentKid, policyPath, rulesPath: rulesFile } = scaffold(dir, { force: args.includes("--force") });
245
290
  console.log(`Scopebond hook enrolled in ${dir}`);
246
291
  console.log(` machine key ${agentKid}`);
@@ -257,34 +302,49 @@ function runInit(args) {
257
302
  // directly costs ~110 ms. Pin a durable copy and use that, and fall back to `npx`
258
303
  // (slow, but it always starts) when no durable copy can be made. `--npx` forces the
259
304
  // portable form for anyone who wants it.
260
- const pin = args.includes("--npx") ? { cli: null, how: "unavailable" } : ensureDurableRuntime(cliPath(), hookVersion());
305
+ const shared = args.includes("--shared");
306
+ const pin = args.includes("--npx") || shared ? { cli: null, how: "unavailable" } : ensureDurableRuntime(cliPath(), hookVersion());
261
307
  const command = pin.cli ? absoluteHookCommand(pin.cli, harness) : undefined;
262
- // No per-action millisecond claim here: it varies by machine, and this project only
263
- // states numbers it has measured. The measured comparison lives in the changelog.
264
- console.log(` hook runtime ${pin.cli
265
- ? `${pin.cli}\n pinned — no npx resolution per action`
266
- : `npx @scopebond/hook@${hookVersion()} — portable, but re-resolves on every action`}`);
267
- console.log("");
268
308
  // Configure the agent automatically by default (idempotent), so there is no
269
309
  // hand-editing step; --no-install prints the snippet instead.
270
310
  if (!args.includes("--no-install")) {
271
- let file;
311
+ let placed;
272
312
  try {
273
- file = installHarness(harness, process.cwd(), command);
313
+ placed = placeHook(harness, process.cwd(), command, { shared });
274
314
  }
275
315
  catch (error) {
276
316
  console.error(error.message);
277
317
  process.exit(1);
278
318
  }
279
- 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}`);
280
333
  if (harness === "codex")
281
334
  console.log(`\nOne last step: ${codexTrustStep}`);
282
335
  if (harness === "cursor")
283
336
  console.log(`\n${cursorCoverageNote}`);
284
337
  }
285
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.
286
341
  console.log(`Add this to your ${harnessFileName(harness)}:`);
287
- console.log(harnessSnippet(harness, command));
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
+ }
288
348
  if (harness === "cursor")
289
349
  console.log(`\n${cursorCoverageNote}`);
290
350
  }
@@ -626,7 +686,7 @@ async function runTest(args) {
626
686
  }
627
687
  const dir = resolveConfigDir(process.cwd());
628
688
  if (!existsSync(join(dir, "policy.json"))) {
629
- console.error("no policy yet — run `scopebond-hook init` first.");
689
+ console.error(`no policy yet — run \`${cliCommand("init")}\` first.`);
630
690
  process.exit(1);
631
691
  }
632
692
  // Evaluate against the real policy and keys, but a throwaway store, so `test`
@@ -665,7 +725,8 @@ async function runConnect(args) {
665
725
  const bundleArg = positional[1];
666
726
  const harness = selectedHarness(args);
667
727
  if (!url) {
668
- 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);
669
730
  process.exit(1);
670
731
  }
671
732
  const dir = configDir();
@@ -679,16 +740,36 @@ async function runConnect(args) {
679
740
  bundle = readBundleArg(bundleArg, readStdin);
680
741
  }
681
742
  catch {
682
- 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}`);
683
744
  process.exit(1);
684
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) {
685
751
  try {
686
752
  const c = await connectCloud(dir, url, bundle);
687
753
  console.log(`✓ Connected to ${c.url}`);
754
+ // With a user-level install present, the hook ignores a project policy until it is
755
+ // trusted, and would fall back to the user home, which holds no cloud.json: the agent
756
+ // stays governed, but nothing reaches the workspace. Connecting this project is the
757
+ // user's decision to use it, exactly as running `init` here is, so pin it the same way.
758
+ if (!process.env.SCOPEBOND_HOOK_DIR && resolve(dir) !== resolve(userHome()) && existsSync(join(userHome(), "policy.json"))) {
759
+ trustProjectPolicy(dir);
760
+ console.log(`✓ This project's rules are trusted (they override ${userHome()} here)`);
761
+ }
688
762
  // Configure the agent automatically (merges into the existing config), unless the
689
- // caller opts out. This removes the "paste this snippet" step.
690
- if (!args.includes("--no-install")) {
691
- const file = installHarness(harness);
763
+ // caller opts out. This removes the "paste this snippet" step. A hook that is already
764
+ // configured — pinned by `init`, or user-level by `install` — is left as it is:
765
+ // connecting changes where receipts go, not how the hook starts.
766
+ const scopes = harnessScopes(harness, process.cwd());
767
+ const existing = scopes.local ?? scopes.project ?? scopes.user;
768
+ if (existing && !args.includes("--no-install")) {
769
+ console.log(`✓ ${harnessName(harness)} already configured in ${existing}`);
770
+ }
771
+ else if (!args.includes("--no-install")) {
772
+ const { file } = placeHook(harness, process.cwd(), undefined);
692
773
  console.log(`✓ ${harnessName(harness)} configured in ${file}`);
693
774
  if (harness === "codex")
694
775
  console.log(`\nOne last step: ${codexTrustStep}`);
@@ -701,14 +782,26 @@ async function runConnect(args) {
701
782
  console.log("Run your agent — the first action appears in your workspace within seconds.");
702
783
  }
703
784
  catch (error) {
704
- console.error(`connect failed: ${error.message}`);
785
+ const message = error.message;
786
+ console.error(`connect failed: ${message}`);
787
+ if (/enrollment|expired|401|403/i.test(message))
788
+ console.error(enrollmentHelp);
705
789
  process.exit(1);
706
790
  }
707
791
  }
792
+ /** Where an enrollment comes from, for every connect error that means "this one will
793
+ * not work": the bare "invalid enrollment token" told the reader nothing about what to
794
+ * do next. */
795
+ const enrollmentHelp = [
796
+ "An enrollment comes from your Scopebond workspace: open it, choose to connect an agent,",
797
+ "and copy the command it shows — it includes the workspace URL and a fresh enrollment.",
798
+ "Each enrollment is single-use and expires soon after it is created; if this one was",
799
+ "used or has expired, create a new one there.",
800
+ ].join("\n");
708
801
  async function runFlush() {
709
802
  const dir = resolveConfigDir(process.cwd());
710
803
  if (!loadConnection(dir)) {
711
- console.error("not connected to a workspace; run `scopebond-hook connect` first");
804
+ console.error(`not connected to a workspace; run \`${cliCommand("connect <workspace-url> <enrollment>")}\` first`);
712
805
  process.exit(1);
713
806
  }
714
807
  const runtime = createHookRuntime(runtimePaths(dir));
@@ -728,6 +821,14 @@ function cliPath() {
728
821
  * so every project a developer opens is governed without a per-repo `init`. */
729
822
  function runInstall(args) {
730
823
  const dir = userHome();
824
+ // Run through `npx`, this CLI lives in npm's throwaway cache; registering that path
825
+ // would leave a hook that stops starting whenever npm clears it — and a hook that
826
+ // cannot start lets every action through. Pin the durable copy, as `init` does, and
827
+ // fall back to the portable `npx` command when none can be made.
828
+ const commandFor = (h) => {
829
+ const pin = ensureDurableRuntime(cliPath(), hookVersion());
830
+ return pin.cli ? absoluteHookCommand(pin.cli, h) : hookCommand(h);
831
+ };
731
832
  const harnessesFor = () => args.includes("--codex") ? ["codex"]
732
833
  : args.includes("--cursor") ? ["cursor"]
733
834
  : args.includes("--claude") ? ["claude"]
@@ -744,7 +845,8 @@ function runInstall(args) {
744
845
  console.log(`Would ${exists ? "modify" : "create"} ${file}`);
745
846
  if (exists)
746
847
  console.log(` backing it up to ${file}.scopebond-backup`);
747
- console.log(` adding hook ${absoluteHookCommand(cliPath(), h)}`);
848
+ // Previewed without copying anything: the path the real run would pin.
849
+ console.log(` adding hook ${absoluteHookCommand(isEphemeralPath(cliPath()) ? pinnedCliPath(hookVersion()) : cliPath(), h)}`);
748
850
  if (exists && isHarnessConfigured(file))
749
851
  console.log(` (a Scopebond hook is already there; it would be replaced, not duplicated)`);
750
852
  }
@@ -762,7 +864,7 @@ function runInstall(args) {
762
864
  try {
763
865
  const target = userHarnessFile(h);
764
866
  const backup = existsSync(target) ? `${target}.scopebond-backup` : null;
765
- const file = writeHarnessConfig(target, h, absoluteHookCommand(cliPath(), h));
867
+ const file = writeHarnessConfig(target, h, commandFor(h));
766
868
  console.log(`✓ ${harnessName(h)} configured in ${file}`);
767
869
  if (backup && existsSync(backup))
768
870
  console.log(` original kept at ${backup}`);
@@ -822,7 +924,7 @@ function runStatus() {
822
924
  const connected = !!loadConnection(resolveConfigDir(process.cwd()));
823
925
  const dbPath = join(resolveConfigDir(process.cwd()), "receipts.db");
824
926
  console.log(`Scopebond hook ${hookVersion()}`);
825
- console.log(` user home ${home} ${installed ? "(installed)" : "(not installed — run `scopebond install`)"}`);
927
+ console.log(` user home ${home} ${installed ? "(installed)" : `(not installed — run \`${cliCommand("install")}\`)`}`);
826
928
  console.log(` active config ${resolveConfigDir(process.cwd())}`);
827
929
  const ignored = untrustedProjectPolicy(process.cwd());
828
930
  if (ignored)
@@ -833,15 +935,14 @@ function runStatus() {
833
935
  console.log(` cloud workspace ${connected ? "connected" : "not connected (local only)"}`);
834
936
  console.log(` local receipts ${existsSync(dbPath) ? `${dbPath} (${describeStore(dbPath)})` : "none yet"}`);
835
937
  for (const [name, scopes] of [["Claude Code", claude], ["Cursor", cursor], ["Codex", codex]]) {
836
- for (const file of [scopes.project, scopes.user])
938
+ for (const file of [scopes.project, scopes.local, scopes.user])
837
939
  if (file)
838
940
  console.log(` ${name}: ${file}`);
839
941
  }
840
942
  }
841
943
  async function runDoctor() {
842
944
  const problems = [];
843
- const [major, minor] = process.versions.node.split(".").map(Number);
844
- const nodeOk = major > 22 || (major === 22 && minor >= 13);
945
+ const nodeOk = nodeSupported();
845
946
  console.log(`Scopebond doctor`);
846
947
  console.log(` node ${process.versions.node} ${nodeOk ? "ok" : "TOO OLD (need >=22.13)"}`);
847
948
  if (!nodeOk)
@@ -850,7 +951,7 @@ async function runDoctor() {
850
951
  console.log(` cli ${cli} ${existsSync(cli) ? "ok" : "MISSING"}`);
851
952
  const active = resolveConfigDir(process.cwd());
852
953
  const hasPolicy = existsSync(join(active, "policy.json"));
853
- console.log(` active config ${active} ${hasPolicy ? "ok" : "no policy (run `scopebond install` or `init`)"}`);
954
+ console.log(` active config ${active} ${hasPolicy ? "ok" : `no policy (run \`${cliCommand("init")}\` here, or \`${cliCommand("install")}\` once for your user)`}`);
854
955
  if (!hasPolicy)
855
956
  problems.push("no policy found in the active config dir");
856
957
  const ignored = untrustedProjectPolicy(process.cwd());
@@ -871,16 +972,27 @@ async function runDoctor() {
871
972
  }
872
973
  anyHarness = true;
873
974
  console.log(` ${name.padEnd(15)} ${label}`);
874
- for (const file of [scopes.project, scopes.user]) {
975
+ for (const file of [scopes.project, scopes.local, scopes.user]) {
875
976
  if (!file)
876
977
  continue;
978
+ // A project file git shares must not name a path on this machine: it starts here,
979
+ // so the resolve check below passes, but on every teammate's machine it cannot
980
+ // start — and a hook that cannot start is a non-blocking error, so their agent runs
981
+ // unchecked. Only the doctor on the machine that wrote it can see this coming.
982
+ const share = file === scopes.project ? gitShareState(file) : "none";
983
+ const shared = share === "tracked" || share === "untracked";
877
984
  for (const command of configuredHookCommands(file)) {
878
985
  const ok = hookCommandResolves(command);
879
- console.log(` ${ok ? "ok " : "BAD "} ${file}`);
986
+ const leaks = ok && shared && isMachineSpecificCommand(command);
987
+ console.log(` ${ok && !leaks ? "ok " : "BAD "} ${file}`);
880
988
  if (!ok) {
881
989
  console.log(` command cannot start: ${command}`);
882
990
  problems.push(`${name} hook command no longer resolves in ${file} — run \`${cliCommand("init")}\` to repair it`);
883
991
  }
992
+ else if (leaks) {
993
+ console.log(` machine-specific command in a file git shares: ${command}`);
994
+ 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`);
995
+ }
884
996
  }
885
997
  }
886
998
  if (harness === "codex")
@@ -913,8 +1025,8 @@ function runUninstall(args) {
913
1025
  // the install the site actually tells people to run — `uninstall` reported "no
914
1026
  // user-level harness config found" and left the project hook in place.
915
1027
  for (const h of ["claude", "cursor", "codex"]) {
916
- for (const file of [projectHarnessFile(h, process.cwd()), userHarnessFile(h)]) {
917
- if (removeHarnessConfig(file)) {
1028
+ for (const file of [projectHarnessFile(h, process.cwd()), localHarnessFile(h, process.cwd()), userHarnessFile(h)]) {
1029
+ if (file && removeHarnessConfig(file)) {
918
1030
  console.log(`✓ removed the Scopebond hook from ${file}`);
919
1031
  removed++;
920
1032
  }
@@ -947,23 +1059,103 @@ function runTrust(args) {
947
1059
  console.log(`✓ trusted ${join(dir, "policy.json")} (sha256 ${digest.slice(0, 12)}…)`);
948
1060
  console.log("It governs agents in this project until it changes; after any edit, review it and run trust again.");
949
1061
  }
950
- function runLogin() {
951
- console.log("Device-code login is not available yet.");
952
- console.log(`For now, connect with a one-time enrollment from your workspace:`);
953
- console.log(` ${cliCommand("connect <workspace-url> <enrollment>")}`);
954
- process.exit(0);
1062
+ /** `login <workspace-url>` — connect this computer without pasting anything. It asks
1063
+ * the workspace for a short code, shows it with the page to open, and waits while a
1064
+ * person who can manage the workspace approves it there for an environment and agent.
1065
+ * The approval hands back a single-use enrollment, which completes exactly as
1066
+ * `connect` does. Nothing secret is printed: the device code stays in memory. */
1067
+ async function runLogin(args) {
1068
+ const positional = args.filter((a) => !a.startsWith("--"));
1069
+ const harness = selectedHarness(args);
1070
+ let origin;
1071
+ try {
1072
+ const parsed = new URL(positional[0] ?? "");
1073
+ const local = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]";
1074
+ if (parsed.protocol !== "https:" && !(local && parsed.protocol === "http:"))
1075
+ throw new Error("https required");
1076
+ origin = parsed.origin;
1077
+ }
1078
+ catch {
1079
+ console.error(`usage: ${cliCommand("login <workspace-url> [--claude|--cursor|--codex] [--no-install]")}`);
1080
+ console.error("The workspace URL is the address of your Scopebond workspace, for example https://cloud.scopebond.com.");
1081
+ process.exit(1);
1082
+ }
1083
+ const post = async (path, body) => {
1084
+ const response = await fetch(new URL(path, origin), {
1085
+ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
1086
+ redirect: "error", signal: AbortSignal.timeout(15_000),
1087
+ });
1088
+ return { status: response.status, json: await response.json().catch(() => ({})) };
1089
+ };
1090
+ let start;
1091
+ try {
1092
+ start = await post("/v1/device/code", { client_name: hostname(), harness });
1093
+ }
1094
+ catch (error) {
1095
+ console.error(`could not reach ${origin}: ${error.message}`);
1096
+ process.exit(1);
1097
+ }
1098
+ const deviceCode = typeof start.json.device_code === "string" ? start.json.device_code : "";
1099
+ if (start.status !== 200 || !deviceCode) {
1100
+ console.error(`${origin} did not start a login (HTTP ${start.status}). Check the workspace URL, or use ${cliCommand("connect <workspace-url> <enrollment>")}.`);
1101
+ process.exit(1);
1102
+ }
1103
+ const userCode = String(start.json.user_code ?? "");
1104
+ const verify = String(start.json.verification_uri_complete ?? start.json.verification_uri ?? origin);
1105
+ let intervalMs = Math.max(1, Number(start.json.interval ?? 5)) * 1000;
1106
+ const deadline = Date.now() + Math.max(60, Number(start.json.expires_in ?? 600)) * 1000;
1107
+ console.log(`To connect this computer, open:\n\n ${verify}\n\nand check that it shows the code ${userCode}\n`);
1108
+ console.log("Waiting for approval (the code expires in 10 minutes; Ctrl+C to stop)…");
1109
+ const dir = configDir();
1110
+ scaffold(dir, {});
1111
+ while (Date.now() < deadline) {
1112
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
1113
+ let polled;
1114
+ try {
1115
+ polled = await post("/v1/device/token", { device_code: deviceCode });
1116
+ }
1117
+ catch {
1118
+ continue;
1119
+ } // a transient network error: keep waiting until the deadline
1120
+ if (polled.status === 200 && polled.json.enrollment && typeof polled.json.enrollment === "object") {
1121
+ console.log("✓ Approved");
1122
+ await finishConnect(dir, origin, polled.json.enrollment, harness, args);
1123
+ return;
1124
+ }
1125
+ const error = polled.json.error;
1126
+ if (error === "authorization_pending")
1127
+ continue;
1128
+ if (error === "slow_down") {
1129
+ intervalMs += 5_000;
1130
+ continue;
1131
+ }
1132
+ if (error === "access_denied") {
1133
+ console.error("The request was denied in the workspace. Nothing was connected.");
1134
+ process.exit(1);
1135
+ }
1136
+ if (error === "expired_token")
1137
+ break;
1138
+ console.error(`login failed (${String(error ?? `HTTP ${polled.status}`)}). Run the command again for a new code.`);
1139
+ process.exit(1);
1140
+ }
1141
+ console.error("The code expired before it was approved. Run the command again for a new one.");
1142
+ process.exit(1);
955
1143
  }
956
1144
  /** What each command does, its arguments, and one example. The whole help used to be a
957
1145
  * single usage line listing 15 command names, which told a reader nothing about what any
958
1146
  * of them did or what arguments they take. */
959
1147
  const COMMANDS = [
960
- { name: "init", args: "[--cursor|--codex] [--no-install] [--npx] [--force] [--yes]",
1148
+ { name: "init", args: "[--cursor|--codex] [--shared] [--dry-run] [--no-install] [--npx] [--force] [--yes]",
961
1149
  summary: "set this project up: keys, a starter policy, and your agent wired to the hook",
962
1150
  detail: [
963
1151
  "Writes .scopebond/ (machine key, countersigning key, starter policy, .gitignore) and",
964
- "configures .claude/settings.json, .cursor/hooks.json or .codex/hooks.json.",
965
- "Pins a durable copy of this package so the hook starts fast; --npx keeps the portable",
966
- "command instead. --no-install prints the config snippet rather than writing it.",
1152
+ "wires your agent to the hook. It pins a durable copy of this package so the hook starts",
1153
+ "fast, and because that command names paths on this machine it goes where git will not",
1154
+ "share it: .claude/settings.local.json (kept out of git for this clone), or for Cursor",
1155
+ "and Codex their project file only while git does not track it. --shared writes the",
1156
+ "portable npx command to .claude/settings.json, .cursor/hooks.json or .codex/hooks.json",
1157
+ "instead, so everyone who clones the project gets the hook. --dry-run shows what it",
1158
+ "would write. --no-install prints the config snippet rather than writing it.",
967
1159
  "Needs a terminal, or --yes in a script, because it changes what governs your agent.",
968
1160
  ] },
969
1161
  { name: "install", args: "[--claude] [--cursor] [--codex] [--dry-run] [--force] [--yes]",
@@ -1002,6 +1194,12 @@ const COMMANDS = [
1002
1194
  "to a JSONL file beside the database, then removes them. Refuses once the log has been",
1003
1195
  "anchored, because a receipt's position is its anchor leaf index.",
1004
1196
  ] },
1197
+ { name: "login", args: "<workspace-url> [--claude|--cursor|--codex] [--no-install]",
1198
+ summary: "connect this computer to a Scopebond Cloud workspace by approving a short code there",
1199
+ detail: [
1200
+ "Prints a code and a link; someone who manages the workspace opens it, checks the code",
1201
+ "and approves it for an environment and agent. Nothing is copied or pasted.",
1202
+ ] },
1005
1203
  { name: "connect", args: "<workspace-url> <enrollment> [--claude|--cursor|--codex]",
1006
1204
  summary: "send receipts to a Scopebond Cloud workspace as well as keeping them locally" },
1007
1205
  { name: "flush", summary: "deliver any receipts still queued for the workspace now" },
@@ -1051,7 +1249,20 @@ function printHelp(topic, toStderr = false) {
1051
1249
  out(`Receipts and keys stay in .scopebond/ in this project. Nothing leaves your machine`);
1052
1250
  out(`unless you run \`connect\`. Docs: https://github.com/avouro-com/scopebond`);
1053
1251
  }
1252
+ /** Whether this Node can run the receipt store (`node:sqlite`, 22.13+). */
1253
+ function nodeSupported(version = process.versions.node) {
1254
+ const [major, minor] = version.split(".").map(Number);
1255
+ return major > 22 || (major === 22 && minor >= 13);
1256
+ }
1054
1257
  const [cmd, ...rest] = process.argv.slice(2);
1258
+ // A setup command on an older Node would scaffold and wire the agent, then fail on the
1259
+ // first action with an error about a missing module. Stop before changing anything, and
1260
+ // say what to do. The hook subcommands are left alone: they already fail closed.
1261
+ if (["init", "install", "connect", "login"].includes(cmd ?? "") && !nodeSupported()) {
1262
+ console.error(`Scopebond needs Node.js 22.13 or later; this is Node ${process.versions.node}.`);
1263
+ console.error("Install the current Node.js LTS from https://nodejs.org, open a new terminal, and run the command again.");
1264
+ process.exit(1);
1265
+ }
1055
1266
  if (cmd === "claude") {
1056
1267
  await runClaude();
1057
1268
  }
@@ -1092,7 +1303,7 @@ else if (cmd === "uninstall") {
1092
1303
  runUninstall(rest);
1093
1304
  }
1094
1305
  else if (cmd === "login") {
1095
- runLogin();
1306
+ await runLogin(rest);
1096
1307
  }
1097
1308
  else if (cmd === "trust") {
1098
1309
  runTrust(rest);