@scopebond/hook 0.5.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 +139 -12
- package/dist/cli.d.ts +2 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +667 -69
- 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 +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- 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 +36 -28
- package/dist/init.js.map +1 -1
- package/dist/install.d.ts +63 -3
- package/dist/install.d.ts.map +1 -1
- package/dist/install.js +242 -28
- package/dist/install.js.map +1 -1
- package/dist/map.d.ts +12 -3
- package/dist/map.d.ts.map +1 -1
- package/dist/map.js +765 -60
- package/dist/map.js.map +1 -1
- package/dist/minimize.d.ts +6 -3
- package/dist/minimize.d.ts.map +1 -1
- package/dist/minimize.js +24 -4
- package/dist/minimize.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 +17 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +122 -11
- package/dist/runtime.js.map +1 -1
- package/dist/shell.d.ts +53 -4
- package/dist/shell.d.ts.map +1 -1
- package/dist/shell.js +737 -81
- package/dist/shell.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,20 +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]
|
|
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
|
|
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.
|
|
15
8
|
//
|
|
16
9
|
// Config dir: $SCOPEBOND_HOOK_DIR, else ./.scopebond
|
|
17
10
|
// Fail-closed: any error denies the action with a repair message.
|
|
18
|
-
import { readFileSync, existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
|
|
19
12
|
import { join } from "node:path";
|
|
20
13
|
import { tmpdir } from "node:os";
|
|
21
14
|
import { execFileSync } from "node:child_process";
|
|
@@ -24,8 +17,12 @@ import { openReceiptStore, loadOrCreateAttester } from "@scopebond/gateway/node"
|
|
|
24
17
|
import { mapClaudeToolUse, mapCodexToolUse, mapCursorEvent, fillPushBranch } from "./map.js";
|
|
25
18
|
import { createHookRuntime } from "./runtime.js";
|
|
26
19
|
import { scaffold, harnessSnippet, installHarness } from "./init.js";
|
|
27
|
-
import { userHome, userHarnessFile, resolveConfigDir, writeHarnessConfig, removeHarnessConfig, cursorDetected, codexDetected, absoluteHookCommand, isHarnessConfigured, purgeHome, } 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";
|
|
28
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";
|
|
29
26
|
import { cliCommand, hookVersion } from "./version.js";
|
|
30
27
|
import { fileURLToPath } from "node:url";
|
|
31
28
|
/** The current git branch in `cwd` (best-effort). A bare `git push` pushes it, so
|
|
@@ -91,7 +88,9 @@ function denyClaude(reason) {
|
|
|
91
88
|
process.stdout.write(JSON.stringify({
|
|
92
89
|
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason },
|
|
93
90
|
}) + "\n");
|
|
94
|
-
|
|
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`);
|
|
95
94
|
process.exit(2);
|
|
96
95
|
}
|
|
97
96
|
/** Codex accepts the structured deny response on a successful hook exit. Keeping
|
|
@@ -107,6 +106,18 @@ const harnessName = (harness) => harness === "claude" ? "Claude Code" : harness
|
|
|
107
106
|
const harnessFileName = (harness) => harness === "claude" ? ".claude/settings.json" : harness === "cursor" ? ".cursor/hooks.json" : ".codex/hooks.json";
|
|
108
107
|
const selectedHarness = (args) => args.includes("--codex") ? "codex" : args.includes("--cursor") ? "cursor" : "claude";
|
|
109
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");
|
|
110
121
|
async function runPreToolUse(mapper, deny = denyClaude) {
|
|
111
122
|
let input;
|
|
112
123
|
try {
|
|
@@ -115,11 +126,16 @@ async function runPreToolUse(mapper, deny = denyClaude) {
|
|
|
115
126
|
catch {
|
|
116
127
|
deny("hook received invalid JSON on stdin");
|
|
117
128
|
}
|
|
129
|
+
let runtime;
|
|
118
130
|
try {
|
|
119
131
|
const cwd = input?.cwd ? String(input.cwd) : process.cwd();
|
|
120
|
-
|
|
132
|
+
runtime = createHookRuntime(runtimePaths(resolveConfigDir(cwd)));
|
|
121
133
|
const decision = await runtime.evaluate(fillPushBranch(mapper(input), currentBranch(cwd)));
|
|
122
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;
|
|
123
139
|
if (decision.decision === "deny")
|
|
124
140
|
deny(decision.reason);
|
|
125
141
|
// Stay silent on allow/not_evaluated so the coding agent's normal permission
|
|
@@ -127,6 +143,10 @@ async function runPreToolUse(mapper, deny = denyClaude) {
|
|
|
127
143
|
process.exit(0);
|
|
128
144
|
}
|
|
129
145
|
catch (error) {
|
|
146
|
+
try {
|
|
147
|
+
runtime?.close();
|
|
148
|
+
}
|
|
149
|
+
catch { /* already failing; the deny below is what matters */ }
|
|
130
150
|
deny(`Scopebond hook failed closed: ${error.message}. Repair: run \`${cliCommand("init")}\`.`);
|
|
131
151
|
}
|
|
132
152
|
}
|
|
@@ -136,51 +156,137 @@ async function runClaude() {
|
|
|
136
156
|
async function runCodex() {
|
|
137
157
|
await runPreToolUse(mapCodexToolUse, denyCodex);
|
|
138
158
|
}
|
|
159
|
+
function denyCursor(reason) {
|
|
160
|
+
process.stdout.write(JSON.stringify({ permission: "deny", agentMessage: reason }) + "\n");
|
|
161
|
+
process.exit(0);
|
|
162
|
+
}
|
|
139
163
|
async function runCursor() {
|
|
140
|
-
|
|
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;
|
|
141
169
|
try {
|
|
142
|
-
|
|
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");
|
|
143
177
|
}
|
|
144
|
-
|
|
145
|
-
const event = String(input
|
|
178
|
+
const input = parsed;
|
|
179
|
+
const event = String(input.hook_event_name ?? input.event ?? process.argv[3] ?? "");
|
|
146
180
|
let permission = "deny";
|
|
147
181
|
let message = "Scopebond hook failed closed";
|
|
182
|
+
let postHoc = false;
|
|
183
|
+
let runtime;
|
|
148
184
|
try {
|
|
149
185
|
const cwd = input?.cwd ? String(input.cwd) : process.cwd();
|
|
150
|
-
|
|
151
|
-
const
|
|
186
|
+
runtime = createHookRuntime(runtimePaths(resolveConfigDir(cwd)));
|
|
187
|
+
const mapped = fillPushBranch(mapCursorEvent(event, input), currentBranch(cwd));
|
|
188
|
+
const decision = await runtime.evaluate(mapped);
|
|
152
189
|
await runtime.flush();
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
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";
|
|
156
204
|
message = decision.reason;
|
|
157
205
|
}
|
|
158
206
|
catch (error) {
|
|
159
207
|
permission = "deny";
|
|
160
208
|
message = `Scopebond hook failed closed: ${error.message}. Repair: run \`${cliCommand("init")}\`.`;
|
|
161
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
|
+
}
|
|
162
221
|
process.stdout.write(JSON.stringify({ permission, agentMessage: message }) + "\n");
|
|
163
222
|
process.exit(0);
|
|
164
223
|
}
|
|
224
|
+
/** `init`, `trust` and `uninstall` change what governs the agent, so they are for a
|
|
225
|
+
* person at a terminal. A coding agent's shell is not interactive: without a TTY on
|
|
226
|
+
* stdin they refuse unless `--yes` is passed (for scripts and CI). The starter policy
|
|
227
|
+
* also denies the agent running them. */
|
|
228
|
+
function requireInteractive(command, args) {
|
|
229
|
+
if (process.stdin.isTTY || args.includes("--yes"))
|
|
230
|
+
return;
|
|
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.`);
|
|
238
|
+
process.exit(1);
|
|
239
|
+
}
|
|
165
240
|
function runInit(args) {
|
|
241
|
+
requireInteractive("init", args);
|
|
166
242
|
const harness = selectedHarness(args);
|
|
167
243
|
const dir = configDir();
|
|
168
|
-
const { agentKid, policyPath } = scaffold(dir, { force: args.includes("--force") });
|
|
244
|
+
const { agentKid, policyPath, rulesPath: rulesFile } = scaffold(dir, { force: args.includes("--force") });
|
|
169
245
|
console.log(`Scopebond hook enrolled in ${dir}`);
|
|
170
246
|
console.log(` machine key ${agentKid}`);
|
|
171
|
-
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)`);
|
|
249
|
+
// With a user-level install present, a project policy governs only once trusted.
|
|
250
|
+
// Running init here is that decision, so pin this policy now.
|
|
251
|
+
if (!process.env.SCOPEBOND_HOOK_DIR && existsSync(join(userHome(), "policy.json"))) {
|
|
252
|
+
trustProjectPolicy(dir);
|
|
253
|
+
console.log(` trusted overrides ${userHome()} here; after editing it, run \`${cliCommand("trust")}\``);
|
|
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`}`);
|
|
172
267
|
console.log("");
|
|
173
268
|
// Configure the agent automatically by default (idempotent), so there is no
|
|
174
269
|
// hand-editing step; --no-install prints the snippet instead.
|
|
175
270
|
if (!args.includes("--no-install")) {
|
|
176
|
-
|
|
271
|
+
let file;
|
|
272
|
+
try {
|
|
273
|
+
file = installHarness(harness, process.cwd(), command);
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
console.error(error.message);
|
|
277
|
+
process.exit(1);
|
|
278
|
+
}
|
|
177
279
|
console.log(`✓ ${harnessName(harness)} configured in ${file}`);
|
|
178
280
|
if (harness === "codex")
|
|
179
281
|
console.log(`\nOne last step: ${codexTrustStep}`);
|
|
282
|
+
if (harness === "cursor")
|
|
283
|
+
console.log(`\n${cursorCoverageNote}`);
|
|
180
284
|
}
|
|
181
285
|
else {
|
|
182
286
|
console.log(`Add this to your ${harnessFileName(harness)}:`);
|
|
183
|
-
console.log(harnessSnippet(harness));
|
|
287
|
+
console.log(harnessSnippet(harness, command));
|
|
288
|
+
if (harness === "cursor")
|
|
289
|
+
console.log(`\n${cursorCoverageNote}`);
|
|
184
290
|
}
|
|
185
291
|
console.log("");
|
|
186
292
|
// Print the runnable `npx` form: after `npx @scopebond/hook init` there is no
|
|
@@ -199,15 +305,14 @@ function decisionOf(payload) {
|
|
|
199
305
|
return "allow";
|
|
200
306
|
}
|
|
201
307
|
function describeIntent(payload) {
|
|
202
|
-
|
|
203
|
-
const p = (intent.params ?? {});
|
|
204
|
-
const bits = intent.action_type === "shell.exec" ? String(p.program ?? "")
|
|
205
|
-
: intent.action_type === "git.push" ? `${p.remote ?? ""} ${p.ref ?? ""}`.trim()
|
|
206
|
-
: intent.action_type === "file.write" || intent.action_type === "file.read" ? String(p.path ?? "")
|
|
207
|
-
: intent.action_type === "mcp.tool.call" ? `${p.server ?? ""}/${p.tool ?? ""}`
|
|
208
|
-
: intent.action_type === "net.fetch" ? String(p.host ?? "") : "";
|
|
209
|
-
return `${String(intent.action_type ?? "?")}${bits ? ` ${bits}` : ""}`;
|
|
308
|
+
return describeAction(payload.intent);
|
|
210
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. */
|
|
211
316
|
async function runLog(args) {
|
|
212
317
|
const dir = resolveConfigDir(process.cwd());
|
|
213
318
|
const dbPath = join(dir, "receipts.db");
|
|
@@ -217,18 +322,259 @@ async function runLog(args) {
|
|
|
217
322
|
}
|
|
218
323
|
const nIdx = args.indexOf("-n");
|
|
219
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
|
+
}
|
|
220
332
|
const { store } = openReceiptStore({ db: dbPath });
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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.`);
|
|
225
521
|
process.exit(0);
|
|
226
522
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
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);
|
|
230
565
|
}
|
|
231
|
-
|
|
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;
|
|
575
|
+
}
|
|
576
|
+
const at = Date.parse(value);
|
|
577
|
+
return Number.isFinite(at) ? at : null;
|
|
232
578
|
}
|
|
233
579
|
async function runVerify() {
|
|
234
580
|
const dir = resolveConfigDir(process.cwd());
|
|
@@ -240,10 +586,18 @@ async function runVerify() {
|
|
|
240
586
|
}
|
|
241
587
|
const { attester } = loadOrCreateAttester({ file: attesterPath });
|
|
242
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.
|
|
243
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;
|
|
244
598
|
let ok = 0;
|
|
245
599
|
const bad = [];
|
|
246
|
-
for (const r of all) {
|
|
600
|
+
for (const [index, r] of all.entries()) {
|
|
247
601
|
const result = verifyReceipt(r, attester.publicKeyPem);
|
|
248
602
|
if (result.valid)
|
|
249
603
|
ok += 1;
|
|
@@ -251,7 +605,11 @@ async function runVerify() {
|
|
|
251
605
|
const failed = Object.entries(result).filter(([k, v]) => k.endsWith("_valid") && v === false).map(([k]) => k);
|
|
252
606
|
bad.push(`${String(r.payload.timestamp ?? "")}: ${failed.join(", ") || "invalid"}`);
|
|
253
607
|
}
|
|
608
|
+
if (progress && (index + 1) % 1000 === 0)
|
|
609
|
+
process.stderr.write(`\rverifying ${index + 1}/${all.length}…`);
|
|
254
610
|
}
|
|
611
|
+
if (progress)
|
|
612
|
+
process.stderr.write("\r".padEnd(40) + "\r");
|
|
255
613
|
console.log(`${ok}/${all.length} receipt(s) verify offline against ${attesterPath}.`);
|
|
256
614
|
if (bad.length) {
|
|
257
615
|
for (const b of bad)
|
|
@@ -284,10 +642,17 @@ async function runTest(args) {
|
|
|
284
642
|
console.log(`command: ${command}`);
|
|
285
643
|
for (const m of mapped) {
|
|
286
644
|
const d = await runtime.evaluateOne(m);
|
|
287
|
-
|
|
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}`);
|
|
288
651
|
}
|
|
289
652
|
const overall = await runtime.evaluate(mapped);
|
|
290
|
-
console.log(`\noverall: ${overall.decision}
|
|
653
|
+
console.log(`\noverall: ${overall.decision}`);
|
|
654
|
+
if (overall.decision === "deny" && overall.reason)
|
|
655
|
+
console.log(`\n${overall.reason}`);
|
|
291
656
|
process.exit(overall.decision === "deny" ? 2 : 0);
|
|
292
657
|
}
|
|
293
658
|
finally {
|
|
@@ -363,19 +728,49 @@ function cliPath() {
|
|
|
363
728
|
* so every project a developer opens is governed without a per-repo `init`. */
|
|
364
729
|
function runInstall(args) {
|
|
365
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
|
+
}
|
|
366
754
|
const { agentKid, policyPath } = scaffold(dir, { force: args.includes("--force") });
|
|
367
755
|
console.log(`Scopebond installed for this user in ${dir}`);
|
|
368
756
|
console.log(` machine key ${agentKid}`);
|
|
369
757
|
console.log(` policy ${policyPath} (starter — edit the limits)`);
|
|
370
758
|
console.log("");
|
|
371
|
-
const harnesses =
|
|
372
|
-
: args.includes("--cursor") ? ["cursor"]
|
|
373
|
-
: args.includes("--claude") ? ["claude"]
|
|
374
|
-
: ["claude", ...(cursorDetected() ? ["cursor"] : []), ...(codexDetected() ? ["codex"] : [])];
|
|
759
|
+
const harnesses = harnessesFor();
|
|
375
760
|
if (!args.includes("--no-install")) {
|
|
376
761
|
for (const h of harnesses) {
|
|
377
|
-
|
|
378
|
-
|
|
762
|
+
try {
|
|
763
|
+
const target = userHarnessFile(h);
|
|
764
|
+
const backup = existsSync(target) ? `${target}.scopebond-backup` : null;
|
|
765
|
+
const file = writeHarnessConfig(target, h, absoluteHookCommand(cliPath(), h));
|
|
766
|
+
console.log(`✓ ${harnessName(h)} configured in ${file}`);
|
|
767
|
+
if (backup && existsSync(backup))
|
|
768
|
+
console.log(` original kept at ${backup}`);
|
|
769
|
+
}
|
|
770
|
+
catch (error) {
|
|
771
|
+
console.error(`✗ ${harnessName(h)}: ${error.message}`);
|
|
772
|
+
process.exitCode = 1;
|
|
773
|
+
}
|
|
379
774
|
}
|
|
380
775
|
}
|
|
381
776
|
else {
|
|
@@ -385,26 +780,63 @@ function runInstall(args) {
|
|
|
385
780
|
if (harnesses.includes("codex"))
|
|
386
781
|
console.log(`\nOne last step for Codex: ${codexTrustStep}`);
|
|
387
782
|
console.log("");
|
|
388
|
-
console.log(
|
|
783
|
+
console.log(`A project's own .scopebond policy applies only after you trust it there (${cliCommand("trust")}).`);
|
|
389
784
|
console.log(`Check it: ${cliCommand("doctor")} · see decisions: ${cliCommand("log")}`);
|
|
390
785
|
console.log(`To send receipts to a workspace: ${cliCommand("connect <workspace-url> <enrollment>")}`);
|
|
391
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
|
+
}
|
|
392
814
|
function runStatus() {
|
|
393
815
|
const home = userHome();
|
|
394
816
|
const installed = existsSync(join(home, "policy.json"));
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
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());
|
|
398
822
|
const connected = !!loadConnection(resolveConfigDir(process.cwd()));
|
|
399
823
|
const dbPath = join(resolveConfigDir(process.cwd()), "receipts.db");
|
|
400
824
|
console.log(`Scopebond hook ${hookVersion()}`);
|
|
401
825
|
console.log(` user home ${home} ${installed ? "(installed)" : "(not installed — run `scopebond install`)"}`);
|
|
402
826
|
console.log(` active config ${resolveConfigDir(process.cwd())}`);
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
827
|
+
const ignored = untrustedProjectPolicy(process.cwd());
|
|
828
|
+
if (ignored)
|
|
829
|
+
console.log(` project policy ${ignored} ignored — not trusted (run \`${cliCommand("trust")}\` to use it)`);
|
|
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"}`);
|
|
406
833
|
console.log(` cloud workspace ${connected ? "connected" : "not connected (local only)"}`);
|
|
407
|
-
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
|
+
}
|
|
408
840
|
}
|
|
409
841
|
async function runDoctor() {
|
|
410
842
|
const problems = [];
|
|
@@ -421,10 +853,41 @@ async function runDoctor() {
|
|
|
421
853
|
console.log(` active config ${active} ${hasPolicy ? "ok" : "no policy (run `scopebond install` or `init`)"}`);
|
|
422
854
|
if (!hasPolicy)
|
|
423
855
|
problems.push("no policy found in the active config dir");
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
856
|
+
const ignored = untrustedProjectPolicy(process.cwd());
|
|
857
|
+
if (ignored)
|
|
858
|
+
console.log(` project policy ${ignored} IGNORED — not trusted (never trusted, or edited since). Review it, then \`${cliCommand("trust")}\``);
|
|
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`);
|
|
428
891
|
const connection = loadConnection(active);
|
|
429
892
|
if (!connection) {
|
|
430
893
|
console.log(` cloud not connected (local only) — receipts stay on this machine`);
|
|
@@ -444,21 +907,45 @@ async function runDoctor() {
|
|
|
444
907
|
process.exitCode = problems.length ? 1 : 0;
|
|
445
908
|
}
|
|
446
909
|
function runUninstall(args) {
|
|
910
|
+
requireInteractive("uninstall", args);
|
|
447
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.
|
|
448
915
|
for (const h of ["claude", "cursor", "codex"]) {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
+
}
|
|
452
921
|
}
|
|
453
922
|
}
|
|
454
923
|
if (removed === 0)
|
|
455
|
-
console.log("no user
|
|
924
|
+
console.log("no Scopebond hook found in this project or your user config.");
|
|
456
925
|
if (args.includes("--purge")) {
|
|
457
926
|
purgeHome();
|
|
458
927
|
console.log(`✓ purged ${userHome()} (keys, policy, receipts)`);
|
|
459
928
|
}
|
|
460
929
|
else
|
|
461
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
|
+
}
|
|
935
|
+
}
|
|
936
|
+
/** `trust` — let this project's .scopebond policy govern here instead of the user
|
|
937
|
+
* home, pinned to its current contents. Run by the user, not the agent: the file it
|
|
938
|
+
* writes lives in the user home, which the starter policy write-protects. */
|
|
939
|
+
function runTrust(args) {
|
|
940
|
+
requireInteractive("trust", args);
|
|
941
|
+
const dir = join(process.cwd(), ".scopebond");
|
|
942
|
+
if (!existsSync(join(dir, "policy.json"))) {
|
|
943
|
+
console.error(`no project policy at ${join(dir, "policy.json")}`);
|
|
944
|
+
process.exit(1);
|
|
945
|
+
}
|
|
946
|
+
const digest = trustProjectPolicy(dir);
|
|
947
|
+
console.log(`✓ trusted ${join(dir, "policy.json")} (sha256 ${digest.slice(0, 12)}…)`);
|
|
948
|
+
console.log("It governs agents in this project until it changes; after any edit, review it and run trust again.");
|
|
462
949
|
}
|
|
463
950
|
function runLogin() {
|
|
464
951
|
console.log("Device-code login is not available yet.");
|
|
@@ -466,6 +953,104 @@ function runLogin() {
|
|
|
466
953
|
console.log(` ${cliCommand("connect <workspace-url> <enrollment>")}`);
|
|
467
954
|
process.exit(0);
|
|
468
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
|
+
}
|
|
469
1054
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
470
1055
|
if (cmd === "claude") {
|
|
471
1056
|
await runClaude();
|
|
@@ -509,8 +1094,21 @@ else if (cmd === "uninstall") {
|
|
|
509
1094
|
else if (cmd === "login") {
|
|
510
1095
|
runLogin();
|
|
511
1096
|
}
|
|
1097
|
+
else if (cmd === "trust") {
|
|
1098
|
+
runTrust(rest);
|
|
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
|
+
}
|
|
512
1109
|
else {
|
|
513
|
-
console.error(
|
|
1110
|
+
console.error(`unknown command: ${cmd}`);
|
|
1111
|
+
printHelp(undefined, true);
|
|
514
1112
|
process.exit(1);
|
|
515
1113
|
}
|
|
516
1114
|
//# sourceMappingURL=cli.js.map
|