@scopebond/hook 0.4.0 → 0.6.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
@@ -1,28 +1,33 @@
1
1
  # @scopebond/hook
2
2
 
3
- The Scopebond connector for **Claude Code** and **Cursor**. It checks every tool
3
+ The Scopebond connector for **Claude Code**, **Cursor**, and **OpenAI Codex**. It checks every tool
4
4
  call a coding agent makes against your policy *before it runs*, blocks the ones
5
5
  outside policy, and records a signed receipt — locally, on the machine, and
6
6
  optionally mirrored to your Scopebond Cloud workspace for monitoring.
7
7
 
8
- - **Label:** in-path · prevents (a Claude Code `PreToolUse` deny via exit code 2
9
- blocks even in bypass-permissions mode).
10
- - **Ceiling:** it governs actions the harness routes through its tool system
8
+ - **What it does:** checks supported agent actions before they run and blocks an
9
+ action when it breaks your policy.
10
+ - **Where it works:** actions the coding agent routes through its tool system
11
11
  (shell, file, MCP, web). A process started outside the harness is not covered.
12
12
 
13
13
  ## Install once for your machine
14
14
 
15
15
  ```
16
16
  npm i -g @scopebond/hook
17
- scopebond install # detects Cursor; add --claude / --cursor to pick
17
+ scopebond install # detects Cursor and Codex
18
+ scopebond install --codex # set up Codex only
18
19
  ```
19
20
 
20
21
  `install` sets Scopebond up **once per developer machine**, not per repository: it
21
22
  scaffolds a user-level home (`~/.scopebond`, override `SCOPEBOND_HOME`) with a
22
23
  signing key, a countersigning key and a starter policy, and registers the hook by
23
- absolute path in your user-level `~/.claude/settings.json` (and `~/.cursor/hooks.json`
24
- when Cursor is present). Every project you open is then governed, and a project-local
25
- `.scopebond/` still takes precedence when you want a per-repo policy.
24
+ absolute path in your user-level `~/.claude/settings.json`, `~/.cursor/hooks.json`,
25
+ or `~/.codex/hooks.json`. After Codex setup, open Codex, run `/hooks`, review
26
+ Scopebond, and choose **Trust** once. Every project you open is then governed by your
27
+ user-level policy. A project's own `.scopebond/policy.json` applies only after you trust it
28
+ in that project (`scopebond trust`, or `init` there), pinned to its exact contents: a
29
+ repository you clone, or an agent working in it, cannot swap in a weaker policy, and any
30
+ later edit to that file stops it applying until you trust it again.
26
31
 
27
32
  ```
28
33
  scopebond status # home, which agents are configured, cloud, receipts
@@ -43,14 +48,17 @@ Scopebond is also a Claude Code plugin (this repo is a plugin marketplace):
43
48
  ### Per-repository enroll (alternative)
44
49
 
45
50
  ```
46
- npx @scopebond/hook init # Claude Code (or: init --cursor)
51
+ npx @scopebond/hook init # Claude Code
52
+ npx @scopebond/hook init --cursor # Cursor
53
+ npx @scopebond/hook init --codex # Codex, then approve it once with /hooks
47
54
  ```
48
55
 
49
56
  `init` scaffolds `.scopebond/` in the current project (a machine signing key, a
50
57
  countersigning key, a starter policy — "protect main and production paths" — and a
51
- `.gitignore` so none of it is committed) and configures `.claude/settings.json` or
52
- `.cursor/hooks.json`. Then run one safe command in the agent and see the receipt in
53
- `.scopebond/receipts.db`.
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`.
54
62
 
55
63
  ## Connect it to your workspace (optional)
56
64
 
@@ -62,11 +70,13 @@ as `scopebond-enrollment.json`, then:
62
70
  npx @scopebond/hook connect https://<your-workspace> scopebond-enrollment.json
