@scopebond/hook 0.6.0 → 0.7.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 +87 -6
- package/dist/cli.d.ts +2 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +612 -70
- package/dist/cli.js.map +1 -1
- package/dist/cloud.d.ts +2 -0
- package/dist/cloud.d.ts.map +1 -1
- package/dist/cloud.js +1 -1
- package/dist/cloud.js.map +1 -1
- package/dist/explain.d.ts +44 -0
- package/dist/explain.d.ts.map +1 -0
- package/dist/explain.js +75 -0
- package/dist/explain.js.map +1 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +9 -3
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +35 -20
- package/dist/init.js.map +1 -1
- package/dist/install.d.ts +45 -2
- package/dist/install.d.ts.map +1 -1
- package/dist/install.js +158 -9
- package/dist/install.js.map +1 -1
- package/dist/map.d.ts +6 -0
- package/dist/map.d.ts.map +1 -1
- package/dist/map.js +4 -1
- package/dist/map.js.map +1 -1
- package/dist/rules.d.ts +51 -0
- package/dist/rules.d.ts.map +1 -0
- package/dist/rules.js +216 -0
- package/dist/rules.js.map +1 -0
- package/dist/runtime-install.d.ts +25 -0
- package/dist/runtime-install.d.ts.map +1 -0
- package/dist/runtime-install.js +106 -0
- package/dist/runtime-install.js.map +1 -0
- package/dist/runtime.d.ts +13 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +52 -7
- package/dist/runtime.js.map +1 -1
- package/dist/version.d.ts +5 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +6 -2
- package/dist/version.js.map +1 -1
- package/package.json +3 -3
package/dist/cli.js
CHANGED
|
@@ -2,21 +2,13 @@
|
|
|
2
2
|
// scopebond-hook — govern a coding agent's tool calls against policy, in-path,
|
|
3
3
|
// before they run, with a signed local receipt.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// scopebond-hook init [--cursor|--codex] [--no-install] [--yes]
|
|
9
|
-
// scopebond-hook connect <url> <bundle.json> [--cursor|--codex]
|
|
10
|
-
// enroll with a Cloud workspace and start exporting
|
|
11
|
-
// scopebond-hook log [-n N] show the most recent local receipts
|
|
12
|
-
// scopebond-hook verify verify every local receipt offline against the attester key
|
|
13
|
-
// scopebond-hook test "<shell command>" show the decision for a command without recording it
|
|
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)
|
|
5
|
+
// The command list, arguments and examples live in `COMMANDS` near the bottom of this
|
|
6
|
+
// file, which is what `scopebond-hook help [command]` prints — one source rather than a
|
|
7
|
+
// comment here that drifts from it.
|
|
16
8
|
//
|
|
17
9
|
// Config dir: $SCOPEBOND_HOOK_DIR, else ./.scopebond
|
|
18
10
|
// Fail-closed: any error denies the action with a repair message.
|
|
19
|
-
import { readFileSync, existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
|
|
20
12
|
import { join } from "node:path";
|
|
21
13
|
import { tmpdir } from "node:os";
|
|
22
14
|
import { execFileSync } from "node:child_process";
|
|
@@ -25,8 +17,12 @@ import { openReceiptStore, loadOrCreateAttester } from "@scopebond/gateway/node"
|
|
|
25
17
|
import { mapClaudeToolUse, mapCodexToolUse, mapCursorEvent, fillPushBranch } from "./map.js";
|
|
26
18
|
import { createHookRuntime } from "./runtime.js";
|
|
27
19
|
import { scaffold, harnessSnippet, installHarness } from "./init.js";
|
|
28
|
-
import { userHome, userHarnessFile, resolveConfigDir, writeHarnessConfig, removeHarnessConfig, cursorDetected, codexDetected, absoluteHookCommand, isHarnessConfigured, purgeHome, trustProjectPolicy, untrustedProjectPolicy, } from "./install.js";
|
|
20
|
+
import { userHome, userHarnessFile, resolveConfigDir, writeHarnessConfig, removeHarnessConfig, cursorDetected, codexDetected, absoluteHookCommand, isHarnessConfigured, purgeHome, harnessScopes, harnessScopeLabel, configuredHookCommands, hookCommandResolves, projectHarnessFile, trustProjectPolicy, untrustedProjectPolicy, } from "./install.js";
|
|
29
21
|
import { connectCloud, loadConnection } from "./cloud.js";
|
|
22
|
+
import { compile, defaultRules, describeRules, loadRules, saveRules, rulesPath, pathRuleFor } from "./rules.js";
|
|
23
|
+
import { createSigner } from "@scopebond/sdk";
|
|
24
|
+
import { describeAction } from "./explain.js";
|
|
25
|
+
import { ensureDurableRuntime } from "./runtime-install.js";
|
|
30
26
|
import { cliCommand, hookVersion } from "./version.js";
|
|
31
27
|
import { fileURLToPath } from "node:url";
|
|
32
28
|
/** The current git branch in `cwd` (best-effort). A bare `git push` pushes it, so
|
|
@@ -92,7 +88,9 @@ function denyClaude(reason) {
|
|
|
92
88
|
process.stdout.write(JSON.stringify({
|
|
93
89
|
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason },
|
|
94
90
|
}) + "\n");
|
|
95
|
-
|
|
91
|
+
// A composed explanation already names Scopebond in its first line; only the
|
|
92
|
+
// bare internal messages need the prefix.
|
|
93
|
+
process.stderr.write(`${reason.startsWith("Scopebond") ? reason : `Scopebond: ${reason}`}\n`);
|
|
96
94
|
process.exit(2);
|
|
97
95
|
}
|
|
98
96
|
/** Codex accepts the structured deny response on a successful hook exit. Keeping
|
|
@@ -108,6 +106,18 @@ const harnessName = (harness) => harness === "claude" ? "Claude Code" : harness
|
|
|
108
106
|
const harnessFileName = (harness) => harness === "claude" ? ".claude/settings.json" : harness === "cursor" ? ".cursor/hooks.json" : ".codex/hooks.json";
|
|
109
107
|
const selectedHarness = (args) => args.includes("--codex") ? "codex" : args.includes("--cursor") ? "cursor" : "claude";
|
|
110
108
|
const codexTrustStep = "Open Codex, run `/hooks`, review Scopebond, and choose Trust. Then start a new task.";
|
|
109
|
+
/** What Cursor can and cannot stop. Cursor has before-hooks for shell commands, MCP
|
|
110
|
+
* calls and file reads, but reports file *edits* only after they are written, so an
|
|
111
|
+
* out-of-policy edit is recorded and flagged rather than prevented. Said at install
|
|
112
|
+
* time, because someone choosing a guardrail needs to know its edges up front. */
|
|
113
|
+
const cursorCoverageNote = [
|
|
114
|
+
"What this covers in Cursor:",
|
|
115
|
+
" prevented shell commands, MCP tool calls, file reads — checked before they run",
|
|
116
|
+
" recorded file edits — Cursor reports an edit only after writing it, so an",
|
|
117
|
+
" out-of-policy edit is signed and flagged, not blocked",
|
|
118
|
+
"For edits that must be blocked before they land, use the GitHub Action as a required",
|
|
119
|
+
"check on pull requests.",
|
|
120
|
+
].join("\n");
|
|
111
121
|
async function runPreToolUse(mapper, deny = denyClaude) {
|
|
112
122
|
let input;
|
|
113
123
|
try {
|
|
@@ -116,11 +126,16 @@ async function runPreToolUse(mapper, deny = denyClaude) {
|
|
|
116
126
|
catch {
|
|
117
127
|
deny("hook received invalid JSON on stdin");
|
|
118
128
|
}
|
|
129
|
+
let runtime;
|
|
119
130
|
try {
|
|
120
131
|
const cwd = input?.cwd ? String(input.cwd) : process.cwd();
|
|
121
|
-
|
|
132
|
+
runtime = createHookRuntime(runtimePaths(resolveConfigDir(cwd)));
|
|
122
133
|
const decision = await runtime.evaluate(fillPushBranch(mapper(input), currentBranch(cwd)));
|
|
123
134
|
await runtime.flush();
|
|
135
|
+
// Close before deciding: the receipt is already committed, and leaving the handle
|
|
136
|
+
// open is what made the write-ahead log grow without bound.
|
|
137
|
+
runtime.close();
|
|
138
|
+
runtime = undefined;
|
|
124
139
|
if (decision.decision === "deny")
|
|
125
140
|
deny(decision.reason);
|
|
126
141
|
// Stay silent on allow/not_evaluated so the coding agent's normal permission
|
|
@@ -128,6 +143,10 @@ async function runPreToolUse(mapper, deny = denyClaude) {
|
|
|
128
143
|
process.exit(0);
|
|
129
144
|
}
|
|
130
145
|
catch (error) {
|
|
146
|
+
try {
|
|
147
|
+
runtime?.close();
|
|
148
|
+
}
|
|
149
|
+
catch { /* already failing; the deny below is what matters */ }
|
|
131
150
|
deny(`Scopebond hook failed closed: ${error.message}. Repair: run \`${cliCommand("init")}\`.`);
|
|
132
151
|
}
|
|
133
152
|
}
|
|
@@ -137,29 +156,68 @@ async function runClaude() {
|
|
|
137
156
|
async function runCodex() {
|
|
138
157
|
await runPreToolUse(mapCodexToolUse, denyCodex);
|
|
139
158
|
}
|
|
159
|
+
function denyCursor(reason) {
|
|
160
|
+
process.stdout.write(JSON.stringify({ permission: "deny", agentMessage: reason }) + "\n");
|
|
161
|
+
process.exit(0);
|
|
162
|
+
}
|
|
140
163
|
async function runCursor() {
|
|
141
|
-
|
|
164
|
+
// Unparseable input denies, like every other adapter. Previously this fell through
|
|
165
|
+
// to evaluation with an empty payload, which mapped to no known action and so
|
|
166
|
+
// answered "ask" — handing an unreadable request to a prompt the user would very
|
|
167
|
+
// likely accept. Deny-by-default on unparseable input is not optional.
|
|
168
|
+
let parsed;
|
|
142
169
|
try {
|
|
143
|
-
|
|
170
|
+
parsed = JSON.parse(readStdin());
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
denyCursor("Scopebond: hook received invalid JSON on stdin");
|
|
174
|
+
}
|
|
175
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
176
|
+
denyCursor("Scopebond: hook received a payload that is not a JSON object");
|
|
144
177
|
}
|
|
145
|
-
|
|
146
|
-
const event = String(input
|
|
178
|
+
const input = parsed;
|
|
179
|
+
const event = String(input.hook_event_name ?? input.event ?? process.argv[3] ?? "");
|
|
147
180
|
let permission = "deny";
|
|
148
181
|
let message = "Scopebond hook failed closed";
|
|
182
|
+
let postHoc = false;
|
|
183
|
+
let runtime;
|
|
149
184
|
try {
|
|
150
185
|
const cwd = input?.cwd ? String(input.cwd) : process.cwd();
|
|
151
|
-
|
|
152
|
-
const
|
|
186
|
+
runtime = createHookRuntime(runtimePaths(resolveConfigDir(cwd)));
|
|
187
|
+
const mapped = fillPushBranch(mapCursorEvent(event, input), currentBranch(cwd));
|
|
188
|
+
const decision = await runtime.evaluate(mapped);
|
|
153
189
|
await runtime.flush();
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
190
|
+
// An `afterFileEdit` violation is real and recorded, but the edit has already
|
|
191
|
+
// landed. Say so rather than letting "blocked" imply it was stopped.
|
|
192
|
+
postHoc = mapped.some((m) => m.postHoc);
|
|
193
|
+
// Three outcomes, three answers:
|
|
194
|
+
// deny — out of policy, blocked outright.
|
|
195
|
+
// allow — a rule was evaluated and permitted it. Returning "ask" here put a
|
|
196
|
+
// confirmation prompt in front of every ordinary command, which is not
|
|
197
|
+
// "your agent works as normal"; it also trained people to click through
|
|
198
|
+
// prompts, which makes the real denials easier to miss. An evaluated
|
|
199
|
+
// allow is a decision, not a silent auto-approval.
|
|
200
|
+
// ask — nothing was evaluated (no rule covers this action), so Cursor's own
|
|
201
|
+
// permission flow stays in charge. That is the fail-closed case and it
|
|
202
|
+
// keeps its prompt.
|
|
203
|
+
permission = decision.decision === "deny" ? "deny" : decision.decision === "allow" ? "allow" : "ask";
|
|
157
204
|
message = decision.reason;
|
|
158
205
|
}
|
|
159
206
|
catch (error) {
|
|
160
207
|
permission = "deny";
|
|
161
208
|
message = `Scopebond hook failed closed: ${error.message}. Repair: run \`${cliCommand("init")}\`.`;
|
|
162
209
|
}
|
|
210
|
+
finally {
|
|
211
|
+
// Release the SQLite handles on every path: an unclosed writer leaves its
|
|
212
|
+
// write-ahead log behind for the next tool call to extend.
|
|
213
|
+
try {
|
|
214
|
+
runtime?.close();
|
|
215
|
+
}
|
|
216
|
+
catch { /* the decision is already recorded */ }
|
|
217
|
+
}
|
|
218
|
+
if (postHoc && permission === "deny") {
|
|
219
|
+
message = `${message}\nCursor reports a file edit only after it is written, so this edit was not prevented. Review and revert it yourself.`;
|
|
220
|
+
}
|
|
163
221
|
process.stdout.write(JSON.stringify({ permission, agentMessage: message }) + "\n");
|
|
164
222
|
process.exit(0);
|
|
165
223
|
}
|
|
@@ -170,31 +228,49 @@ async function runCursor() {
|
|
|
170
228
|
function requireInteractive(command, args) {
|
|
171
229
|
if (process.stdin.isTTY || args.includes("--yes"))
|
|
172
230
|
return;
|
|
173
|
-
console.error(`scopebond ${command} changes what governs your coding agent, so it
|
|
174
|
-
console.error(`
|
|
231
|
+
console.error(`scopebond ${command} changes what governs your coding agent, so it does not run unattended.`);
|
|
232
|
+
console.error(`There is no terminal on stdin here — which is also what it looks like when the agent itself`);
|
|
233
|
+
console.error(`tries to run this, so the refusal is deliberate rather than a bug.`);
|
|
234
|
+
console.error(``);
|
|
235
|
+
console.error(`If you are a person: run it in your own terminal, or confirm it now with --yes:`);
|
|
236
|
+
console.error(` ${cliCommand(`${command} --yes`)}`);
|
|
237
|
+
console.error(`Scripts, CI and container builds should always pass --yes.`);
|
|
175
238
|
process.exit(1);
|
|
176
239
|
}
|
|
177
240
|
function runInit(args) {
|
|
178
241
|
requireInteractive("init", args);
|
|
179
242
|
const harness = selectedHarness(args);
|
|
180
243
|
const dir = configDir();
|
|
181
|
-
const { agentKid, policyPath } = scaffold(dir, { force: args.includes("--force") });
|
|
244
|
+
const { agentKid, policyPath, rulesPath: rulesFile } = scaffold(dir, { force: args.includes("--force") });
|
|
182
245
|
console.log(`Scopebond hook enrolled in ${dir}`);
|
|
183
246
|
console.log(` machine key ${agentKid}`);
|
|
184
|
-
console.log(`
|
|
247
|
+
console.log(` rules ${rulesFile} (the readable list — edit this)`);
|
|
248
|
+
console.log(` policy ${policyPath} (compiled from the rules; don't hand-edit)`);
|
|
185
249
|
// With a user-level install present, a project policy governs only once trusted.
|
|
186
250
|
// Running init here is that decision, so pin this policy now.
|
|
187
251
|
if (!process.env.SCOPEBOND_HOOK_DIR && existsSync(join(userHome(), "policy.json"))) {
|
|
188
252
|
trustProjectPolicy(dir);
|
|
189
253
|
console.log(` trusted overrides ${userHome()} here; after editing it, run \`${cliCommand("trust")}\``);
|
|
190
254
|
}
|
|
255
|
+
// The hook command runs once per tool call, so it must start fast. `npx` re-resolves
|
|
256
|
+
// a package that is already on disk and costs ~830 ms a call; the same CLI invoked
|
|
257
|
+
// directly costs ~110 ms. Pin a durable copy and use that, and fall back to `npx`
|
|
258
|
+
// (slow, but it always starts) when no durable copy can be made. `--npx` forces the
|
|
259
|
+
// portable form for anyone who wants it.
|
|
260
|
+
const pin = args.includes("--npx") ? { cli: null, how: "unavailable" } : ensureDurableRuntime(cliPath(), hookVersion());
|
|
261
|
+
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`}`);
|
|
191
267
|
console.log("");
|
|
192
268
|
// Configure the agent automatically by default (idempotent), so there is no
|
|
193
269
|
// hand-editing step; --no-install prints the snippet instead.
|
|
194
270
|
if (!args.includes("--no-install")) {
|
|
195
271
|
let file;
|
|
196
272
|
try {
|
|
197
|
-
file = installHarness(harness);
|
|
273
|
+
file = installHarness(harness, process.cwd(), command);
|
|
198
274
|
}
|
|
199
275
|
catch (error) {
|
|
200
276
|
console.error(error.message);
|
|
@@ -203,10 +279,14 @@ function runInit(args) {
|
|
|
203
279
|
console.log(`✓ ${harnessName(harness)} configured in ${file}`);
|
|
204
280
|
if (harness === "codex")
|
|
205
281
|
console.log(`\nOne last step: ${codexTrustStep}`);
|
|
282
|
+
if (harness === "cursor")
|
|
283
|
+
console.log(`\n${cursorCoverageNote}`);
|
|
206
284
|
}
|
|
207
285
|
else {
|
|
208
286
|
console.log(`Add this to your ${harnessFileName(harness)}:`);
|
|
209
|
-
console.log(harnessSnippet(harness));
|
|
287
|
+
console.log(harnessSnippet(harness, command));
|
|
288
|
+
if (harness === "cursor")
|
|
289
|
+
console.log(`\n${cursorCoverageNote}`);
|
|
210
290
|
}
|
|
211
291
|
console.log("");
|
|
212
292
|
// Print the runnable `npx` form: after `npx @scopebond/hook init` there is no
|
|
@@ -225,15 +305,14 @@ function decisionOf(payload) {
|
|
|
225
305
|
return "allow";
|
|
226
306
|
}
|
|
227
307
|
function describeIntent(payload) {
|
|
228
|
-
|
|
229
|
-
const p = (intent.params ?? {});
|
|
230
|
-
const bits = intent.action_type === "shell.exec" ? String(p.program ?? "")
|
|
231
|
-
: intent.action_type === "git.push" ? `${p.remote ?? ""} ${p.ref ?? ""}`.trim()
|
|
232
|
-
: intent.action_type === "file.write" || intent.action_type === "file.read" ? String(p.path ?? "")
|
|
233
|
-
: intent.action_type === "mcp.tool.call" ? `${p.server ?? ""}/${p.tool ?? ""}`
|
|
234
|
-
: intent.action_type === "net.fetch" ? String(p.host ?? "") : "";
|
|
235
|
-
return `${String(intent.action_type ?? "?")}${bits ? ` ${bits}` : ""}`;
|
|
308
|
+
return describeAction(payload.intent);
|
|
236
309
|
}
|
|
310
|
+
/** `log [-n N] [--deny] [--since <when>]` — the recent decisions.
|
|
311
|
+
*
|
|
312
|
+
* "What did my agents get blocked on this week" had no answer at the CLI: the only
|
|
313
|
+
* output was an unfiltered tail. `--deny` and `--since` make it answerable, and the
|
|
314
|
+
* tail is read as a tail (`ORDER BY id DESC LIMIT`) rather than by loading and parsing
|
|
315
|
+
* every receipt ever recorded and slicing the end off. */
|
|
237
316
|
async function runLog(args) {
|
|
238
317
|
const dir = resolveConfigDir(process.cwd());
|
|
239
318
|
const dbPath = join(dir, "receipts.db");
|
|
@@ -243,18 +322,259 @@ async function runLog(args) {
|
|
|
243
322
|
}
|
|
244
323
|
const nIdx = args.indexOf("-n");
|
|
245
324
|
const n = nIdx >= 0 ? Math.max(1, Number(args[nIdx + 1]) || 20) : 20;
|
|
325
|
+
const denyOnly = args.includes("--deny");
|
|
326
|
+
const sinceIdx = args.indexOf("--since");
|
|
327
|
+
const since = sinceIdx >= 0 ? parseSince(args[sinceIdx + 1]) : null;
|
|
328
|
+
if (sinceIdx >= 0 && since === null) {
|
|
329
|
+
console.error(`--since wants a duration (7d, 24h, 30m) or a date (2026-09-25); got ${args[sinceIdx + 1] ?? "nothing"}`);
|
|
330
|
+
process.exit(1);
|
|
331
|
+
}
|
|
246
332
|
const { store } = openReceiptStore({ db: dbPath });
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
333
|
+
try {
|
|
334
|
+
const total = store.count ? await Promise.resolve(store.count()) : (await Promise.resolve(store.list())).length;
|
|
335
|
+
// A filter has to look past the last N to find N matches; without one, read only the
|
|
336
|
+
// tail. The scan is still bounded so a huge log cannot hang the command.
|
|
337
|
+
const budget = denyOnly || since ? Math.min(Math.max(n * 50, 1000), 20000) : n;
|
|
338
|
+
const scanned = store.recent
|
|
339
|
+
? await Promise.resolve(store.recent(budget))
|
|
340
|
+
: (await Promise.resolve(store.list())).slice(-budget).reverse();
|
|
341
|
+
const matching = scanned.filter((r) => {
|
|
342
|
+
const p = r.payload;
|
|
343
|
+
if (denyOnly && decisionOf(p) !== "deny")
|
|
344
|
+
return false;
|
|
345
|
+
if (since) {
|
|
346
|
+
const at = Date.parse(String(p.timestamp ?? ""));
|
|
347
|
+
if (!Number.isFinite(at) || at < since)
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
return true;
|
|
351
|
+
});
|
|
352
|
+
const shown = matching.slice(0, n).reverse(); // oldest-first on screen, newest last
|
|
353
|
+
if (shown.length === 0) {
|
|
354
|
+
console.log(denyOnly || since ? "no receipts match that filter." : "no receipts yet.");
|
|
355
|
+
process.exit(0);
|
|
356
|
+
}
|
|
357
|
+
for (const r of shown) {
|
|
358
|
+
const p = r.payload;
|
|
359
|
+
console.log(`${String(p.timestamp ?? "")} ${decisionOf(p).padEnd(13)} ${describeIntent(p)}`);
|
|
360
|
+
}
|
|
361
|
+
const filters = [denyOnly ? "denied" : "", since ? `since ${new Date(since).toISOString()}` : ""].filter(Boolean).join(", ");
|
|
362
|
+
const scope = filters ? ` matching ${filters}` : "";
|
|
363
|
+
const capped = (denyOnly || since) && scanned.length >= budget && total > budget;
|
|
364
|
+
console.log(`\n${shown.length}${scope} of ${total} receipt(s)${capped ? ` — searched the most recent ${budget}` : ""}. Verify them: ${cliCommand("verify")}`);
|
|
365
|
+
}
|
|
366
|
+
finally {
|
|
367
|
+
try {
|
|
368
|
+
store.close?.();
|
|
369
|
+
}
|
|
370
|
+
catch { /* read-only */ }
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/** `rules` — read and change the limits in plain terms.
|
|
374
|
+
*
|
|
375
|
+
* Without this, "edit the limits" meant hand-writing a ~700-character case-folded
|
|
376
|
+
* negative lookahead, which nobody does — so the starter policy was effectively the only
|
|
377
|
+
* policy. The lists live in `.scopebond/rules.json`; `policy.json` is compiled from them. */
|
|
378
|
+
function runRules(args) {
|
|
379
|
+
const dir = resolveConfigDir(process.cwd());
|
|
380
|
+
if (!existsSync(join(dir, "policy.json"))) {
|
|
381
|
+
console.error(`no policy here yet — run \`${cliCommand("init")}\` first.`);
|
|
382
|
+
process.exit(1);
|
|
383
|
+
}
|
|
384
|
+
const rules = loadRules(dir) ?? defaultRules();
|
|
385
|
+
const [verb, ...values] = args.filter((a) => !a.startsWith("--"));
|
|
386
|
+
const value = values.join(" ").trim();
|
|
387
|
+
if (!verb || verb === "show") {
|
|
388
|
+
console.log(`Rules for ${dir}`);
|
|
389
|
+
if (!loadRules(dir))
|
|
390
|
+
console.log(`(showing the defaults — ${rulesPath(dir)} will be written on your first change)`);
|
|
391
|
+
console.log("");
|
|
392
|
+
console.log(describeRules(rules));
|
|
393
|
+
console.log("");
|
|
394
|
+
console.log(`Change them:`);
|
|
395
|
+
console.log(` ${cliCommand("rules allow <program>")} stop blocking a program`);
|
|
396
|
+
console.log(` ${cliCommand("rules block <program>")} start blocking one`);
|
|
397
|
+
console.log(` ${cliCommand("rules protect <path>")} never write there`);
|
|
398
|
+
console.log(` ${cliCommand("rules unprotect <path>")} allow writing there again`);
|
|
399
|
+
console.log(` ${cliCommand("rules protect-branch <name>")} never push there`);
|
|
400
|
+
console.log(` ${cliCommand("rules unprotect-branch <name>")}`);
|
|
401
|
+
console.log(`Or edit ${rulesPath(dir)} directly, then \`${cliCommand("rules apply")}\`.`);
|
|
402
|
+
process.exit(0);
|
|
403
|
+
}
|
|
404
|
+
const needsValue = ["allow", "block", "protect", "unprotect", "protect-branch", "unprotect-branch"];
|
|
405
|
+
if (needsValue.includes(verb) && !value) {
|
|
406
|
+
console.error(`\`rules ${verb}\` needs a value, e.g. \`${cliCommand(`rules ${verb} ${verb.includes("branch") ? "production" : verb.includes("protect") ? "infra/" : "dd"}`)}\``);
|
|
407
|
+
process.exit(1);
|
|
408
|
+
}
|
|
409
|
+
let changed = "";
|
|
410
|
+
switch (verb) {
|
|
411
|
+
case "allow": {
|
|
412
|
+
const before = rules.destructive_programs.length;
|
|
413
|
+
rules.destructive_programs = rules.destructive_programs.filter((p) => p.toLowerCase() !== value.toLowerCase());
|
|
414
|
+
if (rules.destructive_programs.length === before) {
|
|
415
|
+
console.log(`${value} was not in the blocked list; nothing to change.`);
|
|
416
|
+
process.exit(0);
|
|
417
|
+
}
|
|
418
|
+
changed = `${value} is no longer blocked`;
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
case "block": {
|
|
422
|
+
if (rules.destructive_programs.some((p) => p.toLowerCase() === value.toLowerCase())) {
|
|
423
|
+
console.log(`${value} is already blocked.`);
|
|
424
|
+
process.exit(0);
|
|
425
|
+
}
|
|
426
|
+
rules.destructive_programs.push(value.toLowerCase());
|
|
427
|
+
changed = `${value} is now blocked`;
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
case "protect":
|
|
431
|
+
case "unprotect": {
|
|
432
|
+
const list = verb === "protect" ? "protected_write" : "protected_write";
|
|
433
|
+
const rule = pathRuleFor(value);
|
|
434
|
+
if (verb === "protect") {
|
|
435
|
+
if (rules[list].some((r) => r.label === rule.label)) {
|
|
436
|
+
console.log(`${value} is already protected.`);
|
|
437
|
+
process.exit(0);
|
|
438
|
+
}
|
|
439
|
+
rules[list].push(rule);
|
|
440
|
+
changed = `writes to ${rule.label} are now blocked`;
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
const before = rules[list].length;
|
|
444
|
+
rules[list] = rules[list].filter((r) => r.label !== rule.label && !r.label.startsWith(`${value.replace(/\/$/, "")}/`));
|
|
445
|
+
if (rules[list].length === before) {
|
|
446
|
+
console.log(`${value} is not in the protected list. Current list:`);
|
|
447
|
+
for (const r of rules[list])
|
|
448
|
+
console.log(` ${r.label}`);
|
|
449
|
+
process.exit(1);
|
|
450
|
+
}
|
|
451
|
+
changed = `writes to ${value} are allowed again`;
|
|
452
|
+
}
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
case "protect-branch": {
|
|
456
|
+
if (rules.protected_branches.some((b) => b.toLowerCase() === value.toLowerCase())) {
|
|
457
|
+
console.log(`${value} is already protected.`);
|
|
458
|
+
process.exit(0);
|
|
459
|
+
}
|
|
460
|
+
rules.protected_branches.push(value);
|
|
461
|
+
changed = `pushes to ${value} are now blocked`;
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
case "unprotect-branch": {
|
|
465
|
+
const before = rules.protected_branches.length;
|
|
466
|
+
rules.protected_branches = rules.protected_branches.filter((b) => b.toLowerCase() !== value.toLowerCase());
|
|
467
|
+
if (rules.protected_branches.length === before) {
|
|
468
|
+
console.log(`${value} was not protected; nothing to change.`);
|
|
469
|
+
process.exit(0);
|
|
470
|
+
}
|
|
471
|
+
changed = `pushes to ${value} are allowed again`;
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
case "apply":
|
|
475
|
+
changed = `recompiled from ${rulesPath(dir)}`;
|
|
476
|
+
break;
|
|
477
|
+
default:
|
|
478
|
+
console.error(`unknown: rules ${verb}`);
|
|
479
|
+
printHelp("rules", true);
|
|
480
|
+
process.exit(1);
|
|
481
|
+
}
|
|
482
|
+
// Changing what governs the agent is the same class of action as `init`.
|
|
483
|
+
requireInteractive("rules", args);
|
|
484
|
+
const policyPath = join(dir, "policy.json");
|
|
485
|
+
const agentKid = createSigner({ privateKeyPem: readFileSync(join(dir, "agent.key"), "utf8") }).kid;
|
|
486
|
+
saveRules(dir, rules);
|
|
487
|
+
writeFileSync(policyPath, `${JSON.stringify(compile(rules, agentKid), null, 2)}\n`);
|
|
488
|
+
console.log(`✓ ${changed}`);
|
|
489
|
+
console.log(` rules ${rulesPath(dir)}`);
|
|
490
|
+
console.log(` policy ${policyPath} (recompiled)`);
|
|
491
|
+
// A project policy governs only once trusted, and the hash just changed.
|
|
492
|
+
if (!process.env.SCOPEBOND_HOOK_DIR && existsSync(join(userHome(), "policy.json"))) {
|
|
493
|
+
trustProjectPolicy(dir);
|
|
494
|
+
console.log(` trusted re-pinned for this project`);
|
|
495
|
+
}
|
|
496
|
+
console.log(`\nCheck it: ${cliCommand('test "rm -rf /"')}`);
|
|
497
|
+
process.exit(0);
|
|
498
|
+
}
|
|
499
|
+
/** `prune --before <when> [--yes]` — bound the local receipt store.
|
|
500
|
+
*
|
|
501
|
+
* Signed receipts are the product, so this never runs on its own and never quietly
|
|
502
|
+
* destroys anything: it archives what it will remove to a JSONL file beside the
|
|
503
|
+
* database first, and it refuses entirely once the log has been anchored, because a
|
|
504
|
+
* receipt's position is its anchor leaf index. Without a `--before` it reports the
|
|
505
|
+
* footprint and exits. */
|
|
506
|
+
async function runPrune(args) {
|
|
507
|
+
const dir = resolveConfigDir(process.cwd());
|
|
508
|
+
const dbPath = join(dir, "receipts.db");
|
|
509
|
+
if (!existsSync(dbPath)) {
|
|
510
|
+
console.log("no local receipts yet — nothing to prune.");
|
|
511
|
+
process.exit(0);
|
|
512
|
+
}
|
|
513
|
+
const beforeIdx = args.indexOf("--before");
|
|
514
|
+
if (beforeIdx < 0) {
|
|
515
|
+
console.log(`local receipts ${dbPath}`);
|
|
516
|
+
console.log(` ${describeStore(dbPath)}`);
|
|
517
|
+
console.log(`\nNothing is removed automatically. To bound it, name a cutoff:`);
|
|
518
|
+
console.log(` ${cliCommand("prune --before 90d")} # older than 90 days`);
|
|
519
|
+
console.log(` ${cliCommand("prune --before 2026-01-01")}`);
|
|
520
|
+
console.log(`Receipts are archived beside the database before removal.`);
|
|
251
521
|
process.exit(0);
|
|
252
522
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
console.
|
|
523
|
+
const cutoff = parseSince(args[beforeIdx + 1]);
|
|
524
|
+
if (cutoff === null) {
|
|
525
|
+
console.error(`--before wants a duration (90d, 24h) or a date (2026-01-01); got ${args[beforeIdx + 1] ?? "nothing"}`);
|
|
526
|
+
process.exit(1);
|
|
527
|
+
}
|
|
528
|
+
const iso = new Date(cutoff).toISOString();
|
|
529
|
+
const { store } = openReceiptStore({ db: dbPath });
|
|
530
|
+
const sqlite = store;
|
|
531
|
+
if (!sqlite.before || !sqlite.removeBefore) {
|
|
532
|
+
console.error("this receipt store does not support pruning (no SQLite available).");
|
|
533
|
+
process.exit(1);
|
|
534
|
+
}
|
|
535
|
+
try {
|
|
536
|
+
const doomed = sqlite.before(iso);
|
|
537
|
+
if (doomed.length === 0) {
|
|
538
|
+
console.log(`no receipts older than ${iso}.`);
|
|
539
|
+
process.exit(0);
|
|
540
|
+
}
|
|
541
|
+
const before = describeStore(dbPath);
|
|
542
|
+
console.log(`${doomed.length} receipt(s) recorded before ${iso} (store is currently ${before}).`);
|
|
543
|
+
if (!args.includes("--yes") && !process.stdin.isTTY) {
|
|
544
|
+
console.error(`\nThis removes signed evidence, so it needs an explicit confirmation:`);
|
|
545
|
+
console.error(` ${cliCommand(`prune --before ${args[beforeIdx + 1]} --yes`)}`);
|
|
546
|
+
process.exit(1);
|
|
547
|
+
}
|
|
548
|
+
const archive = join(dir, `receipts-archived-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`);
|
|
549
|
+
writeFileSync(archive, `${doomed.map((r) => JSON.stringify(r)).join("\n")}\n`);
|
|
550
|
+
console.log(`archived to ${archive}`);
|
|
551
|
+
const { removed } = sqlite.removeBefore(iso);
|
|
552
|
+
console.log(`removed ${removed} receipt(s)`);
|
|
553
|
+
store.close?.();
|
|
554
|
+
console.log(`store now ${describeStore(dbPath)}`);
|
|
555
|
+
console.log(`\nThe archive is a plain JSONL of signed receipts — still verifiable, still yours.`);
|
|
556
|
+
process.exit(0);
|
|
557
|
+
}
|
|
558
|
+
catch (error) {
|
|
559
|
+
try {
|
|
560
|
+
store.close?.();
|
|
561
|
+
}
|
|
562
|
+
catch { /* closing after a failure */ }
|
|
563
|
+
console.error(`prune refused: ${error.message}`);
|
|
564
|
+
process.exit(1);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
/** A `--since` value: a duration (`7d`, `24h`, `30m`) or an ISO-ish date. */
|
|
568
|
+
export function parseSince(value, now = Date.now()) {
|
|
569
|
+
if (!value)
|
|
570
|
+
return null;
|
|
571
|
+
const duration = /^(\d+)\s*([dhm])$/i.exec(value.trim());
|
|
572
|
+
if (duration) {
|
|
573
|
+
const scale = { d: 86_400_000, h: 3_600_000, m: 60_000 }[duration[2].toLowerCase()];
|
|
574
|
+
return now - Number(duration[1]) * scale;
|
|
256
575
|
}
|
|
257
|
-
|
|
576
|
+
const at = Date.parse(value);
|
|
577
|
+
return Number.isFinite(at) ? at : null;
|
|
258
578
|
}
|
|
259
579
|
async function runVerify() {
|
|
260
580
|
const dir = resolveConfigDir(process.cwd());
|
|
@@ -266,10 +586,18 @@ async function runVerify() {
|
|
|
266
586
|
}
|
|
267
587
|
const { attester } = loadOrCreateAttester({ file: attesterPath });
|
|
268
588
|
const { store } = openReceiptStore({ db: dbPath });
|
|
589
|
+
// Verification is the one command that must read everything — that is the point of it.
|
|
590
|
+
// It just should not look hung while doing so: at ~2.6 s per 20,000 receipts a long
|
|
591
|
+
// history is a visible wait, so report progress on a TTY.
|
|
269
592
|
const all = await Promise.resolve(store.list());
|
|
593
|
+
try {
|
|
594
|
+
store.close?.();
|
|
595
|
+
}
|
|
596
|
+
catch { /* read-only */ }
|
|
597
|
+
const progress = process.stdout.isTTY && all.length >= 2000;
|
|
270
598
|
let ok = 0;
|
|
271
599
|
const bad = [];
|
|
272
|
-
for (const r of all) {
|
|
600
|
+
for (const [index, r] of all.entries()) {
|
|
273
601
|
const result = verifyReceipt(r, attester.publicKeyPem);
|
|
274
602
|
if (result.valid)
|
|
275
603
|
ok += 1;
|
|
@@ -277,7 +605,11 @@ async function runVerify() {
|
|
|
277
605
|
const failed = Object.entries(result).filter(([k, v]) => k.endsWith("_valid") && v === false).map(([k]) => k);
|
|
278
606
|
bad.push(`${String(r.payload.timestamp ?? "")}: ${failed.join(", ") || "invalid"}`);
|
|
279
607
|
}
|
|
608
|
+
if (progress && (index + 1) % 1000 === 0)
|
|
609
|
+
process.stderr.write(`\rverifying ${index + 1}/${all.length}…`);
|
|
280
610
|
}
|
|
611
|
+
if (progress)
|
|
612
|
+
process.stderr.write("\r".padEnd(40) + "\r");
|
|
281
613
|
console.log(`${ok}/${all.length} receipt(s) verify offline against ${attesterPath}.`);
|
|
282
614
|
if (bad.length) {
|
|
283
615
|
for (const b of bad)
|
|
@@ -310,10 +642,17 @@ async function runTest(args) {
|
|
|
310
642
|
console.log(`command: ${command}`);
|
|
311
643
|
for (const m of mapped) {
|
|
312
644
|
const d = await runtime.evaluateOne(m);
|
|
313
|
-
|
|
645
|
+
// One tidy row per action. A deny's full explanation is multi-line, so the
|
|
646
|
+
// row carries only the deciding rule and the whole message is printed once,
|
|
647
|
+
// below — exactly as the agent will receive it.
|
|
648
|
+
const note = d.decision === "deny" ? (d.clauseId ? `rule "${d.clauseId}"` : "")
|
|
649
|
+
: d.decision === "not_evaluated" ? d.reason : "";
|
|
650
|
+
console.log(` ${describeIntent({ intent: m.intent }).padEnd(28)} → ${d.decision.padEnd(14)}${note}`);
|
|
314
651
|
}
|
|
315
652
|
const overall = await runtime.evaluate(mapped);
|
|
316
|
-
console.log(`\noverall: ${overall.decision}
|
|
653
|
+
console.log(`\noverall: ${overall.decision}`);
|
|
654
|
+
if (overall.decision === "deny" && overall.reason)
|
|
655
|
+
console.log(`\n${overall.reason}`);
|
|
317
656
|
process.exit(overall.decision === "deny" ? 2 : 0);
|
|
318
657
|
}
|
|
319
658
|
finally {
|
|
@@ -389,20 +728,44 @@ function cliPath() {
|
|
|
389
728
|
* so every project a developer opens is governed without a per-repo `init`. */
|
|
390
729
|
function runInstall(args) {
|
|
391
730
|
const dir = userHome();
|
|
731
|
+
const harnessesFor = () => args.includes("--codex") ? ["codex"]
|
|
732
|
+
: args.includes("--cursor") ? ["cursor"]
|
|
733
|
+
: args.includes("--claude") ? ["claude"]
|
|
734
|
+
: ["claude", ...(cursorDetected() ? ["cursor"] : []), ...(codexDetected() ? ["codex"] : [])];
|
|
735
|
+
// `install` rewrites agent config files the user did not create — their theme, plugins
|
|
736
|
+
// and permissions live in ~/.claude/settings.json — so there is a way to see exactly
|
|
737
|
+
// what it would touch before it touches anything.
|
|
738
|
+
if (args.includes("--dry-run")) {
|
|
739
|
+
console.log(`Dry run — nothing is written.\n`);
|
|
740
|
+
console.log(`Would scaffold ${dir} (machine key, countersigning key, starter policy)`);
|
|
741
|
+
for (const h of harnessesFor()) {
|
|
742
|
+
const file = userHarnessFile(h);
|
|
743
|
+
const exists = existsSync(file);
|
|
744
|
+
console.log(`Would ${exists ? "modify" : "create"} ${file}`);
|
|
745
|
+
if (exists)
|
|
746
|
+
console.log(` backing it up to ${file}.scopebond-backup`);
|
|
747
|
+
console.log(` adding hook ${absoluteHookCommand(cliPath(), h)}`);
|
|
748
|
+
if (exists && isHarnessConfigured(file))
|
|
749
|
+
console.log(` (a Scopebond hook is already there; it would be replaced, not duplicated)`);
|
|
750
|
+
}
|
|
751
|
+
console.log(`\nNothing else in those files is changed. Run without --dry-run to apply.`);
|
|
752
|
+
process.exit(0);
|
|
753
|
+
}
|
|
392
754
|
const { agentKid, policyPath } = scaffold(dir, { force: args.includes("--force") });
|
|
393
755
|
console.log(`Scopebond installed for this user in ${dir}`);
|
|
394
756
|
console.log(` machine key ${agentKid}`);
|
|
395
757
|
console.log(` policy ${policyPath} (starter — edit the limits)`);
|
|
396
758
|
console.log("");
|
|
397
|
-
const harnesses =
|
|
398
|
-
: args.includes("--cursor") ? ["cursor"]
|
|
399
|
-
: args.includes("--claude") ? ["claude"]
|
|
400
|
-
: ["claude", ...(cursorDetected() ? ["cursor"] : []), ...(codexDetected() ? ["codex"] : [])];
|
|
759
|
+
const harnesses = harnessesFor();
|
|
401
760
|
if (!args.includes("--no-install")) {
|
|
402
761
|
for (const h of harnesses) {
|
|
403
762
|
try {
|
|
404
|
-
const
|
|
763
|
+
const target = userHarnessFile(h);
|
|
764
|
+
const backup = existsSync(target) ? `${target}.scopebond-backup` : null;
|
|
765
|
+
const file = writeHarnessConfig(target, h, absoluteHookCommand(cliPath(), h));
|
|
405
766
|
console.log(`✓ ${harnessName(h)} configured in ${file}`);
|
|
767
|
+
if (backup && existsSync(backup))
|
|
768
|
+
console.log(` original kept at ${backup}`);
|
|
406
769
|
}
|
|
407
770
|
catch (error) {
|
|
408
771
|
console.error(`✗ ${harnessName(h)}: ${error.message}`);
|
|
@@ -421,12 +784,41 @@ function runInstall(args) {
|
|
|
421
784
|
console.log(`Check it: ${cliCommand("doctor")} · see decisions: ${cliCommand("log")}`);
|
|
422
785
|
console.log(`To send receipts to a workspace: ${cliCommand("connect <workspace-url> <enrollment>")}`);
|
|
423
786
|
}
|
|
787
|
+
/** Size and count of the local receipt store, plus its write-ahead log. Signed evidence
|
|
788
|
+
* accumulates in the user's project directory and there is no automatic deletion — so
|
|
789
|
+
* the footprint is reported rather than left to be discovered. */
|
|
790
|
+
function describeStore(dbPath) {
|
|
791
|
+
const bytes = (file) => { try {
|
|
792
|
+
return statSync(file).size;
|
|
793
|
+
}
|
|
794
|
+
catch {
|
|
795
|
+
return 0;
|
|
796
|
+
} };
|
|
797
|
+
const total = bytes(dbPath) + bytes(`${dbPath}-wal`) + bytes(`${dbPath}-shm`);
|
|
798
|
+
const human = total >= 1024 * 1024 ? `${(total / 1024 / 1024).toFixed(1)} MiB` : `${Math.max(1, Math.round(total / 1024))} KiB`;
|
|
799
|
+
let count = null;
|
|
800
|
+
try {
|
|
801
|
+
const { store } = openReceiptStore({ db: dbPath });
|
|
802
|
+
try {
|
|
803
|
+
count = store.count ? Number(store.count()) : null;
|
|
804
|
+
}
|
|
805
|
+
finally {
|
|
806
|
+
store.close?.();
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
catch {
|
|
810
|
+
count = null;
|
|
811
|
+
}
|
|
812
|
+
return count === null ? human : `${count} receipt(s), ${human}`;
|
|
813
|
+
}
|
|
424
814
|
function runStatus() {
|
|
425
815
|
const home = userHome();
|
|
426
816
|
const installed = existsSync(join(home, "policy.json"));
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
const
|
|
817
|
+
// Both scopes, always: `init` writes the project config and `install` writes the
|
|
818
|
+
// user one, so a single-scope check contradicts whichever command the user ran.
|
|
819
|
+
const claude = harnessScopes("claude", process.cwd());
|
|
820
|
+
const cursor = harnessScopes("cursor", process.cwd());
|
|
821
|
+
const codex = harnessScopes("codex", process.cwd());
|
|
430
822
|
const connected = !!loadConnection(resolveConfigDir(process.cwd()));
|
|
431
823
|
const dbPath = join(resolveConfigDir(process.cwd()), "receipts.db");
|
|
432
824
|
console.log(`Scopebond hook ${hookVersion()}`);
|
|
@@ -435,11 +827,16 @@ function runStatus() {
|
|
|
435
827
|
const ignored = untrustedProjectPolicy(process.cwd());
|
|
436
828
|
if (ignored)
|
|
437
829
|
console.log(` project policy ${ignored} ignored — not trusted (run \`${cliCommand("trust")}\` to use it)`);
|
|
438
|
-
console.log(` Claude Code
|
|
439
|
-
console.log(` Cursor ${cursor
|
|
440
|
-
console.log(` Codex ${codex ?
|
|
830
|
+
console.log(` Claude Code ${harnessScopeLabel(claude) || "not configured"}`);
|
|
831
|
+
console.log(` Cursor ${harnessScopeLabel(cursor) || (cursorDetected() ? "detected, not configured" : "not detected")}`);
|
|
832
|
+
console.log(` Codex ${codex.project || codex.user ? `${harnessScopeLabel(codex)} — approve once with /hooks` : codexDetected() ? "detected, not configured" : "not detected"}`);
|
|
441
833
|
console.log(` cloud workspace ${connected ? "connected" : "not connected (local only)"}`);
|
|
442
|
-
console.log(` local receipts ${existsSync(dbPath) ? dbPath : "none yet"}`);
|
|
834
|
+
console.log(` local receipts ${existsSync(dbPath) ? `${dbPath} (${describeStore(dbPath)})` : "none yet"}`);
|
|
835
|
+
for (const [name, scopes] of [["Claude Code", claude], ["Cursor", cursor], ["Codex", codex]]) {
|
|
836
|
+
for (const file of [scopes.project, scopes.user])
|
|
837
|
+
if (file)
|
|
838
|
+
console.log(` ${name}: ${file}`);
|
|
839
|
+
}
|
|
443
840
|
}
|
|
444
841
|
async function runDoctor() {
|
|
445
842
|
const problems = [];
|
|
@@ -459,10 +856,38 @@ async function runDoctor() {
|
|
|
459
856
|
const ignored = untrustedProjectPolicy(process.cwd());
|
|
460
857
|
if (ignored)
|
|
461
858
|
console.log(` project policy ${ignored} IGNORED — not trusted (never trusted, or edited since). Review it, then \`${cliCommand("trust")}\``);
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
859
|
+
// Every harness, in both scopes, plus a check that each configured command can
|
|
860
|
+
// actually start. A pinned path that has gone missing is the one failure mode of
|
|
861
|
+
// the fast absolute-path install, so doctor is where it must surface.
|
|
862
|
+
let anyHarness = false;
|
|
863
|
+
for (const harness of ["claude", "cursor", "codex"]) {
|
|
864
|
+
const scopes = harnessScopes(harness, process.cwd());
|
|
865
|
+
const label = harnessScopeLabel(scopes);
|
|
866
|
+
const name = harnessName(harness);
|
|
867
|
+
if (!label) {
|
|
868
|
+
const detected = harness === "claude" || (harness === "cursor" ? cursorDetected() : codexDetected());
|
|
869
|
+
console.log(` ${name.padEnd(15)} ${detected ? `not configured — run \`${cliCommand(`init${harness === "claude" ? "" : ` --${harness}`}`)}\`` : "not detected"}`);
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
872
|
+
anyHarness = true;
|
|
873
|
+
console.log(` ${name.padEnd(15)} ${label}`);
|
|
874
|
+
for (const file of [scopes.project, scopes.user]) {
|
|
875
|
+
if (!file)
|
|
876
|
+
continue;
|
|
877
|
+
for (const command of configuredHookCommands(file)) {
|
|
878
|
+
const ok = hookCommandResolves(command);
|
|
879
|
+
console.log(` ${ok ? "ok " : "BAD "} ${file}`);
|
|
880
|
+
if (!ok) {
|
|
881
|
+
console.log(` command cannot start: ${command}`);
|
|
882
|
+
problems.push(`${name} hook command no longer resolves in ${file} — run \`${cliCommand("init")}\` to repair it`);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
if (harness === "codex")
|
|
887
|
+
console.log(` run /hooks in Codex and approve Scopebond once`);
|
|
888
|
+
}
|
|
889
|
+
if (!anyHarness)
|
|
890
|
+
problems.push(`no coding agent is configured — run \`${cliCommand("init")}\` in your project root`);
|
|
466
891
|
const connection = loadConnection(active);
|
|
467
892
|
if (!connection) {
|
|
468
893
|
console.log(` cloud not connected (local only) — receipts stay on this machine`);
|
|
@@ -484,20 +909,29 @@ async function runDoctor() {
|
|
|
484
909
|
function runUninstall(args) {
|
|
485
910
|
requireInteractive("uninstall", args);
|
|
486
911
|
let removed = 0;
|
|
912
|
+
// Both scopes. Checking only the user config meant that after a per-project `init` —
|
|
913
|
+
// the install the site actually tells people to run — `uninstall` reported "no
|
|
914
|
+
// user-level harness config found" and left the project hook in place.
|
|
487
915
|
for (const h of ["claude", "cursor", "codex"]) {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
916
|
+
for (const file of [projectHarnessFile(h, process.cwd()), userHarnessFile(h)]) {
|
|
917
|
+
if (removeHarnessConfig(file)) {
|
|
918
|
+
console.log(`✓ removed the Scopebond hook from ${file}`);
|
|
919
|
+
removed++;
|
|
920
|
+
}
|
|
491
921
|
}
|
|
492
922
|
}
|
|
493
923
|
if (removed === 0)
|
|
494
|
-
console.log("no user
|
|
924
|
+
console.log("no Scopebond hook found in this project or your user config.");
|
|
495
925
|
if (args.includes("--purge")) {
|
|
496
926
|
purgeHome();
|
|
497
927
|
console.log(`✓ purged ${userHome()} (keys, policy, receipts)`);
|
|
498
928
|
}
|
|
499
929
|
else
|
|
500
930
|
console.log(`Kept ${userHome()} (keys, policy, receipts). Use --purge to remove it too.`);
|
|
931
|
+
const projectDir = resolveConfigDir(process.cwd());
|
|
932
|
+
if (existsSync(join(projectDir, "policy.json"))) {
|
|
933
|
+
console.log(`Kept ${projectDir} (this project's keys, policy and receipts) — delete it by hand if you want it gone.`);
|
|
934
|
+
}
|
|
501
935
|
}
|
|
502
936
|
/** `trust` — let this project's .scopebond policy govern here instead of the user
|
|
503
937
|
* home, pinned to its current contents. Run by the user, not the agent: the file it
|
|
@@ -519,6 +953,104 @@ function runLogin() {
|
|
|
519
953
|
console.log(` ${cliCommand("connect <workspace-url> <enrollment>")}`);
|
|
520
954
|
process.exit(0);
|
|
521
955
|
}
|
|
956
|
+
/** What each command does, its arguments, and one example. The whole help used to be a
|
|
957
|
+
* single usage line listing 15 command names, which told a reader nothing about what any
|
|
958
|
+
* of them did or what arguments they take. */
|
|
959
|
+
const COMMANDS = [
|
|
960
|
+
{ name: "init", args: "[--cursor|--codex] [--no-install] [--npx] [--force] [--yes]",
|
|
961
|
+
summary: "set this project up: keys, a starter policy, and your agent wired to the hook",
|
|
962
|
+
detail: [
|
|
963
|
+
"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.",
|
|
967
|
+
"Needs a terminal, or --yes in a script, because it changes what governs your agent.",
|
|
968
|
+
] },
|
|
969
|
+
{ name: "install", args: "[--claude] [--cursor] [--codex] [--dry-run] [--force] [--yes]",
|
|
970
|
+
summary: "set up once for this user, so every project you open is governed",
|
|
971
|
+
detail: [
|
|
972
|
+
"Scaffolds ~/.scopebond and registers the hook in your user-level agent config.",
|
|
973
|
+
"--dry-run prints exactly which files it would touch and changes nothing. Each config",
|
|
974
|
+
"is copied to <file>.scopebond-backup before its first modification.",
|
|
975
|
+
] },
|
|
976
|
+
{ name: "rules", args: "[show|allow|block|protect|unprotect|protect-branch|unprotect-branch|apply] [value]",
|
|
977
|
+
summary: "read and change the limits in plain terms",
|
|
978
|
+
detail: [
|
|
979
|
+
"With no arguments, prints what is blocked in plain English — no regular expressions.",
|
|
980
|
+
"The editable lists are .scopebond/rules.json; policy.json is compiled from them, so",
|
|
981
|
+
"you never hand-write a lookahead.",
|
|
982
|
+
" rules allow dd stop blocking a program",
|
|
983
|
+
" rules protect infra/ never write there",
|
|
984
|
+
" rules protect-branch production",
|
|
985
|
+
" rules apply recompile after editing rules.json by hand",
|
|
986
|
+
] },
|
|
987
|
+
{ name: "status", summary: "what is configured, where, and how big the local log is" },
|
|
988
|
+
{ name: "doctor", summary: "check the setup and whether each configured hook command can start",
|
|
989
|
+
detail: ["Exits non-zero when something is wrong, so it works in a script."] },
|
|
990
|
+
{ name: "log", args: "[-n N] [--deny] [--since 7d]",
|
|
991
|
+
summary: "the recent decisions",
|
|
992
|
+
detail: [`--deny shows only blocked actions; --since takes 7d, 24h, 30m or a date.`, `e.g. ${cliCommand("log --deny --since 7d")}`] },
|
|
993
|
+
{ name: "verify", summary: "check every local receipt offline against the countersigning key",
|
|
994
|
+
detail: ["No network, no account. Exits non-zero if any receipt fails."] },
|
|
995
|
+
{ name: "test", args: '"<shell command>"',
|
|
996
|
+
summary: "show the decision for a command without running or recording it",
|
|
997
|
+
detail: [`e.g. ${cliCommand('test "rm -rf /"')}`] },
|
|
998
|
+
{ name: "prune", args: "[--before 90d] [--yes]",
|
|
999
|
+
summary: "report the local store's size, or bound it",
|
|
1000
|
+
detail: [
|
|
1001
|
+
"With no --before it only reports. With one, it archives the receipts it will remove",
|
|
1002
|
+
"to a JSONL file beside the database, then removes them. Refuses once the log has been",
|
|
1003
|
+
"anchored, because a receipt's position is its anchor leaf index.",
|
|
1004
|
+
] },
|
|
1005
|
+
{ name: "connect", args: "<workspace-url> <enrollment> [--claude|--cursor|--codex]",
|
|
1006
|
+
summary: "send receipts to a Scopebond Cloud workspace as well as keeping them locally" },
|
|
1007
|
+
{ name: "flush", summary: "deliver any receipts still queued for the workspace now" },
|
|
1008
|
+
{ name: "trust", args: "[--yes]", summary: "let this project's .scopebond policy govern here (pinned by hash)" },
|
|
1009
|
+
{ name: "uninstall", args: "[--purge] [--yes]", summary: "remove the hook from your agent config; --purge also deletes the home" },
|
|
1010
|
+
{ name: "claude", summary: "(internal) decide one Claude Code PreToolUse call, JSON on stdin" },
|
|
1011
|
+
{ name: "cursor", summary: "(internal) decide one Cursor hook event, JSON on stdin" },
|
|
1012
|
+
{ name: "codex", summary: "(internal) decide one Codex PreToolUse call, JSON on stdin" },
|
|
1013
|
+
];
|
|
1014
|
+
function printHelp(topic, toStderr = false) {
|
|
1015
|
+
const out = toStderr ? console.error : console.log;
|
|
1016
|
+
const match = topic ? COMMANDS.find((c) => c.name === topic.replace(/^--?/, "")) : undefined;
|
|
1017
|
+
if (match) {
|
|
1018
|
+
out(`scopebond-hook ${match.name}${match.args ? ` ${match.args}` : ""}`);
|
|
1019
|
+
out("");
|
|
1020
|
+
out(` ${match.summary}`);
|
|
1021
|
+
if (match.detail) {
|
|
1022
|
+
out("");
|
|
1023
|
+
for (const line of match.detail)
|
|
1024
|
+
out(` ${line}`);
|
|
1025
|
+
}
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
if (topic) {
|
|
1029
|
+
out(`no such command: ${topic}`);
|
|
1030
|
+
out("");
|
|
1031
|
+
}
|
|
1032
|
+
out(`scopebond-hook — govern a coding agent's tool calls against policy, before they run.`);
|
|
1033
|
+
out("");
|
|
1034
|
+
out(`Usage: scopebond-hook <command> [options]`);
|
|
1035
|
+
out("");
|
|
1036
|
+
out(` ${cliCommand("init")} set up this project`);
|
|
1037
|
+
out(` ${cliCommand('test "rm -rf /"')} see a decision without running it`);
|
|
1038
|
+
out(` ${cliCommand("log --deny")} what got blocked`);
|
|
1039
|
+
out(` ${cliCommand("rules")} what is blocked, in plain English`);
|
|
1040
|
+
out("");
|
|
1041
|
+
out("Commands:");
|
|
1042
|
+
const width = Math.max(...COMMANDS.map((c) => c.name.length));
|
|
1043
|
+
for (const command of COMMANDS) {
|
|
1044
|
+
if (command.summary.startsWith("(internal)"))
|
|
1045
|
+
continue;
|
|
1046
|
+
out(` ${command.name.padEnd(width)} ${command.summary}`);
|
|
1047
|
+
}
|
|
1048
|
+
out("");
|
|
1049
|
+
out(` ${"help".padEnd(width)} \`help <command>\` for that command's arguments and examples`);
|
|
1050
|
+
out("");
|
|
1051
|
+
out(`Receipts and keys stay in .scopebond/ in this project. Nothing leaves your machine`);
|
|
1052
|
+
out(`unless you run \`connect\`. Docs: https://github.com/avouro-com/scopebond`);
|
|
1053
|
+
}
|
|
522
1054
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
523
1055
|
if (cmd === "claude") {
|
|
524
1056
|
await runClaude();
|
|
@@ -565,8 +1097,18 @@ else if (cmd === "login") {
|
|
|
565
1097
|
else if (cmd === "trust") {
|
|
566
1098
|
runTrust(rest);
|
|
567
1099
|
}
|
|
1100
|
+
else if (cmd === "prune") {
|
|
1101
|
+
await runPrune(rest);
|
|
1102
|
+
}
|
|
1103
|
+
else if (cmd === "rules") {
|
|
1104
|
+
runRules(rest);
|
|
1105
|
+
}
|
|
1106
|
+
else if (cmd === "help" || cmd === "--help" || cmd === "-h" || cmd === undefined) {
|
|
1107
|
+
printHelp(rest[0]);
|
|
1108
|
+
}
|
|
568
1109
|
else {
|
|
569
|
-
console.error(
|
|
1110
|
+
console.error(`unknown command: ${cmd}`);
|
|
1111
|
+
printHelp(undefined, true);
|
|
570
1112
|
process.exit(1);
|
|
571
1113
|
}
|
|
572
1114
|
//# sourceMappingURL=cli.js.map
|