@scopebond/hook 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +123 -13
  2. package/dist/cli.d.ts +2 -1
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +825 -92
  5. package/dist/cli.js.map +1 -1
  6. package/dist/cloud.d.ts +2 -0
  7. package/dist/cloud.d.ts.map +1 -1
  8. package/dist/cloud.js +1 -1
  9. package/dist/cloud.js.map +1 -1
  10. package/dist/explain.d.ts +44 -0
  11. package/dist/explain.d.ts.map +1 -0
  12. package/dist/explain.js +75 -0
  13. package/dist/explain.js.map +1 -0
  14. package/dist/index.d.ts +11 -4
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +6 -3
  17. package/dist/index.js.map +1 -1
  18. package/dist/init.d.ts +41 -4
  19. package/dist/init.d.ts.map +1 -1
  20. package/dist/init.js +104 -21
  21. package/dist/init.js.map +1 -1
  22. package/dist/install.d.ts +70 -2
  23. package/dist/install.d.ts.map +1 -1
  24. package/dist/install.js +262 -10
  25. package/dist/install.js.map +1 -1
  26. package/dist/map.d.ts +6 -0
  27. package/dist/map.d.ts.map +1 -1
  28. package/dist/map.js +4 -1
  29. package/dist/map.js.map +1 -1
  30. package/dist/minimize.d.ts +11 -3
  31. package/dist/minimize.d.ts.map +1 -1
  32. package/dist/minimize.js +35 -6
  33. package/dist/minimize.js.map +1 -1
  34. package/dist/rules.d.ts +51 -0
  35. package/dist/rules.d.ts.map +1 -0
  36. package/dist/rules.js +216 -0
  37. package/dist/rules.js.map +1 -0
  38. package/dist/runtime-install.d.ts +25 -0
  39. package/dist/runtime-install.d.ts.map +1 -0
  40. package/dist/runtime-install.js +106 -0
  41. package/dist/runtime-install.js.map +1 -0
  42. package/dist/runtime.d.ts +13 -0
  43. package/dist/runtime.d.ts.map +1 -1
  44. package/dist/runtime.js +52 -7
  45. package/dist/runtime.js.map +1 -1
  46. package/dist/version.d.ts +5 -1
  47. package/dist/version.d.ts.map +1 -1
  48. package/dist/version.js +6 -2
  49. package/dist/version.js.map +1 -1
  50. package/package.json +2 -2
package/dist/install.js CHANGED
@@ -5,10 +5,11 @@
5
5
  // user-level install exists, a project-local `.scopebond` is used only if the user
6
6
  // trusted that exact policy (`scopebond trust`, or `init` in the project): a cloned
7
7
  // repository, or an agent inside it, must not be able to swap in its own policy.
8
+ import { execFileSync } from "node:child_process";
8
9
  import { createHash } from "node:crypto";