63
71
  ```
64
72
 
73
+ In Windows PowerShell, use `npx.cmd` instead of `npx` if script execution policy blocks `npx.ps1`; no execution-policy change is needed. Enrollment registers both the gateway attester and the hook's separate agent signing key with proof of possession, so Cloud can verify authenticated receipts.
74
+
65
75
  `connect` does the whole setup in one command: it scaffolds `.scopebond/` if needed,
66
76
  enrolls this machine's countersigning key, stores a scoped machine credential in
67
- `.scopebond/cloud.json` (a secret — never commit it), **and configures Claude Code
68
- for you** (it merges the hook into `.claude/settings.json`, preserving anything
69
- already there — pass `--no-install` to skip, or `--cursor` for Cursor). The
77
+ `.scopebond/cloud.json` (a secret — never commit it), **and configures your coding
78
+ agent for you** (it merges the hook into the agent's settings, preserving anything
79
+ already there — pass `--no-install` to skip, `--cursor` for Cursor, or `--codex` for Codex). The
70
80
  enrollment argument can be a file, an inline blob, or JSON on stdin, so the portal
71
81
  can hand you a single copy-paste command with nothing to save.
72
82
 
@@ -81,27 +91,69 @@ anything still queued — run it on a session-end hook (and set
81
91
  Each tool call is mapped to a normalized [Action Taxonomy](https://github.com/avouro-com/scopebond)
82
92
  action (`shell.exec`, `git.push`, `file.write`, `mcp.tool.call`, …), signed by the
83
93
  machine key and decided against your policy by an in-process check-only gateway.
84
- An allowed action is a **cooperative allow** (recorded, never executed by the
85
- hook — the agent performs it); a denied action is blocked. Unknown tools are
94
+ An allowed action is recorded and left to the coding agent's normal permission
95
+ prompt; the hook never auto-approves it. A denied action is blocked. Unknown tools are
86
96
  recorded as *not evaluated* and grant nothing. Anything unexpected fails closed
87
97
  (deny) with a repair message.
88
98
 
89
99
  Commands are stored as a scrubbed head plus a digest; file contents are never
90
100
  stored; common secret shapes are removed before signing.
91
101
 
92
- **Strict mode.** By default a tool with no taxonomy mapping is recorded *not
93
- evaluated* (not blocked). Add `--strict` (or `SCOPEBOND_HOOK_STRICT=1`) to deny
94
- unmapped tools too — fail-closed coverage for anything the taxonomy does not map.
102
+ **What a shell command is checked for.** A command line is split into every command it
103
+ runs (`&&`, `;`, pipes, `$( )` and backticks — also inside double quotes — `bash -c`,
104
+ `eval`, `trap`, `su -c`, `script -c`, `watch`, `parallel`, `cmd /c`, `pwsh -Command`
105
+ and `-EncodedCommand`, `find -exec`, `env -S`), with shell keywords and grouping
106
+ (`if … then`, `for … do`, `{ … }`, `!`, `case`) and wrappers (`sudo`, `env`, `time`,
107
+ `nice`, `xargs`, `timeout`, `strace`, `busybox` …, each with its own option arity)
108
+ stripped so the real program is seen; the wrappers are checked too. Each command is
109
+ checked as a program, and the files it reads or writes are checked like the Read and
110
+ Write tools: operands of programs that output or copy file content (`cat`, `grep`,
111
+ `tar`, …), copies and moves (`cp`, `mv`, `rsync`, `scp`, `Copy-Item`), writers and
112
+ editors (`tee`, `touch`, `sed -i`, `yq -i`, `ed`, `vim`, `Set-Content`), git's own writes
113
+ (`checkout -- p`, `restore`, `mv`, `rm`, `config core.hooksPath`), secrets sent or staged
114
+ (`curl -T`, `-d @file`, `gh gist create`, `git add`) and redirections, including
115
+ `x>file` without spaces. Existence and metadata checks (`ls`, `test -f`, `[ -f ]`,
116
+ `stat`) and a secret file's name in a string are not reads. Paths are compared
117
+ case-insensitively; Windows `\` paths, 8.3 short names (`CLAUDE~1`), `::$DATA` streams
118
+ and trailing dots are normalized; and a glob, variable or brace list that could name a
119
+ protected file or directory (`.scope*/agent.key`, `.*/*`, `$HOME/.ssh/id_rsa`) is
120
+ treated as that file. Every destination of a `git push` is checked in the common
121
+ spellings (`refs/heads/main`, `HEAD:main`, a second refspec, `--repo`, `--all`,
122
+ `--mirror`); a push whose destination is not on the command line (a git alias, a
123
+ configured push refspec, `send-pack`) is denied, and a tags-only push is allowed. The
124
+ agent running the hook's own `init`, `install`, `trust`, `uninstall` or `connect` is
125
+ denied, and those commands also refuse a non-interactive terminal unless `--yes` is
126
+ passed.
127
+
128
+ **Limits.** The hook sees the command text, not what a program does at run time. It
129
+ does not follow a variable whose value it cannot see (`cat $FILE`), a path assembled
130
+ inside a script or interpreter (`python script.py`), aliases and functions defined in
131
+ an earlier call, git aliases from a config file, recursive reads of a parent of a
132
+ protected directory (`grep -r . `, `cp -r ~ /tmp`), or deletion expressed as arguments
133
+ (`find -delete`, `git clean`). Commands whose written files are named only inside their
134
+ input — `patch`, `git apply`, `git am`, `tar x`, `unzip`, `7z x`, `cpio -i` — are
135
+ recorded with an unevaluated write: allowed in normal mode, denied in strict mode.
136
+ Reading any `*.pem` file is denied, including a public certificate, because a PEM file
137
+ is often a private key. For stronger guarantees, run the agent behind the Scopebond
138
+ gateway or in a sandbox; the hook is a guardrail for a cooperating agent, not a jail.
139
+
140
+ **Strict mode.** A shell command that cannot be parsed (an unbalanced quote) or whose
141
+ program is only known at run time (`$cmd`, `$(…) args`) is checked with an empty
142
+ program name, which the starter policy denies in every mode — edit the `safe-shell`
143
+ clause to change that. By default a tool with no taxonomy mapping, or a write whose
144
+ target the command text does not name, is recorded *not evaluated* (not blocked). Add
145
+ `--strict` (or `SCOPEBOND_HOOK_STRICT=1`) to deny those too — fail-closed coverage for
146
+ anything the taxonomy does not map.
95
147
 
96
148
  ## Library
97
149
 
98
150
  The mapper and runtime are exported for testing and embedding:
99
151
 
100
152
  ```js
101
- import { mapClaudeToolUse, createHookRuntime } from "@scopebond/hook";
153
+ import { mapClaudeToolUse, mapCodexToolUse, createHookRuntime } from "@scopebond/hook";
102
154
  ```
103
155
 
104
- `mapClaudeToolUse` / `mapCursorEvent` are pure functions (native payload →
156
+ `mapClaudeToolUse`, `mapCursorEvent`, and `mapCodexToolUse` are pure functions (native payload →
105
157
  normalized action). `createHookRuntime` builds the check-only gateway from a
106
158
  policy, a machine key and a local receipt log; pass `cloud: { connection }` (from
107
159
  `connectCloud`) to mirror receipts to a workspace.
package/dist/cli.js CHANGED
@@ -4,13 +4,15 @@
4
4
  //
5
5
  // scopebond-hook claude evaluate a Claude Code PreToolUse call (stdin JSON)
6
6
  // scopebond-hook cursor evaluate a Cursor hook event (stdin JSON)
7
- // scopebond-hook init [--cursor] [--no-install] scaffold keys + starter policy and configure the agent
8
- // scopebond-hook connect <url> <bundle.json> [--cursor]
7
+ // scopebond-hook codex evaluate a Codex PreToolUse call (stdin JSON)
8
+ // scopebond-hook init [--cursor|--codex] [--no-install] [--yes]
9
+ // scopebond-hook connect <url> <bundle.json> [--cursor|--codex]
9
10
  // enroll with a Cloud workspace and start exporting
10
11
  // scopebond-hook log [-n N] show the most recent local receipts
11
12
  // scopebond-hook verify verify every local receipt offline against the attester key
12
13
  // scopebond-hook test "<shell command>" show the decision for a command without recording it
13
14
  // scopebond-hook flush deliver any queued receipts to Cloud now
15
+ // scopebond-hook trust [--yes] let this project's .scopebond policy govern here (pinned)
14
16
  //
15
17
  // Config dir: $SCOPEBOND_HOOK_DIR, else ./.scopebond
16
18
  // Fail-closed: any error denies the action with a repair message.
@@ -20,10 +22,10 @@ import { tmpdir } from "node:os";
20
22
  import { execFileSync } from "node:child_process";
21
23
  import { verifyReceipt } from "@scopebond/gateway";
22
24
  import { openReceiptStore, loadOrCreateAttester } from "@scopebond/gateway/node";
23
- import { mapClaudeToolUse, mapCursorEvent, fillPushBranch } from "./map.js";
25
+ import { mapClaudeToolUse, mapCodexToolUse, mapCursorEvent, fillPushBranch } from "./map.js";
24
26
  import { createHookRuntime } from "./runtime.js";
25
27
  import { scaffold, harnessSnippet, installHarness } from "./init.js";
26
- import { userHome, userHarnessFile, resolveConfigDir, writeHarnessConfig, removeHarnessConfig, cursorDetected, absoluteHookCommand, isHarnessConfigured, purgeHome, } from "./install.js";
28
+ import { userHome, userHarnessFile, resolveConfigDir, writeHarnessConfig, removeHarnessConfig, cursorDetected, codexDetected, absoluteHookCommand, isHarnessConfigured, purgeHome, trustProjectPolicy, untrustedProjectPolicy, } from "./install.js";
27
29
  import { connectCloud, loadConnection } from "./cloud.js";
28
30
  import { cliCommand, hookVersion } from "./version.js";
29
31
  import { fileURLToPath } from "node:url";
@@ -93,30 +95,48 @@ function denyClaude(reason) {
93
95
  process.stderr.write(`Scopebond: ${reason}\n`);
94
96
  process.exit(2);
95
97
  }
96
- async function runClaude() {
98
+ /** Codex accepts the structured deny response on a successful hook exit. Keeping
99
+ * exit 0 lets its UI show a completed policy check instead of a failed hook while
100
+ * still preventing the tool call. */
101
+ function denyCodex(reason) {
102
+ process.stdout.write(JSON.stringify({
103
+ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason },
104
+ }) + "\n");
105
+ process.exit(0);
106
+ }
107
+ const harnessName = (harness) => harness === "claude" ? "Claude Code" : harness === "cursor" ? "Cursor" : "Codex";
108
+ const harnessFileName = (harness) => harness === "claude" ? ".claude/settings.json" : harness === "cursor" ? ".cursor/hooks.json" : ".codex/hooks.json";
109
+ const selectedHarness = (args) => args.includes("--codex") ? "codex" : args.includes("--cursor") ? "cursor" : "claude";
110
+ const codexTrustStep = "Open Codex, run `/hooks`, review Scopebond, and choose Trust. Then start a new task.";
111
+ async function runPreToolUse(mapper, deny = denyClaude) {
97
112
  let input;
98
113
  try {
99
114
  input = JSON.parse(readStdin());
100
115
  }
101
116
  catch {
102
- denyClaude("hook received invalid JSON on stdin");
117
+ deny("hook received invalid JSON on stdin");
103
118
  }
104
119
  try {
105
120
  const cwd = input?.cwd ? String(input.cwd) : process.cwd();
106
121
  const runtime = createHookRuntime(runtimePaths(resolveConfigDir(cwd)));
107
- const decision = await runtime.evaluate(fillPushBranch(mapClaudeToolUse(input), currentBranch(cwd)));
122
+ const decision = await runtime.evaluate(fillPushBranch(mapper(input), currentBranch(cwd)));
108
123
  await runtime.flush();
109
124
  if (decision.decision === "deny")
110
- denyClaude(decision.reason);
111
- // allow and not_evaluated both DEFER to Claude Code's own permission flow: the
112
- // hook records the receipt but never returns permissionDecision:"allow", which
113
- // would suppress the user's normal review. Scopebond blocks; it does not approve.
125
+ deny(decision.reason);
126
+ // Stay silent on allow/not_evaluated so the coding agent's normal permission
127
+ // flow remains in charge. Scopebond blocks; it never silently approves.
114
128
  process.exit(0);
115
129
  }
116
130
  catch (error) {
117
- denyClaude(`Scopebond hook failed closed: ${error.message}. Repair: run \`${cliCommand("init")}\`.`);
131
+ deny(`Scopebond hook failed closed: ${error.message}. Repair: run \`${cliCommand("init")}\`.`);
118
132
  }
119
133
  }