9
- import { mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from "node:fs";
10
+ import { mkdirSync, writeFileSync, existsSync, readFileSync, rmSync, copyFileSync } from "node:fs";
10
11
  import { homedir } from "node:os";
11
- import { dirname, join, resolve } from "node:path";
12
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
12
13
  /** The user-level Scopebond home. `SCOPEBOND_HOME` overrides it (tests, CI). */
13
14
  export function userHome() {
14
15
  return process.env.SCOPEBOND_HOME ?? join(homedir(), ".scopebond");
@@ -22,6 +23,135 @@ export function userHarnessFile(harness) {
22
23
  return join(homedir(), ".codex", "hooks.json");
23
24
  return join(homedir(), ".claude", "settings.json");
24
25
  }
26
+ /** The project-level agent config file for a harness — what `init` writes, so the
27
+ * hook travels with the repository rather than the machine. */
28
+ export function projectHarnessFile(harness, cwd = process.cwd()) {
29
+ if (harness === "cursor")
30
+ return join(cwd, ".cursor", "hooks.json");
31
+ if (harness === "codex")
32
+ return join(cwd, ".codex", "hooks.json");
33
+ return join(cwd, ".claude", "settings.json");
34
+ }
35
+ /** The personal, per-machine project config for a harness, or null when the harness
36
+ * has none. Claude Code reads `.claude/settings.local.json` beside `settings.json` and
37
+ * merges the hooks of both; it is the place for anything that names a path on this
38
+ * machine. Cursor and Codex have no such file. */
39
+ export function localHarnessFile(harness, cwd = process.cwd()) {
40
+ return harness === "claude" ? join(cwd, ".claude", "settings.local.json") : null;
41
+ }
42
+ export function harnessScopes(harness, cwd = process.cwd()) {
43
+ const project = projectHarnessFile(harness, cwd);
44
+ const local = localHarnessFile(harness, cwd);
45
+ const user = userHarnessFile(harness);
46
+ return {
47
+ project: isHarnessConfigured(project) ? project : null,
48
+ local: local && isHarnessConfigured(local) ? local : null,
49
+ user: isHarnessConfigured(user) ? user : null,
50
+ };
51
+ }
52
+ /** One word for where a harness is wired, for a status line. */
53
+ export function harnessScopeLabel(scopes) {
54
+ const where = [
55
+ scopes.project ? "project" : "",
56
+ scopes.local ? "this machine" : "",
57
+ scopes.user ? "user" : "",
58
+ ].filter(Boolean);
59
+ if (where.length === 0)
60
+ return "";
61
+ if (where.length === 1 && scopes.project)
62
+ return "configured (this project)";
63
+ if (where.length === 1 && scopes.local)
64
+ return "configured (this project, this machine only)";
65
+ if (where.length === 1 && scopes.user)
66
+ return "configured (user-level)";
67
+ return `configured (${where.join(" + ")})`;
68
+ }
69
+ /** Whether a hook command names a path that exists only on this machine — the pinned
70
+ * `"<node>" "<…/cli.js>" claude` form — rather than the portable `npx` form. */
71
+ export function isMachineSpecificCommand(command) {
72
+ if (/(^|\s)npx(\.cmd)?\s/.test(command))
73
+ return false;
74
+ const first = (command.match(/"[^"]+"|\S+/) ?? [""])[0].replace(/^"|"$/g, "");
75
+ return isAbsolute(first) || /^[A-Za-z]:[\\/]/.test(first);
76
+ }
77
+ export function gitShareState(file) {
78
+ const cwd = dirname(file);
79
+ const git = (args) => {
80
+ try {
81
+ mkdirSync(cwd, { recursive: true });
82
+ execFileSync("git", args, { cwd, stdio: "ignore", timeout: 5000 });
83
+ return 0;
84
+ }
85
+ catch (error) {
86
+ const status = error.status;
87
+ return typeof status === "number" ? status : -1;
88
+ }
89
+ };
90
+ if (git(["rev-parse", "--is-inside-work-tree"]) !== 0)
91
+ return "none";
92
+ if (git(["ls-files", "--error-unmatch", "--", file]) === 0)
93
+ return "tracked";
94
+ return git(["check-ignore", "-q", "--", file]) === 0 ? "ignored" : "untracked";
95
+ }
96
+ /** Keep a file out of git for this clone only, through `.git/info/exclude` — never the
97
+ * repository's own `.gitignore`, which is the team's file. Returns true when the file is
98
+ * ignored afterwards. */
99
+ export function excludeFromGit(file) {
100
+ try {
101
+ const cwd = dirname(file);
102
+ mkdirSync(cwd, { recursive: true });
103
+ // The entry is built from git's own view of where this directory sits in the work
104
+ // tree (`--show-prefix`), not from comparing file-system paths: the same directory
105
+ // can be spelled two ways (a Windows 8.3 short name, a symlinked temp dir), and a
106
+ // path computed across the two spellings excludes the wrong file.
107
+ const prefix = execFileSync("git", ["rev-parse", "--show-prefix"], { cwd, encoding: "utf8", timeout: 5000 }).trim();
108
+ const exclude = resolve(cwd, execFileSync("git", ["rev-parse", "--git-path", "info/exclude"], { cwd, encoding: "utf8", timeout: 5000 }).trim());
109
+ const entry = "/" + prefix + basename(file);
110
+ const existing = existsSync(exclude) ? readFileSync(exclude, "utf8") : "";
111
+ if (!existing.split(/\r?\n/).includes(entry)) {
112
+ mkdirSync(dirname(exclude), { recursive: true });
113
+ writeFileSync(exclude, `${existing}${existing && !existing.endsWith("\n") ? "\n" : ""}${entry}\n`);
114
+ }
115
+ return gitShareState(file) === "ignored";
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ }
121
+ /** Remove the Scopebond entries whose command `remove` selects, leaving every other
122
+ * setting alone. Returns the number of entries removed. */
123
+ export function pruneHarnessEntries(file, remove) {
124
+ if (!existsSync(file))
125
+ return 0;
126
+ let config;
127
+ try {
128
+ config = readHarnessConfig(file);
129
+ }
130
+ catch {
131
+ return 0;
132
+ }
133
+ const hooks = isRecord(config.hooks) ? config.hooks : null;
134
+ if (!hooks)
135
+ return 0;
136
+ let removed = 0;
137
+ const commandsOf = (e) => {
138
+ if (!isRecord(e))
139
+ return [];
140
+ const own = isScopebond(e.command) ? [String(e.command)] : [];
141
+ const nested = Array.isArray(e.hooks) ? e.hooks.flatMap(commandsOf) : [];
142
+ return [...own, ...nested];
143
+ };
144
+ for (const [event, value] of Object.entries(hooks)) {
145
+ if (!Array.isArray(value))
146
+ continue;
147
+ const kept = value.filter((e) => !(entryMatches(e) && commandsOf(e).some(remove)));
148
+ removed += value.length - kept.length;
149
+ hooks[event] = kept;
150
+ }
151
+ if (removed > 0)
152
+ writeFileSync(file, JSON.stringify(config, null, 2) + "\n");
153
+ return removed;
154
+ }
25
155
  /** The file in the user home that pins trusted project policies: absolute project
26
156
  * config dir → SHA-256 of its policy.json. It lives under the protected home, so the
27
157
  * governed agent cannot write it. */
@@ -96,11 +226,52 @@ export function untrustedProjectPolicy(payloadCwd) {
96
226
  return dirs.find((d) => existsSync(join(d, "policy.json")) && !isTrustedProject(d)) ?? null;
97
227
  }
98
228
  const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
99
- const isScopebond = (cmd) => typeof cmd === "string" && (/@scopebond\/hook|scopebond-hook(\s|$)/.test(cmd) ||
100
- // the user-level absolute form: `<node> <…/cli.js> claude|cursor|codex`
101
- /cli\.js["']?\s+(claude|cursor|codex)\s*$/.test(cmd) ||
102
- /(^|["\s])scopebond(["'\s]).*\b(claude|cursor|codex)\b/.test(cmd));
103
- const entryMatches = (e) => isRecord(e) && (isScopebond(e.command) || (Array.isArray(e.hooks) && e.hooks.some((h) => isRecord(h) && isScopebond(h.command))));
229
+ /** Whether a harness command string is one of ours. This is the single matcher for
230
+ * every form the installer has ever written, and it is what makes re-running `init`
231
+ * or `install` replace the entry instead of appending a second one:
232
+ * - `npx -y @scopebond/hook@0.6.0 claude` (portable)
233
+ * - `"<node>" "<…/@scopebond/hook/dist/cli.js>" claude` (pinned, POSIX)
234
+ * - `"<node>" "<…\@scopebond\hook\dist\cli.js>" claude` (pinned, Windows)
235
+ * - `scopebond-hook claude` (legacy global binary)
236
+ * The separator class matters: a Windows pinned path spells the scope
237
+ * `@scopebond\hook`, and matching only `@scopebond/hook` let a second `init`
238
+ * install a duplicate hook — which would double-check every tool call.
239
+ *
240
+ * Written with substring and token tests rather than one regular expression: the
241
+ * previous `(^|["\s])scopebond(["'\s]).*\b(claude|cursor|codex)\b` backtracked
242
+ * polynomially on a long run of `\tscopebond\t` (CodeQL js/polynomial-redos). The
243
+ * input is a command out of a config file rather than a request, so this was not
244
+ * reachable by an attacker — but a linear check is also the clearer one. */
245
+ const HARNESS_SUBCOMMANDS = new Set(["claude", "cursor", "codex"]);
246
+ /** Long enough for any real command; a bound keeps the work linear and small. */
247
+ const MAX_COMMAND = 4096;
248
+ const unquote = (token) => token.replace(/^["']+|["']+$/g, "");
249
+ export const isScopebondHookCommand = (cmd) => {
250
+ if (typeof cmd !== "string" || cmd.length === 0 || cmd.length > MAX_COMMAND)
251
+ return false;
252
+ const lower = cmd.toLowerCase();
253
+ // The package itself, in either path spelling (`@scopebond/hook`, `@scopebond\hook`).
254
+ if (lower.includes("@scopebond/hook") || lower.includes("@scopebond\\hook"))
255
+ return true;
256
+ // The legacy global binary, as a whole token.
257
+ if (/(^|[\s"'])scopebond-hook([\s"']|$)/.test(cmd))
258
+ return true;
259
+ // An absolute or bare form whose last argument is one of our harness subcommands:
260
+ // `<node> <…/cli.js> claude`, `scopebond cursor`.
261
+ const tokens = cmd.split(/\s+/).filter(Boolean);
262
+ const last = unquote(tokens[tokens.length - 1] ?? "");
263
+ if (!HARNESS_SUBCOMMANDS.has(last))
264
+ return false;
265
+ return tokens.some((token) => {
266
+ const t = unquote(token).toLowerCase();
267
+ return t.includes("scopebond") || /cli\.(m|c)?js$/.test(t);
268
+ });
269
+ };
270
+ const isScopebond = isScopebondHookCommand;
271
+ /** Whether a harness hook entry (either shape: a bare command, or a group with a
272
+ * nested `hooks` array) is a Scopebond entry. */
273
+ export const harnessEntryMatches = (e) => isRecord(e) && (isScopebond(e.command) || (Array.isArray(e.hooks) && e.hooks.some((h) => isRecord(h) && isScopebond(h.command))));
274
+ const entryMatches = harnessEntryMatches;
104
275
  /** Read an agent's JSON config for merging. A missing file is an empty config; a file
105
276
  * that exists but does not parse as a JSON object is an error — rewriting it would
106
277
  * silently delete the user's other settings, so the installer stops instead. */
@@ -121,10 +292,33 @@ export function readHarnessConfig(file) {
121
292
  throw new Error(`${file} is not a JSON object; fix or move it, then run this again — it was left unchanged`);
122
293
  return parsed;
123
294
  }
295
+ /** Copy an agent config aside before changing it, once. Re-running an installer must not
296
+ * overwrite the backup with the already-modified file, or the original is lost on the
297
+ * second run — so an existing backup is left alone. Returns the backup path when one was
298
+ * written.
299
+ *
300
+ * This exists because these installers rewrite files the user did not create and may not
301
+ * know are involved (`~/.claude/settings.json` holds their theme, plugins and
302
+ * permissions), and the undo was previously "hope the merge was right". */
303
+ export function backupHarnessConfig(file) {
304
+ if (!existsSync(file))
305
+ return null;
306
+ const backup = `${file}.scopebond-backup`;
307
+ if (existsSync(backup))
308
+ return null;
309
+ try {
310
+ copyFileSync(file, backup);
311
+ return backup;
312
+ }
313
+ catch {
314
+ return null;
315
+ }
316
+ }
124
317
  /** Write (idempotently) a harness hook entry pointing at an explicit command string.
125
318
  * Shared by the project installer (npx command) and the user installer (absolute path). */
126
319
  export function writeHarnessConfig(file, harness, command) {
127
320
  const config = readHarnessConfig(file);
321
+ backupHarnessConfig(file);
128
322
  mkdirSync(dirname(file), { recursive: true });
129
323
  const hooks = isRecord(config.hooks) ? config.hooks : (config.hooks = {});
130
324
  if (harness === "cursor") {
@@ -183,10 +377,17 @@ export function cursorDetected() {
183
377
  export function codexDetected() {
184
378
  return existsSync(join(homedir(), ".codex"));
185
379
  }
186
- /** The absolute command a user-level harness entry runs: the current Node executable
187
- * and the absolute path to this CLI, so it does not depend on PATH. */
380
+ /** The absolute command a harness entry runs: the current Node executable and the
381
+ * absolute path to this CLI, so it does not depend on PATH.
382
+ *
383
+ * Both paths are always quoted on Windows, even without a space in them. A harness
384
+ * runs this string through a shell, and that shell may be bash (Git Bash, WSL, a
385
+ * dev container), where an unquoted Windows path loses every backslash and the
386
+ * hook dies with MODULE_NOT_FOUND. Inside double quotes bash leaves a backslash
387
+ * alone unless it precedes $ ` " \ or a newline — none of which occur in a Windows
388
+ * path — and cmd.exe and PowerShell accept quoted paths too. */
188
389
  export function absoluteHookCommand(cliPath, harness) {
189
- const quote = (s) => (/\s/.test(s) ? `"${s}"` : s);
390
+ const quote = (s) => (process.platform === "win32" || /\s/.test(s) ? `"${s}"` : s);
190
391
  return `${quote(process.execPath)} ${quote(cliPath)} ${harness}`;
191
392
  }
192
393
  export function isHarnessConfigured(file) {
@@ -202,6 +403,57 @@ export function isHarnessConfigured(file) {
202
403
  return false;
203
404
  }
204
405
  }
406
+ /** Every Scopebond command string a harness config currently runs. `doctor` uses it to
407
+ * check that a pinned command still resolves: a hook entry that cannot start is worse
408
+ * than none, because a harness can read the failure as "no hook". */
409
+ export function configuredHookCommands(file) {
410
+ if (!existsSync(file))
411
+ return [];
412
+ const found = [];
413
+ const collect = (entry) => {
414
+ if (!isRecord(entry))
415
+ return;
416
+ if (isScopebond(entry.command))
417
+ found.push(String(entry.command));
418
+ if (Array.isArray(entry.hooks))
419
+ for (const h of entry.hooks)
420
+ collect(h);
421
+ };
422
+ try {
423
+ const p = JSON.parse(readFileSync(file, "utf8"));
424
+ if (!isRecord(p) || !isRecord(p.hooks))
425
+ return [];
426
+ for (const list of Object.values(p.hooks))
427
+ if (Array.isArray(list))
428
+ for (const e of list)
429
+ collect(e);
430
+ }
431
+ catch {
432
+ return [];
433
+ }
434
+ return found;
435
+ }
436
+ /** Whether a configured hook command can actually start. An `npx` form always can
437
+ * (npm resolves it); a pinned command names two absolute paths — the Node binary and
438
+ * the CLI — and either can go away: a Node version manager switching versions, or the
439
+ * Scopebond home being cleared. Both are exactly what this check exists to catch. */
440
+ export function hookCommandResolves(command) {
441
+ if (/(^|\s)npx(\.cmd)?\s/.test(command))
442
+ return true;
443
+ // `"<node>" "<cli.js>" claude`: check every token that names a file on disk.
444
+ const tokens = command.match(/"[^"]+"|\S+/g) ?? [];
445
+ let checked = 0;
446
+ for (const raw of tokens) {
447
+ const candidate = raw.replace(/^"|"$/g, "");
448
+ if (!/\.(m|c)?js$|\.exe$/i.test(candidate))
449
+ continue;
450
+ checked += 1;
451
+ if (!existsSync(candidate))
452
+ return false;
453
+ }
454
+ // A bare `node cli.js` form on POSIX has no .exe to check; the .js still counted.
455
+ return checked > 0 || !/[\\/]/.test(command.split(/\s+/)[0] ?? "");
456
+ }
205
457
  /** Remove the user-level home (keys, policy, receipts). */
206
458
  export function purgeHome() {
207
459
  rmSync(userHome(), { recursive: true, force: true });
@@ -1 +1 @@
1
- {"version":3,"file":"install.js","sourceRoot":"","sources":["../src/install.ts"],"names":[],"mappings":"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","sourcesContent":["// SB112 — the user-level installer. One install per developer machine (not per repo):\n// keys and a starter policy live in a user-level home (~/.scopebond, override\n// SCOPEBOND_HOME), and the hook is registered by absolute path in the user-level agent\n// config (~/.claude/settings.json, ~/.cursor/hooks.json, ~/.codex/hooks.json). When a\n// user-level install exists, a project-local `.scopebond` is used only if the user\n// trusted that exact policy (`scopebond trust`, or `init` in the project): a cloned\n// repository, or an agent inside it, must not be able to swap in its own policy.\n\nimport { createHash } from \"node:crypto\";\nimport { mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { dirname, join, resolve } from \"node:path\";\n\nexport type Harness = \"claude\" | \"cursor\" | \"codex\";\n\n/** The user-level Scopebond home. `SCOPEBOND_HOME` overrides it (tests, CI). */\nexport function userHome(): string {\n return process.env.SCOPEBOND_HOME ?? join(homedir(), \".scopebond\");\n}\n\n/** The user-level agent config file for a harness. `HOME`/`USERPROFILE` (via homedir)\n * roots it; SCOPEBOND_HOME does not move the agent's own config. */\nexport function userHarnessFile(harness: Harness): string {\n if (harness === \"cursor\") return join(homedir(), \".cursor\", \"hooks.json\");\n if (harness === \"codex\") return join(homedir(), \".codex\", \"hooks.json\");\n return join(homedir(), \".claude\", \"settings.json\");\n}\n\n/** The file in the user home that pins trusted project policies: absolute project\n * config dir → SHA-256 of its policy.json. It lives under the protected home, so the\n * governed agent cannot write it. */\nexport function trustedProjectsFile(): string {\n return join(userHome(), \"trusted-projects.json\");\n}\n\nfunction policyDigest(dir: string): string | null {\n try { return createHash(\"sha256\").update(readFileSync(join(dir, \"policy.json\"))).digest(\"hex\"); } catch { return null; }\n}\n\nfunction readTrusted(): Record<string, string> {\n try { const p = JSON.parse(readFileSync(trustedProjectsFile(), \"utf8\")); return isRecord(p) ? (p as Record<string, string>) : {}; } catch { return {}; }\n}\n\nconst trustKey = (dir: string): string => resolve(dir).replace(/\\\\/g, \"/\").toLowerCase();\n\n/** Pin the project's current policy as trusted. Editing the policy afterwards un-trusts\n * it until the user trusts it again, so a rewrite by the agent never takes effect. */\nexport function trustProjectPolicy(dir: string): string {\n const digest = policyDigest(dir);\n if (!digest) throw new Error(`no policy at ${join(dir, \"policy.json\")}`);\n const trusted = readTrusted();\n trusted[trustKey(dir)] = digest;\n mkdirSync(userHome(), { recursive: true });\n writeFileSync(trustedProjectsFile(), JSON.stringify(trusted, null, 2) + \"\\n\");\n return digest;\n}\n\n/** Whether the project's policy is exactly the one the user trusted. */\nexport function isTrustedProject(dir: string): boolean {\n const digest = policyDigest(dir);\n return !!digest && readTrusted()[trustKey(dir)] === digest;\n}\n\n/** Resolve the config dir for a hook event, most specific first:\n * explicit override → the payload's project → $CLAUDE_PROJECT_DIR → the user home.\n * A project dir counts only when it has been scaffolded (a policy.json), so a bare\n * working directory never shadows the user-level install — and, once a user-level\n * install exists, only when its policy is trusted (`isTrustedProject`). Otherwise the\n * user's own policy governs: a repository cannot bring a weaker one with it. */\nexport function resolveConfigDir(payloadCwd: string | undefined): string {\n if (process.env.SCOPEBOND_HOOK_DIR) return process.env.SCOPEBOND_HOOK_DIR;\n const candidates: string[] = [];\n if (payloadCwd) candidates.push(join(payloadCwd, \".scopebond\"));\n if (process.env.CLAUDE_PROJECT_DIR) candidates.push(join(process.env.CLAUDE_PROJECT_DIR, \".scopebond\"));\n const home = userHome();\n const homeInstalled = existsSync(join(home, \"policy.json\"));\n for (const dir of candidates) {\n if (existsSync(join(dir, \"policy.json\")) && (!homeInstalled || isTrustedProject(dir))) return dir;\n }\n if (homeInstalled) return home;\n // Nothing scaffolded yet: prefer the payload project, else the user home.\n return candidates[0] ?? home;\n}\n\n/** A project policy present but ignored because it is not trusted (for status/doctor). */\nexport function untrustedProjectPolicy(payloadCwd: string | undefined): string | null {\n if (process.env.SCOPEBOND_HOOK_DIR || !existsSync(join(userHome(), \"policy.json\"))) return null;\n const dirs = [payloadCwd, process.env.CLAUDE_PROJECT_DIR].filter(Boolean).map((d) => join(d as string, \".scopebond\"));\n return dirs.find((d) => existsSync(join(d, \"policy.json\")) && !isTrustedProject(d)) ?? null;\n}\n\nconst isRecord = (v: unknown): v is Record<string, unknown> => typeof v === \"object\" && v !== null && !Array.isArray(v);\nconst isScopebond = (cmd: unknown): boolean =>\n typeof cmd === \"string\" && (\n /@scopebond\\/hook|scopebond-hook(\\s|$)/.test(cmd) ||\n // the user-level absolute form: `<node> <…/cli.js> claude|cursor|codex`\n /cli\\.js[\"']?\\s+(claude|cursor|codex)\\s*$/.test(cmd) ||\n /(^|[\"\\s])scopebond([\"'\\s]).*\\b(claude|cursor|codex)\\b/.test(cmd)\n );\nconst entryMatches = (e: unknown): boolean =>\n isRecord(e) && (isScopebond(e.command) || (Array.isArray(e.hooks) && e.hooks.some((h) => isRecord(h) && isScopebond(h.command))));\n\n/** Read an agent's JSON config for merging. A missing file is an empty config; a file\n * that exists but does not parse as a JSON object is an error — rewriting it would\n * silently delete the user's other settings, so the installer stops instead. */\nexport function readHarnessConfig(file: string): Record<string, unknown> {\n if (!existsSync(file)) return {};\n const text = readFileSync(file, \"utf8\");\n if (!text.trim()) return {};\n let parsed: unknown;\n try { parsed = JSON.parse(text); } catch (error) {\n throw new Error(`${file} is not valid JSON (${(error as Error).message}); fix or move it, then run this again — it was left unchanged`);\n }\n if (!isRecord(parsed)) throw new Error(`${file} is not a JSON object; fix or move it, then run this again — it was left unchanged`);\n return parsed;\n}\n\n/** Write (idempotently) a harness hook entry pointing at an explicit command string.\n * Shared by the project installer (npx command) and the user installer (absolute path). */\nexport function writeHarnessConfig(file: string, harness: Harness, command: string): string {\n const config = readHarnessConfig(file);\n mkdirSync(dirname(file), { recursive: true });\n const hooks = isRecord(config.hooks) ? config.hooks : (config.hooks = {});\n if (harness === \"cursor\") {\n config.version = config.version ?? 1;\n for (const event of [\"beforeShellExecution\", \"beforeMCPExecution\", \"beforeReadFile\", \"afterFileEdit\"]) {\n const list = Array.isArray((hooks as Record<string, unknown>)[event]) ? (hooks as Record<string, unknown[]>)[event] : ((hooks as Record<string, unknown[]>)[event] = []);\n const at = list.findIndex(entryMatches);\n const entry = { command: command.replace(/\\bclaude$/, \"cursor\") };\n if (at >= 0) list[at] = entry; else list.push(entry);\n }\n } else {\n const list = Array.isArray((hooks as Record<string, unknown>).PreToolUse) ? (hooks as Record<string, unknown[]>).PreToolUse : ((hooks as Record<string, unknown[]>).PreToolUse = []);\n const at = list.findIndex(entryMatches);\n // Codex matchers are regular expressions; omitting the matcher means every tool.\n // A literal \"*\" is not a valid regular expression and causes Codex to skip the group.\n const entry = harness === \"codex\"\n ? { hooks: [{ type: \"command\", command, timeout: 30, statusMessage: \"Checking this action with Scopebond\" }] }\n : { matcher: \"*\", hooks: [{ type: \"command\", command }] };\n if (at >= 0) list[at] = entry; else list.push(entry);\n }\n writeFileSync(file, JSON.stringify(config, null, 2) + \"\\n\");\n return file;\n}\n\n/** Remove any Scopebond hook entry from a user-level harness config, leaving the rest\n * intact. Returns true if the file existed. */\nexport function removeHarnessConfig(file: string): boolean {\n if (!existsSync(file)) return false;\n let config: Record<string, unknown>;\n try { config = readHarnessConfig(file); } catch { return true; } // unreadable: leave it untouched\n const hooks = isRecord(config.hooks) ? config.hooks : {};\n for (const [event, value] of Object.entries(hooks)) {\n if (Array.isArray(value)) (hooks as Record<string, unknown[]>)[event] = value.filter((e) => !entryMatches(e));\n }\n writeFileSync(file, JSON.stringify(config, null, 2) + \"\\n\");\n return true;\n}\n\n/** Detect a Cursor install by its user config directory. */\nexport function cursorDetected(): boolean {\n return existsSync(join(homedir(), \".cursor\"));\n}\n\n/** Detect a Codex install by its user config directory. */\nexport function codexDetected(): boolean {\n return existsSync(join(homedir(), \".codex\"));\n}\n\n/** The absolute command a user-level harness entry runs: the current Node executable\n * and the absolute path to this CLI, so it does not depend on PATH. */\nexport function absoluteHookCommand(cliPath: string, harness: Harness): string {\n const quote = (s: string) => (/\\s/.test(s) ? `\"${s}\"` : s);\n return `${quote(process.execPath)} ${quote(cliPath)} ${harness}`;\n}\n\nexport function isHarnessConfigured(file: string): boolean {\n if (!existsSync(file)) return false;\n try {\n const p = JSON.parse(readFileSync(file, \"utf8\"));\n if (!isRecord(p) || !isRecord(p.hooks)) return false;\n return Object.values(p.hooks).some((v) => Array.isArray(v) && v.some(entryMatches));\n } catch { return false; }\n}\n\n/** Remove the user-level home (keys, policy, receipts). */\nexport function purgeHome(): void {\n rmSync(userHome(), { recursive: true, force: true });\n}\n"]}
1
+ {"version":3,"file":"install.js","sourceRoot":"","sources":["../src/install.ts"],"names":[],"mappings":"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","sourcesContent":["// SB112 — the user-level installer. One install per developer machine (not per repo):\n// keys and a starter policy live in a user-level home (~/.scopebond, override\n// SCOPEBOND_HOME), and the hook is registered by absolute path in the user-level agent\n// config (~/.claude/settings.json, ~/.cursor/hooks.json, ~/.codex/hooks.json). When a\n// user-level install exists, a project-local `.scopebond` is used only if the user\n// trusted that exact policy (`scopebond trust`, or `init` in the project): a cloned\n// repository, or an agent inside it, must not be able to swap in its own policy.\n\nimport { execFileSync } from \"node:child_process\";\nimport { createHash } from \"node:crypto\";\nimport { mkdirSync, writeFileSync, existsSync, readFileSync, rmSync, copyFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { basename, dirname, isAbsolute, join, resolve } from \"node:path\";\n\nexport type Harness = \"claude\" | \"cursor\" | \"codex\";\n\n/** The user-level Scopebond home. `SCOPEBOND_HOME` overrides it (tests, CI). */\nexport function userHome(): string {\n return process.env.SCOPEBOND_HOME ?? join(homedir(), \".scopebond\");\n}\n\n/** The user-level agent config file for a harness. `HOME`/`USERPROFILE` (via homedir)\n * roots it; SCOPEBOND_HOME does not move the agent's own config. */\nexport function userHarnessFile(harness: Harness): string {\n if (harness === \"cursor\") return join(homedir(), \".cursor\", \"hooks.json\");\n if (harness === \"codex\") return join(homedir(), \".codex\", \"hooks.json\");\n return join(homedir(), \".claude\", \"settings.json\");\n}\n\n/** The project-level agent config file for a harness — what `init` writes, so the\n * hook travels with the repository rather than the machine. */\nexport function projectHarnessFile(harness: Harness, cwd: string = process.cwd()): string {\n if (harness === \"cursor\") return join(cwd, \".cursor\", \"hooks.json\");\n if (harness === \"codex\") return join(cwd, \".codex\", \"hooks.json\");\n return join(cwd, \".claude\", \"settings.json\");\n}\n\n/** The personal, per-machine project config for a harness, or null when the harness\n * has none. Claude Code reads `.claude/settings.local.json` beside `settings.json` and\n * merges the hooks of both; it is the place for anything that names a path on this\n * machine. Cursor and Codex have no such file. */\nexport function localHarnessFile(harness: Harness, cwd: string = process.cwd()): string | null {\n return harness === \"claude\" ? join(cwd, \".claude\", \"settings.local.json\") : null;\n}\n\n/** Which config files actually carry a Scopebond hook for this harness. There are three\n * places it can live — the shared project config, the personal project config (Claude\n * Code's `settings.local.json`, where `init` pins the fast command) and the user config\n * `install` writes — and `status`/`doctor` must look at all of them: checking only the\n * user one told everyone who ran `init` that their install had failed. */\nexport interface HarnessScopes { project: string | null; local: string | null; user: string | null }\n\nexport function harnessScopes(harness: Harness, cwd: string = process.cwd()): HarnessScopes {\n const project = projectHarnessFile(harness, cwd);\n const local = localHarnessFile(harness, cwd);\n const user = userHarnessFile(harness);\n return {\n project: isHarnessConfigured(project) ? project : null,\n local: local && isHarnessConfigured(local) ? local : null,\n user: isHarnessConfigured(user) ? user : null,\n };\n}\n\n/** One word for where a harness is wired, for a status line. */\nexport function harnessScopeLabel(scopes: HarnessScopes): string {\n const where = [\n scopes.project ? \"project\" : \"\",\n scopes.local ? \"this machine\" : \"\",\n scopes.user ? \"user\" : \"\",\n ].filter(Boolean);\n if (where.length === 0) return \"\";\n if (where.length === 1 && scopes.project) return \"configured (this project)\";\n if (where.length === 1 && scopes.local) return \"configured (this project, this machine only)\";\n if (where.length === 1 && scopes.user) return \"configured (user-level)\";\n return `configured (${where.join(\" + \")})`;\n}\n\n/** Whether a hook command names a path that exists only on this machine — the pinned\n * `\"<node>\" \"<…/cli.js>\" claude` form — rather than the portable `npx` form. */\nexport function isMachineSpecificCommand(command: string): boolean {\n if (/(^|\\s)npx(\\.cmd)?\\s/.test(command)) return false;\n const first = (command.match(/\"[^\"]+\"|\\S+/) ?? [\"\"])[0].replace(/^\"|\"$/g, \"\");\n return isAbsolute(first) || /^[A-Za-z]:[\\\\/]/.test(first);\n}\n\n/** How git treats a config file: committed (`tracked`), kept out (`ignored`), neither\n * yet (`untracked`, so a `git add .` would commit it), or not in a repository / no git\n * on this machine (`none`). A file git shares must never carry a machine-specific\n * command: on a teammate's machine the path does not exist, the command cannot start,\n * and Claude Code, Cursor and Codex all treat a hook that cannot start as a non-blocking\n * error — the agent runs with no check at all. */\nexport type GitShare = \"tracked\" | \"ignored\" | \"untracked\" | \"none\";\n\nexport function gitShareState(file: string): GitShare {\n const cwd = dirname(file);\n const git = (args: string[]): number => {\n try {\n mkdirSync(cwd, { recursive: true });\n execFileSync(\"git\", args, { cwd, stdio: \"ignore\", timeout: 5000 });\n return 0;\n } catch (error) {\n const status = (error as { status?: number | null }).status;\n return typeof status === \"number\" ? status : -1;\n }\n };\n if (git([\"rev-parse\", \"--is-inside-work-tree\"]) !== 0) return \"none\";\n if (git([\"ls-files\", \"--error-unmatch\", \"--\", file]) === 0) return \"tracked\";\n return git([\"check-ignore\", \"-q\", \"--\", file]) === 0 ? \"ignored\" : \"untracked\";\n}\n\n/** Keep a file out of git for this clone only, through `.git/info/exclude` — never the\n * repository's own `.gitignore`, which is the team's file. Returns true when the file is\n * ignored afterwards. */\nexport function excludeFromGit(file: string): boolean {\n try {\n const cwd = dirname(file);\n mkdirSync(cwd, { recursive: true });\n // The entry is built from git's own view of where this directory sits in the work\n // tree (`--show-prefix`), not from comparing file-system paths: the same directory\n // can be spelled two ways (a Windows 8.3 short name, a symlinked temp dir), and a\n // path computed across the two spellings excludes the wrong file.\n const prefix = execFileSync(\"git\", [\"rev-parse\", \"--show-prefix\"], { cwd, encoding: \"utf8\", timeout: 5000 }).trim();\n const exclude = resolve(cwd, execFileSync(\"git\", [\"rev-parse\", \"--git-path\", \"info/exclude\"], { cwd, encoding: \"utf8\", timeout: 5000 }).trim());\n const entry = \"/\" + prefix + basename(file);\n const existing = existsSync(exclude) ? readFileSync(exclude, \"utf8\") : \"\";\n if (!existing.split(/\\r?\\n/).includes(entry)) {\n mkdirSync(dirname(exclude), { recursive: true });\n writeFileSync(exclude, `${existing}${existing && !existing.endsWith(\"\\n\") ? \"\\n\" : \"\"}${entry}\\n`);\n }\n return gitShareState(file) === \"ignored\";\n } catch { return false; }\n}\n\n/** Remove the Scopebond entries whose command `remove` selects, leaving every other\n * setting alone. Returns the number of entries removed. */\nexport function pruneHarnessEntries(file: string, remove: (command: string) => boolean): number {\n if (!existsSync(file)) return 0;\n let config: Record<string, unknown>;\n try { config = readHarnessConfig(file); } catch { return 0; }\n const hooks = isRecord(config.hooks) ? config.hooks : null;\n if (!hooks) return 0;\n let removed = 0;\n const commandsOf = (e: unknown): string[] => {\n if (!isRecord(e)) return [];\n const own = isScopebond(e.command) ? [String(e.command)] : [];\n const nested = Array.isArray(e.hooks) ? e.hooks.flatMap(commandsOf) : [];\n return [...own, ...nested];\n };\n for (const [event, value] of Object.entries(hooks)) {\n if (!Array.isArray(value)) continue;\n const kept = value.filter((e) => !(entryMatches(e) && commandsOf(e).some(remove)));\n removed += value.length - kept.length;\n (hooks as Record<string, unknown[]>)[event] = kept;\n }\n if (removed > 0) writeFileSync(file, JSON.stringify(config, null, 2) + \"\\n\");\n return removed;\n}\n\n/** The file in the user home that pins trusted project policies: absolute project\n * config dir → SHA-256 of its policy.json. It lives under the protected home, so the\n * governed agent cannot write it. */\nexport function trustedProjectsFile(): string {\n return join(userHome(), \"trusted-projects.json\");\n}\n\nfunction policyDigest(dir: string): string | null {\n try { return createHash(\"sha256\").update(readFileSync(join(dir, \"policy.json\"))).digest(\"hex\"); } catch { return null; }\n}\n\nfunction readTrusted(): Record<string, string> {\n try { const p = JSON.parse(readFileSync(trustedProjectsFile(), \"utf8\")); return isRecord(p) ? (p as Record<string, string>) : {}; } catch { return {}; }\n}\n\nconst trustKey = (dir: string): string => resolve(dir).replace(/\\\\/g, \"/\").toLowerCase();\n\n/** Pin the project's current policy as trusted. Editing the policy afterwards un-trusts\n * it until the user trusts it again, so a rewrite by the agent never takes effect. */\nexport function trustProjectPolicy(dir: string): string {\n const digest = policyDigest(dir);\n if (!digest) throw new Error(`no policy at ${join(dir, \"policy.json\")}`);\n const trusted = readTrusted();\n trusted[trustKey(dir)] = digest;\n mkdirSync(userHome(), { recursive: true });\n writeFileSync(trustedProjectsFile(), JSON.stringify(trusted, null, 2) + \"\\n\");\n return digest;\n}\n\n/** Whether the project's policy is exactly the one the user trusted. */\nexport function isTrustedProject(dir: string): boolean {\n const digest = policyDigest(dir);\n return !!digest && readTrusted()[trustKey(dir)] === digest;\n}\n\n/** Resolve the config dir for a hook event, most specific first:\n * explicit override → the payload's project → $CLAUDE_PROJECT_DIR → the user home.\n * A project dir counts only when it has been scaffolded (a policy.json), so a bare\n * working directory never shadows the user-level install — and, once a user-level\n * install exists, only when its policy is trusted (`isTrustedProject`). Otherwise the\n * user's own policy governs: a repository cannot bring a weaker one with it. */\nexport function resolveConfigDir(payloadCwd: string | undefined): string {\n if (process.env.SCOPEBOND_HOOK_DIR) return process.env.SCOPEBOND_HOOK_DIR;\n const candidates: string[] = [];\n if (payloadCwd) candidates.push(join(payloadCwd, \".scopebond\"));\n if (process.env.CLAUDE_PROJECT_DIR) candidates.push(join(process.env.CLAUDE_PROJECT_DIR, \".scopebond\"));\n const home = userHome();\n const homeInstalled = existsSync(join(home, \"policy.json\"));\n for (const dir of candidates) {\n if (existsSync(join(dir, \"policy.json\")) && (!homeInstalled || isTrustedProject(dir))) return dir;\n }\n if (homeInstalled) return home;\n // Nothing scaffolded yet: prefer the payload project, else the user home.\n return candidates[0] ?? home;\n}\n\n/** A project policy present but ignored because it is not trusted (for status/doctor). */\nexport function untrustedProjectPolicy(payloadCwd: string | undefined): string | null {\n if (process.env.SCOPEBOND_HOOK_DIR || !existsSync(join(userHome(), \"policy.json\"))) return null;\n const dirs = [payloadCwd, process.env.CLAUDE_PROJECT_DIR].filter(Boolean).map((d) => join(d as string, \".scopebond\"));\n return dirs.find((d) => existsSync(join(d, \"policy.json\")) && !isTrustedProject(d)) ?? null;\n}\n\nconst isRecord = (v: unknown): v is Record<string, unknown> => typeof v === \"object\" && v !== null && !Array.isArray(v);\n\n/** Whether a harness command string is one of ours. This is the single matcher for\n * every form the installer has ever written, and it is what makes re-running `init`\n * or `install` replace the entry instead of appending a second one:\n * - `npx -y @scopebond/hook@0.6.0 claude` (portable)\n * - `\"<node>\" \"<…/@scopebond/hook/dist/cli.js>\" claude` (pinned, POSIX)\n * - `\"<node>\" \"<…\\@scopebond\\hook\\dist\\cli.js>\" claude` (pinned, Windows)\n * - `scopebond-hook claude` (legacy global binary)\n * The separator class matters: a Windows pinned path spells the scope\n * `@scopebond\\hook`, and matching only `@scopebond/hook` let a second `init`\n * install a duplicate hook — which would double-check every tool call.\n *\n * Written with substring and token tests rather than one regular expression: the\n * previous `(^|[\"\\s])scopebond([\"'\\s]).*\\b(claude|cursor|codex)\\b` backtracked\n * polynomially on a long run of `\\tscopebond\\t` (CodeQL js/polynomial-redos). The\n * input is a command out of a config file rather than a request, so this was not\n * reachable by an attacker — but a linear check is also the clearer one. */\nconst HARNESS_SUBCOMMANDS = new Set([\"claude\", \"cursor\", \"codex\"]);\n/** Long enough for any real command; a bound keeps the work linear and small. */\nconst MAX_COMMAND = 4096;\nconst unquote = (token: string): string => token.replace(/^[\"']+|[\"']+$/g, \"\");\n\nexport const isScopebondHookCommand = (cmd: unknown): boolean => {\n if (typeof cmd !== \"string\" || cmd.length === 0 || cmd.length > MAX_COMMAND) return false;\n const lower = cmd.toLowerCase();\n // The package itself, in either path spelling (`@scopebond/hook`, `@scopebond\\hook`).\n if (lower.includes(\"@scopebond/hook\") || lower.includes(\"@scopebond\\\\hook\")) return true;\n // The legacy global binary, as a whole token.\n if (/(^|[\\s\"'])scopebond-hook([\\s\"']|$)/.test(cmd)) return true;\n // An absolute or bare form whose last argument is one of our harness subcommands:\n // `<node> <…/cli.js> claude`, `scopebond cursor`.\n const tokens = cmd.split(/\\s+/).filter(Boolean);\n const last = unquote(tokens[tokens.length - 1] ?? \"\");\n if (!HARNESS_SUBCOMMANDS.has(last)) return false;\n return tokens.some((token) => {\n const t = unquote(token).toLowerCase();\n return t.includes(\"scopebond\") || /cli\\.(m|c)?js$/.test(t);\n });\n};\nconst isScopebond = isScopebondHookCommand;\n\n/** Whether a harness hook entry (either shape: a bare command, or a group with a\n * nested `hooks` array) is a Scopebond entry. */\nexport const harnessEntryMatches = (e: unknown): boolean =>\n isRecord(e) && (isScopebond(e.command) || (Array.isArray(e.hooks) && e.hooks.some((h) => isRecord(h) && isScopebond(h.command))));\nconst entryMatches = harnessEntryMatches;\n\n/** Read an agent's JSON config for merging. A missing file is an empty config; a file\n * that exists but does not parse as a JSON object is an error — rewriting it would\n * silently delete the user's other settings, so the installer stops instead. */\nexport function readHarnessConfig(file: string): Record<string, unknown> {\n if (!existsSync(file)) return {};\n const text = readFileSync(file, \"utf8\");\n if (!text.trim()) return {};\n let parsed: unknown;\n try { parsed = JSON.parse(text); } catch (error) {\n throw new Error(`${file} is not valid JSON (${(error as Error).message}); fix or move it, then run this again — it was left unchanged`);\n }\n if (!isRecord(parsed)) throw new Error(`${file} is not a JSON object; fix or move it, then run this again — it was left unchanged`);\n return parsed;\n}\n\n/** Copy an agent config aside before changing it, once. Re-running an installer must not\n * overwrite the backup with the already-modified file, or the original is lost on the\n * second run — so an existing backup is left alone. Returns the backup path when one was\n * written.\n *\n * This exists because these installers rewrite files the user did not create and may not\n * know are involved (`~/.claude/settings.json` holds their theme, plugins and\n * permissions), and the undo was previously \"hope the merge was right\". */\nexport function backupHarnessConfig(file: string): string | null {\n if (!existsSync(file)) return null;\n const backup = `${file}.scopebond-backup`;\n if (existsSync(backup)) return null;\n try { copyFileSync(file, backup); return backup; } catch { return null; }\n}\n\n/** Write (idempotently) a harness hook entry pointing at an explicit command string.\n * Shared by the project installer (npx command) and the user installer (absolute path). */\nexport function writeHarnessConfig(file: string, harness: Harness, command: string): string {\n const config = readHarnessConfig(file);\n backupHarnessConfig(file);\n mkdirSync(dirname(file), { recursive: true });\n const hooks = isRecord(config.hooks) ? config.hooks : (config.hooks = {});\n if (harness === \"cursor\") {\n config.version = config.version ?? 1;\n for (const event of [\"beforeShellExecution\", \"beforeMCPExecution\", \"beforeReadFile\", \"afterFileEdit\"]) {\n const list = Array.isArray((hooks as Record<string, unknown>)[event]) ? (hooks as Record<string, unknown[]>)[event] : ((hooks as Record<string, unknown[]>)[event] = []);\n const at = list.findIndex(entryMatches);\n const entry = { command: command.replace(/\\bclaude$/, \"cursor\") };\n if (at >= 0) list[at] = entry; else list.push(entry);\n }\n } else {\n const list = Array.isArray((hooks as Record<string, unknown>).PreToolUse) ? (hooks as Record<string, unknown[]>).PreToolUse : ((hooks as Record<string, unknown[]>).PreToolUse = []);\n const at = list.findIndex(entryMatches);\n // Codex matchers are regular expressions; omitting the matcher means every tool.\n // A literal \"*\" is not a valid regular expression and causes Codex to skip the group.\n const entry = harness === \"codex\"\n ? { hooks: [{ type: \"command\", command, timeout: 30, statusMessage: \"Checking this action with Scopebond\" }] }\n : { matcher: \"*\", hooks: [{ type: \"command\", command }] };\n if (at >= 0) list[at] = entry; else list.push(entry);\n }\n writeFileSync(file, JSON.stringify(config, null, 2) + \"\\n\");\n return file;\n}\n\n/** Remove any Scopebond hook entry from a user-level harness config, leaving the rest\n * intact. Returns true if the file existed. */\nexport function removeHarnessConfig(file: string): boolean {\n if (!existsSync(file)) return false;\n let config: Record<string, unknown>;\n try { config = readHarnessConfig(file); } catch { return true; } // unreadable: leave it untouched\n const hooks = isRecord(config.hooks) ? config.hooks : {};\n for (const [event, value] of Object.entries(hooks)) {\n if (Array.isArray(value)) (hooks as Record<string, unknown[]>)[event] = value.filter((e) => !entryMatches(e));\n }\n writeFileSync(file, JSON.stringify(config, null, 2) + \"\\n\");\n return true;\n}\n\n/** Detect a Cursor install by its user config directory. */\nexport function cursorDetected(): boolean {\n return existsSync(join(homedir(), \".cursor\"));\n}\n\n/** Detect a Codex install by its user config directory. */\nexport function codexDetected(): boolean {\n return existsSync(join(homedir(), \".codex\"));\n}\n\n/** The absolute command a harness entry runs: the current Node executable and the\n * absolute path to this CLI, so it does not depend on PATH.\n *\n * Both paths are always quoted on Windows, even without a space in them. A harness\n * runs this string through a shell, and that shell may be bash (Git Bash, WSL, a\n * dev container), where an unquoted Windows path loses every backslash and the\n * hook dies with MODULE_NOT_FOUND. Inside double quotes bash leaves a backslash\n * alone unless it precedes $ ` \" \\ or a newline — none of which occur in a Windows\n * path — and cmd.exe and PowerShell accept quoted paths too. */\nexport function absoluteHookCommand(cliPath: string, harness: Harness): string {\n const quote = (s: string) => (process.platform === \"win32\" || /\\s/.test(s) ? `\"${s}\"` : s);\n return `${quote(process.execPath)} ${quote(cliPath)} ${harness}`;\n}\n\nexport function isHarnessConfigured(file: string): boolean {\n if (!existsSync(file)) return false;\n try {\n const p = JSON.parse(readFileSync(file, \"utf8\"));\n if (!isRecord(p) || !isRecord(p.hooks)) return false;\n return Object.values(p.hooks).some((v) => Array.isArray(v) && v.some(entryMatches));\n } catch { return false; }\n}\n\n/** Every Scopebond command string a harness config currently runs. `doctor` uses it to\n * check that a pinned command still resolves: a hook entry that cannot start is worse\n * than none, because a harness can read the failure as \"no hook\". */\nexport function configuredHookCommands(file: string): string[] {\n if (!existsSync(file)) return [];\n const found: string[] = [];\n const collect = (entry: unknown): void => {\n if (!isRecord(entry)) return;\n if (isScopebond(entry.command)) found.push(String(entry.command));\n if (Array.isArray(entry.hooks)) for (const h of entry.hooks) collect(h);\n };\n try {\n const p = JSON.parse(readFileSync(file, \"utf8\"));\n if (!isRecord(p) || !isRecord(p.hooks)) return [];\n for (const list of Object.values(p.hooks)) if (Array.isArray(list)) for (const e of list) collect(e);\n } catch { return []; }\n return found;\n}\n\n/** Whether a configured hook command can actually start. An `npx` form always can\n * (npm resolves it); a pinned command names two absolute paths — the Node binary and\n * the CLI — and either can go away: a Node version manager switching versions, or the\n * Scopebond home being cleared. Both are exactly what this check exists to catch. */\nexport function hookCommandResolves(command: string): boolean {\n if (/(^|\\s)npx(\\.cmd)?\\s/.test(command)) return true;\n // `\"<node>\" \"<cli.js>\" claude`: check every token that names a file on disk.\n const tokens = command.match(/\"[^\"]+\"|\\S+/g) ?? [];\n let checked = 0;\n for (const raw of tokens) {\n const candidate = raw.replace(/^\"|\"$/g, \"\");\n if (!/\\.(m|c)?js$|\\.exe$/i.test(candidate)) continue;\n checked += 1;\n if (!existsSync(candidate)) return false;\n }\n // A bare `node cli.js` form on POSIX has no .exe to check; the .js still counted.\n return checked > 0 || !/[\\\\/]/.test(command.split(/\\s+/)[0] ?? \"\");\n}\n\n/** Remove the user-level home (keys, policy, receipts). */\nexport function purgeHome(): void {\n rmSync(userHome(), { recursive: true, force: true });\n}\n"]}
package/dist/map.d.ts CHANGED
@@ -13,6 +13,12 @@ export interface Mapped {
13
13
  evaluated: boolean;
14
14
  /** The native tool/event name, for diagnostics. */
15
15
  source: string;
16
+ /** True when the harness only tells us about this action *after* it happened, so a
17
+ * policy decision on it records and flags the action but did not prevent it. Cursor's
18
+ * `afterFileEdit` is the case that exists today: it has no before-edit counterpart,
19
+ * so a file write there is observed, not stopped. Callers must not describe such a
20
+ * decision as prevention. */
21
+ postHoc?: boolean;
16
22
  }
17
23
  /** A bare `git push` (no ref) pushes the current branch. The mapper cannot know it,
18
24
  * so the runtime resolves it and fills it in here, before evaluation — otherwise the
package/dist/map.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"map.d.ts","sourceRoot":"","sources":["../src/map.ts"],"names":[],"mappings":"AAaA,MAAM,WAAW,gBAAgB;IAC/B,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACjC;AACD,MAAM,WAAW,MAAM;IACrB,MAAM,EAAE,gBAAgB,CAAC;IACzB;;;;;kEAK8D;IAC9D,SAAS,EAAE,OAAO,CAAC;IACnB,mDAAmD;IACnD,MAAM,EAAE,MAAM,CAAC;CAChB;AAkpBD;;;uCAGuC;AACvC,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,EAAE,CAM5F;AAED,oFAAoF;AACpF,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,EAAE,CAyBzE;AAED;;kFAEkF;AAClF,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,EAAE,CAaxE;AAED,uEAAuE;AACvE,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,EAAE,CAkBxF"}
1
+ {"version":3,"file":"map.d.ts","sourceRoot":"","sources":["../src/map.ts"],"names":[],"mappings":"AAaA,MAAM,WAAW,gBAAgB;IAC/B,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACjC;AACD,MAAM,WAAW,MAAM;IACrB,MAAM,EAAE,gBAAgB,CAAC;IACzB;;;;;kEAK8D;IAC9D,SAAS,EAAE,OAAO,CAAC;IACnB,mDAAmD;IACnD,MAAM,EAAE,MAAM,CAAC;IACf;;;;kCAI8B;IAC9B,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAkpBD;;;uCAGuC;AACvC,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,EAAE,CAM5F;AAED,oFAAoF;AACpF,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,EAAE,CAyBzE;AAED;;kFAEkF;AAClF,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,EAAE,CAaxE;AAED,uEAAuE;AACvE,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,EAAE,CAqBxF"}
package/dist/map.js CHANGED
@@ -903,7 +903,10 @@ export function mapCursorEvent(event, payload) {
903
903
  case "beforeReadFile":
904
904
  return pathIntents("file.read", rel(p.path ?? p.file_path, cwd), event);
905
905
  case "afterFileEdit":
906
- return pathIntents("file.write", rel(p.path ?? p.file_path, cwd), event);
906
+ // Cursor reports an edit only once it has been made; there is no before-edit
907
+ // hook to block at. The rule still decides and the decision is still recorded —
908
+ // it just cannot be called prevention.
909
+ return pathIntents("file.write", rel(p.path ?? p.file_path, cwd), event).map((m) => ({ ...m, postHoc: true }));
907
910
  case "beforeMCPExecution": {
908
911
  const server = String(p.server ?? p.server_name ?? "");
909
912
  const tool = String(p.tool ?? p.tool_name ?? "");