134
+ async function runClaude() {
135
+ await runPreToolUse(mapClaudeToolUse);
136
+ }
137
+ async function runCodex() {
138
+ await runPreToolUse(mapCodexToolUse, denyCodex);
139
+ }
120
140
  async function runCursor() {
121
141
  let input = {};
122
142
  try {
@@ -143,22 +163,49 @@ async function runCursor() {
143
163
  process.stdout.write(JSON.stringify({ permission, agentMessage: message }) + "\n");
144
164
  process.exit(0);
145
165
  }
166
+ /** `init`, `trust` and `uninstall` change what governs the agent, so they are for a
167
+ * person at a terminal. A coding agent's shell is not interactive: without a TTY on
168
+ * stdin they refuse unless `--yes` is passed (for scripts and CI). The starter policy
169
+ * also denies the agent running them. */
170
+ function requireInteractive(command, args) {
171
+ if (process.stdin.isTTY || args.includes("--yes"))
172
+ return;
173
+ console.error(`scopebond ${command} changes what governs your coding agent, so it must be run from an interactive terminal.`);
174
+ console.error(`In a script or CI, pass --yes: ${cliCommand(`${command} --yes`)}`);
175
+ process.exit(1);
176
+ }
146
177
  function runInit(args) {
147
- const harness = args.includes("--cursor") ? "cursor" : "claude";
178
+ requireInteractive("init", args);
179
+ const harness = selectedHarness(args);
148
180
  const dir = configDir();
149
181
  const { agentKid, policyPath } = scaffold(dir, { force: args.includes("--force") });
150
182
  console.log(`Scopebond hook enrolled in ${dir}`);
151
183
  console.log(` machine key ${agentKid}`);
152
184
  console.log(` policy ${policyPath} (starter — edit the limits)`);
185
+ // With a user-level install present, a project policy governs only once trusted.
186
+ // Running init here is that decision, so pin this policy now.
187
+ if (!process.env.SCOPEBOND_HOOK_DIR && existsSync(join(userHome(), "policy.json"))) {
188
+ trustProjectPolicy(dir);
189
+ console.log(` trusted overrides ${userHome()} here; after editing it, run \`${cliCommand("trust")}\``);
190
+ }
153
191
  console.log("");
154
192
  // Configure the agent automatically by default (idempotent), so there is no
155
193
  // hand-editing step; --no-install prints the snippet instead.
156
194
  if (!args.includes("--no-install")) {
157
- const file = installHarness(harness);
158
- console.log(`✓ ${harness === "cursor" ? "Cursor" : "Claude Code"} configured in ${file}`);
195
+ let file;
196
+ try {
197
+ file = installHarness(harness);
198
+ }
199
+ catch (error) {
200
+ console.error(error.message);
201
+ process.exit(1);
202
+ }
203
+ console.log(`✓ ${harnessName(harness)} configured in ${file}`);
204
+ if (harness === "codex")
205
+ console.log(`\nOne last step: ${codexTrustStep}`);
159
206
  }
160
207
  else {
161
- console.log(`Add this to your ${harness === "cursor" ? ".cursor/hooks.json" : ".claude/settings.json"}:`);
208
+ console.log(`Add this to your ${harnessFileName(harness)}:`);
162
209
  console.log(harnessSnippet(harness));
163
210
  }
164
211
  console.log("");
@@ -277,9 +324,9 @@ async function runConnect(args) {
277
324
  const positional = args.filter((a) => !a.startsWith("--"));
278
325
  const url = positional[0];
279
326
  const bundleArg = positional[1];
280
- const harness = args.includes("--cursor") ? "cursor" : "claude";
327
+ const harness = selectedHarness(args);
281
328
  if (!url) {
282
- console.error("usage: scopebond-hook connect <workspace-url> <enrollment> [--cursor] [--no-install]");
329
+ console.error("usage: scopebond-hook connect <workspace-url> <enrollment> [--claude|--cursor|--codex] [--no-install]");
283
330
  process.exit(1);
284
331
  }
285
332
  const dir = configDir();
@@ -303,10 +350,12 @@ async function runConnect(args) {
303
350
  // caller opts out. This removes the "paste this snippet" step.
304
351
  if (!args.includes("--no-install")) {
305
352
  const file = installHarness(harness);
306
- console.log(`✓ ${harness === "cursor" ? "Cursor" : "Claude Code"} configured in ${file}`);
353
+ console.log(`✓ ${harnessName(harness)} configured in ${file}`);
354
+ if (harness === "codex")
355
+ console.log(`\nOne last step: ${codexTrustStep}`);
307
356
  }
308
357
  else {
309
- console.log(`Add this to your ${harness === "cursor" ? ".cursor/hooks.json" : ".claude/settings.json"}:`);
358
+ console.log(`Add this to your ${harnessFileName(harness)}:`);
310
359
  console.log(harnessSnippet(harness));
311
360
  }
312
361
  console.log("");
@@ -326,8 +375,10 @@ async function runFlush() {
326
375
  const runtime = createHookRuntime(runtimePaths(dir));
327
376
  await runtime.exporter?.flush();
328
377
  const status = runtime.exporter?.status();
378
+ runtime.exporter?.stop();
329
379
  console.log(`flushed; ${status?.pending ?? 0} receipt(s) still pending${status?.lastError ? ` (last error: ${status.lastError})` : ""}`);
330
- process.exit(status && status.pending > 0 ? 1 : 0);
380
+ // Let pending HTTP handles close normally (forced exit can abort on Windows).
381
+ process.exitCode = status && status.pending > 0 ? 1 : 0;
331
382
  }
332
383
  /** The absolute path to this CLI file, for registering the hook by absolute path. */
333
384
  function cliPath() {
@@ -343,21 +394,30 @@ function runInstall(args) {
343
394
  console.log(` machine key ${agentKid}`);
344
395
  console.log(` policy ${policyPath} (starter — edit the limits)`);
345
396
  console.log("");
346
- const harnesses = args.includes("--cursor")
347
- ? ["cursor"]
348
- : args.includes("--claude") ? ["claude"] : (cursorDetected() ? ["claude", "cursor"] : ["claude"]);
397
+ const harnesses = args.includes("--codex") ? ["codex"]
398
+ : args.includes("--cursor") ? ["cursor"]
399
+ : args.includes("--claude") ? ["claude"]
400
+ : ["claude", ...(cursorDetected() ? ["cursor"] : []), ...(codexDetected() ? ["codex"] : [])];
349
401
  if (!args.includes("--no-install")) {
350
402
  for (const h of harnesses) {
351
- const file = writeHarnessConfig(userHarnessFile(h), h, absoluteHookCommand(cliPath(), h));
352
- console.log(`✓ ${h === "cursor" ? "Cursor" : "Claude Code"} configured in ${file}`);
403
+ try {
404
+ const file = writeHarnessConfig(userHarnessFile(h), h, absoluteHookCommand(cliPath(), h));
405
+ console.log(`✓ ${harnessName(h)} configured in ${file}`);
406
+ }
407
+ catch (error) {
408
+ console.error(`✗ ${harnessName(h)}: ${error.message}`);
409
+ process.exitCode = 1;
410
+ }
353
411
  }
354
412
  }
355
413
  else {
356
414
  console.log("Add this to your user-level agent config:");
357
415
  console.log(harnessSnippet(harnesses[0]));
358
416
  }
417
+ if (harnesses.includes("codex"))
418
+ console.log(`\nOne last step for Codex: ${codexTrustStep}`);
359
419
  console.log("");
360
- console.log("A project-local .scopebond still takes precedence when present.");
420
+ console.log(`A project's own .scopebond policy applies only after you trust it there (${cliCommand("trust")}).`);
361
421
  console.log(`Check it: ${cliCommand("doctor")} · see decisions: ${cliCommand("log")}`);
362
422
  console.log(`To send receipts to a workspace: ${cliCommand("connect <workspace-url> <enrollment>")}`);
363
423
  }
@@ -366,13 +426,18 @@ function runStatus() {
366
426
  const installed = existsSync(join(home, "policy.json"));
367
427
  const claude = isHarnessConfigured(userHarnessFile("claude"));
368
428
  const cursor = isHarnessConfigured(userHarnessFile("cursor"));
429
+ const codex = isHarnessConfigured(userHarnessFile("codex"));
369
430
  const connected = !!loadConnection(resolveConfigDir(process.cwd()));
370
431
  const dbPath = join(resolveConfigDir(process.cwd()), "receipts.db");
371
432
  console.log(`Scopebond hook ${hookVersion()}`);
372
433
  console.log(` user home ${home} ${installed ? "(installed)" : "(not installed — run `scopebond install`)"}`);
373
434
  console.log(` active config ${resolveConfigDir(process.cwd())}`);
374
- console.log(` Claude Code ${claude ? "configured" : "not configured"}`);
435
+ const ignored = untrustedProjectPolicy(process.cwd());
436
+ if (ignored)
437
+ console.log(` project policy ${ignored} ignored — not trusted (run \`${cliCommand("trust")}\` to use it)`);
438
+ console.log(` Claude Code ${claude ? "configured" : "not configured"}`);
375
439
  console.log(` Cursor ${cursor ? "configured" : cursorDetected() ? "detected, not configured" : "not detected"}`);
440
+ console.log(` Codex ${codex ? "configured (approve once with /hooks)" : codexDetected() ? "detected, not configured" : "not detected"}`);
376
441
  console.log(` cloud workspace ${connected ? "connected" : "not connected (local only)"}`);
377
442
  console.log(` local receipts ${existsSync(dbPath) ? dbPath : "none yet"}`);
378
443
  }
@@ -391,6 +456,13 @@ async function runDoctor() {
391
456
  console.log(` active config ${active} ${hasPolicy ? "ok" : "no policy (run `scopebond install` or `init`)"}`);
392
457
  if (!hasPolicy)
393
458
  problems.push("no policy found in the active config dir");
459
+ const ignored = untrustedProjectPolicy(process.cwd());
460
+ if (ignored)
461
+ console.log(` project policy ${ignored} IGNORED — not trusted (never trusted, or edited since). Review it, then \`${cliCommand("trust")}\``);
462
+ const codex = isHarnessConfigured(userHarnessFile("codex"));
463
+ console.log(` Codex hook ${codex ? "configured" : codexDetected() ? "not configured — run `scopebond install --codex`" : "not detected"}`);
464
+ if (codex)
465
+ console.log(` Codex approval run /hooks in Codex and approve Scopebond once`);
394
466
  const connection = loadConnection(active);
395
467
  if (!connection) {
396
468
  console.log(` cloud not connected (local only) — receipts stay on this machine`);
@@ -407,11 +479,12 @@ async function runDoctor() {
407
479
  console.log(` cloud ${connection.url} — ${reachable}`);
408
480
  }
409
481
  console.log(problems.length ? `\n${problems.length} problem(s): ${problems.join("; ")}` : `\nAll good.`);
410
- process.exit(problems.length ? 1 : 0);
482
+ process.exitCode = problems.length ? 1 : 0;
411
483
  }
412
484
  function runUninstall(args) {
485
+ requireInteractive("uninstall", args);
413
486
  let removed = 0;
414
- for (const h of ["claude", "cursor"]) {
487
+ for (const h of ["claude", "cursor", "codex"]) {
415
488
  if (removeHarnessConfig(userHarnessFile(h))) {
416
489
  console.log(`✓ removed the Scopebond hook from ${userHarnessFile(h)}`);
417
490
  removed++;
@@ -426,6 +499,20 @@ function runUninstall(args) {
426
499
  else
427
500
  console.log(`Kept ${userHome()} (keys, policy, receipts). Use --purge to remove it too.`);
428
501
  }
502
+ /** `trust` — let this project's .scopebond policy govern here instead of the user
503
+ * home, pinned to its current contents. Run by the user, not the agent: the file it
504
+ * writes lives in the user home, which the starter policy write-protects. */
505
+ function runTrust(args) {
506
+ requireInteractive("trust", args);
507
+ const dir = join(process.cwd(), ".scopebond");
508
+ if (!existsSync(join(dir, "policy.json"))) {
509
+ console.error(`no project policy at ${join(dir, "policy.json")}`);
510
+ process.exit(1);
511
+ }
512
+ const digest = trustProjectPolicy(dir);
513
+ console.log(`✓ trusted ${join(dir, "policy.json")} (sha256 ${digest.slice(0, 12)}…)`);
514
+ console.log("It governs agents in this project until it changes; after any edit, review it and run trust again.");
515
+ }
429
516
  function runLogin() {
430
517
  console.log("Device-code login is not available yet.");
431
518
  console.log(`For now, connect with a one-time enrollment from your workspace:`);
@@ -439,6 +526,9 @@ if (cmd === "claude") {
439
526
  else if (cmd === "cursor") {
440
527
  await runCursor();
441
528
  }
529
+ else if (cmd === "codex") {
530
+ await runCodex();
531
+ }
442
532
  else if (cmd === "init") {
443
533
  runInit(rest);
444
534
  }
@@ -472,8 +562,11 @@ else if (cmd === "uninstall") {
472
562
  else if (cmd === "login") {
473
563
  runLogin();
474
564
  }
565
+ else if (cmd === "trust") {
566
+ runTrust(rest);
567
+ }
475
568
  else {
476
- console.error("usage: scopebond <claude|cursor|init|install|connect|log|verify|test|flush|status|doctor|uninstall|login> [--cursor] [--claude] [--force] [--strict] [--no-install] [--purge]");
569
+ console.error("usage: scopebond <claude|cursor|codex|init|install|connect|log|verify|test|flush|status|doctor|uninstall|login|trust> [--cursor] [--claude] [--codex] [--force] [--strict] [--no-install] [--purge] [--yes]");
477
570
  process.exit(1);
478
571
  }
479
572
  //# sourceMappingURL=cli.js.map