@scopebond/hook 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -13
- package/dist/cli.d.ts +2 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +825 -92
- 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 +11 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +41 -4
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +104 -21
- package/dist/init.js.map +1 -1
- package/dist/install.d.ts +70 -2
- package/dist/install.d.ts.map +1 -1
- package/dist/install.js +262 -10
- 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/minimize.d.ts +11 -3
- package/dist/minimize.d.ts.map +1 -1
- package/dist/minimize.js +35 -6
- 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 +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 +2 -2
package/dist/cli.js
CHANGED
|
@@ -2,32 +2,44 @@
|
|
|
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
|
-
|
|
11
|
+
// `node:sqlite` (the receipt store) is still flagged experimental on Node 22, and Node
|
|
12
|
+
// prints a warning on stderr the first time it loads — on every `verify`, and into the
|
|
13
|
+
// agent's transcript on every hook call. It is a notice about Node, not about the user's
|
|
14
|
+
// setup, so it is dropped; every other warning still prints through Node's own handler.
|
|
15
|
+
// (The store is required lazily, after this module has run, so the filter is in place.)
|
|
16
|
+
{
|
|
17
|
+
const nodeWarningHandlers = process.listeners("warning");
|
|
18
|
+
process.removeAllListeners("warning");
|
|
19
|
+
process.on("warning", (warning) => {
|
|
20
|
+
if (warning.name === "ExperimentalWarning" && /sqlite/i.test(warning.message))
|
|
21
|
+
return;
|
|
22
|
+
for (const handler of nodeWarningHandlers)
|
|
23
|
+
handler.call(process, warning);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
|
|
20
27
|
import { join } from "node:path";
|
|
21
|
-
import { tmpdir } from "node:os";
|
|
28
|
+
import { hostname, tmpdir } from "node:os";
|
|
22
29
|
import { execFileSync } from "node:child_process";
|
|
23
30
|
import { verifyReceipt } from "@scopebond/gateway";
|
|
24
31
|
import { openReceiptStore, loadOrCreateAttester } from "@scopebond/gateway/node";
|
|
25
32
|
import { mapClaudeToolUse, mapCodexToolUse, mapCursorEvent, fillPushBranch } from "./map.js";
|
|
26
33
|
import { createHookRuntime } from "./runtime.js";
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
34
|
+
import { useDigestKey, loadOrCreateDigestKey } from "./minimize.js";
|
|
35
|
+
import { scaffold, harnessSnippet, placeHook } from "./init.js";
|
|
36
|
+
import { userHome, userHarnessFile, resolveConfigDir, writeHarnessConfig, removeHarnessConfig, cursorDetected, codexDetected, absoluteHookCommand, isHarnessConfigured, purgeHome, harnessScopes, harnessScopeLabel, configuredHookCommands, hookCommandResolves, projectHarnessFile, localHarnessFile, gitShareState, isMachineSpecificCommand, trustProjectPolicy, untrustedProjectPolicy, } from "./install.js";
|
|
29
37
|
import { connectCloud, loadConnection } from "./cloud.js";
|
|
30
|
-
import {
|
|
38
|
+
import { compile, defaultRules, describeRules, loadRules, saveRules, rulesPath, pathRuleFor } from "./rules.js";
|
|
39
|
+
import { createSigner } from "@scopebond/sdk";
|
|
40
|
+
import { describeAction } from "./explain.js";
|
|
41
|
+
import { ensureDurableRuntime, pinnedCliPath, isEphemeralPath } from "./runtime-install.js";
|
|
42
|
+
import { cliCommand, hookCommand, hookVersion } from "./version.js";
|
|
31
43
|
import { fileURLToPath } from "node:url";
|
|
32
44
|
/** The current git branch in `cwd` (best-effort). A bare `git push` pushes it, so
|
|
33
45
|
* the runtime fills it in before evaluating; on failure the ref stays absent and
|
|
@@ -92,7 +104,9 @@ function denyClaude(reason) {
|
|
|
92
104
|
process.stdout.write(JSON.stringify({
|
|
93
105
|
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason },
|
|
94
106
|
}) + "\n");
|
|
95
|
-
|
|
107
|
+
// A composed explanation already names Scopebond in its first line; only the
|
|
108
|
+
// bare internal messages need the prefix.
|
|
109
|
+
process.stderr.write(`${reason.startsWith("Scopebond") ? reason : `Scopebond: ${reason}`}\n`);
|
|
96
110
|
process.exit(2);
|
|
97
111
|
}
|
|
98
112
|
/** Codex accepts the structured deny response on a successful hook exit. Keeping
|
|
@@ -108,6 +122,18 @@ const harnessName = (harness) => harness === "claude" ? "Claude Code" : harness
|
|
|
108
122
|
const harnessFileName = (harness) => harness === "claude" ? ".claude/settings.json" : harness === "cursor" ? ".cursor/hooks.json" : ".codex/hooks.json";
|
|
109
123
|
const selectedHarness = (args) => args.includes("--codex") ? "codex" : args.includes("--cursor") ? "cursor" : "claude";
|
|
110
124
|
const codexTrustStep = "Open Codex, run `/hooks`, review Scopebond, and choose Trust. Then start a new task.";
|
|
125
|
+
/** What Cursor can and cannot stop. Cursor has before-hooks for shell commands, MCP
|
|
126
|
+
* calls and file reads, but reports file *edits* only after they are written, so an
|
|
127
|
+
* out-of-policy edit is recorded and flagged rather than prevented. Said at install
|
|
128
|
+
* time, because someone choosing a guardrail needs to know its edges up front. */
|
|
129
|
+
const cursorCoverageNote = [
|
|
130
|
+
"What this covers in Cursor:",
|
|
131
|
+
" prevented shell commands, MCP tool calls, file reads — checked before they run",
|
|
132
|
+
" recorded file edits — Cursor reports an edit only after writing it, so an",
|
|
133
|
+
" out-of-policy edit is signed and flagged, not blocked",
|
|
134
|
+
"For edits that must be blocked before they land, use the GitHub Action as a required",
|
|
135
|
+
"check on pull requests.",
|
|
136
|
+
].join("\n");
|
|
111
137
|
async function runPreToolUse(mapper, deny = denyClaude) {
|
|
112
138
|
let input;
|
|
113
139
|
try {
|
|
@@ -116,11 +142,18 @@ async function runPreToolUse(mapper, deny = denyClaude) {
|
|
|
116
142
|
catch {
|
|
117
143
|
deny("hook received invalid JSON on stdin");
|
|
118
144
|
}
|
|
145
|
+
let runtime;
|
|
119
146
|
try {
|
|
120
147
|
const cwd = input?.cwd ? String(input.cwd) : process.cwd();
|
|
121
|
-
const
|
|
148
|
+
const dir = resolveConfigDir(cwd);
|
|
149
|
+
runtime = createHookRuntime(runtimePaths(dir));
|
|
150
|
+
useDigestKey(loadOrCreateDigestKey(dir));
|
|
122
151
|
const decision = await runtime.evaluate(fillPushBranch(mapper(input), currentBranch(cwd)));
|
|
123
152
|
await runtime.flush();
|
|
153
|
+
// Close before deciding: the receipt is already committed, and leaving the handle
|
|
154
|
+
// open is what made the write-ahead log grow without bound.
|
|
155
|
+
runtime.close();
|
|
156
|
+
runtime = undefined;
|
|
124
157
|
if (decision.decision === "deny")
|
|
125
158
|
deny(decision.reason);
|
|
126
159
|
// Stay silent on allow/not_evaluated so the coding agent's normal permission
|
|
@@ -128,6 +161,10 @@ async function runPreToolUse(mapper, deny = denyClaude) {
|
|
|
128
161
|
process.exit(0);
|
|
129
162
|
}
|
|
130
163
|
catch (error) {
|
|
164
|
+
try {
|
|
165
|
+
runtime?.close();
|
|
166
|
+
}
|
|
167
|
+
catch { /* already failing; the deny below is what matters */ }
|
|
131
168
|
deny(`Scopebond hook failed closed: ${error.message}. Repair: run \`${cliCommand("init")}\`.`);
|
|
132
169
|
}
|
|
133
170
|
}
|
|
@@ -137,29 +174,70 @@ async function runClaude() {
|
|
|
137
174
|
async function runCodex() {
|
|
138
175
|
await runPreToolUse(mapCodexToolUse, denyCodex);
|
|
139
176
|
}
|
|
177
|
+
function denyCursor(reason) {
|
|
178
|
+
process.stdout.write(JSON.stringify({ permission: "deny", agentMessage: reason }) + "\n");
|
|
179
|
+
process.exit(0);
|
|
180
|
+
}
|
|
140
181
|
async function runCursor() {
|
|
141
|
-
|
|
182
|
+
// Unparseable input denies, like every other adapter. Previously this fell through
|
|
183
|
+
// to evaluation with an empty payload, which mapped to no known action and so
|
|
184
|
+
// answered "ask" — handing an unreadable request to a prompt the user would very
|
|
185
|
+
// likely accept. Deny-by-default on unparseable input is not optional.
|
|
186
|
+
let parsed;
|
|
142
187
|
try {
|
|
143
|
-
|
|
188
|
+
parsed = JSON.parse(readStdin());
|
|
144
189
|
}
|
|
145
|
-
catch {
|
|
146
|
-
|
|
190
|
+
catch {
|
|
191
|
+
denyCursor("Scopebond: hook received invalid JSON on stdin");
|
|
192
|
+
}
|
|
193
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
194
|
+
denyCursor("Scopebond: hook received a payload that is not a JSON object");
|
|
195
|
+
}
|
|
196
|
+
const input = parsed;
|
|
197
|
+
const event = String(input.hook_event_name ?? input.event ?? process.argv[3] ?? "");
|
|
147
198
|
let permission = "deny";
|
|
148
199
|
let message = "Scopebond hook failed closed";
|
|
200
|
+
let postHoc = false;
|
|
201
|
+
let runtime;
|
|
149
202
|
try {
|
|
150
203
|
const cwd = input?.cwd ? String(input.cwd) : process.cwd();
|
|
151
|
-
const
|
|
152
|
-
|
|
204
|
+
const dir = resolveConfigDir(cwd);
|
|
205
|
+
runtime = createHookRuntime(runtimePaths(dir));
|
|
206
|
+
useDigestKey(loadOrCreateDigestKey(dir));
|
|
207
|
+
const mapped = fillPushBranch(mapCursorEvent(event, input), currentBranch(cwd));
|
|
208
|
+
const decision = await runtime.evaluate(mapped);
|
|
153
209
|
await runtime.flush();
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
210
|
+
// An `afterFileEdit` violation is real and recorded, but the edit has already
|
|
211
|
+
// landed. Say so rather than letting "blocked" imply it was stopped.
|
|
212
|
+
postHoc = mapped.some((m) => m.postHoc);
|
|
213
|
+
// Three outcomes, three answers:
|
|
214
|
+
// deny — out of policy, blocked outright.
|
|
215
|
+
// allow — a rule was evaluated and permitted it. Returning "ask" here put a
|
|
216
|
+
// confirmation prompt in front of every ordinary command, which is not
|
|
217
|
+
// "your agent works as normal"; it also trained people to click through
|
|
218
|
+
// prompts, which makes the real denials easier to miss. An evaluated
|
|
219
|
+
// allow is a decision, not a silent auto-approval.
|
|
220
|
+
// ask — nothing was evaluated (no rule covers this action), so Cursor's own
|
|
221
|
+
// permission flow stays in charge. That is the fail-closed case and it
|
|
222
|
+
// keeps its prompt.
|
|
223
|
+
permission = decision.decision === "deny" ? "deny" : decision.decision === "allow" ? "allow" : "ask";
|
|
157
224
|
message = decision.reason;
|
|
158
225
|
}
|
|
159
226
|
catch (error) {
|
|
160
227
|
permission = "deny";
|
|
161
228
|
message = `Scopebond hook failed closed: ${error.message}. Repair: run \`${cliCommand("init")}\`.`;
|
|
162
229
|
}
|
|
230
|
+
finally {
|
|
231
|
+
// Release the SQLite handles on every path: an unclosed writer leaves its
|
|
232
|
+
// write-ahead log behind for the next tool call to extend.
|
|
233
|
+
try {
|
|
234
|
+
runtime?.close();
|
|
235
|
+
}
|
|
236
|
+
catch { /* the decision is already recorded */ }
|
|
237
|
+
}
|
|
238
|
+
if (postHoc && permission === "deny") {
|
|
239
|
+
message = `${message}\nCursor reports a file edit only after it is written, so this edit was not prevented. Review and revert it yourself.`;
|
|
240
|
+
}
|
|
163
241
|
process.stdout.write(JSON.stringify({ permission, agentMessage: message }) + "\n");
|
|
164
242
|
process.exit(0);
|
|
165
243
|
}
|
|
@@ -170,43 +248,105 @@ async function runCursor() {
|
|
|
170
248
|
function requireInteractive(command, args) {
|
|
171
249
|
if (process.stdin.isTTY || args.includes("--yes"))
|
|
172
250
|
return;
|
|
173
|
-
console.error(`scopebond ${command} changes what governs your coding agent, so it
|
|
174
|
-
console.error(`
|
|
251
|
+
console.error(`scopebond ${command} changes what governs your coding agent, so it does not run unattended.`);
|
|
252
|
+
console.error(`There is no terminal on stdin here — which is also what it looks like when the agent itself`);
|
|
253
|
+
console.error(`tries to run this, so the refusal is deliberate rather than a bug.`);
|
|
254
|
+
console.error(``);
|
|
255
|
+
console.error(`If you are a person: run it in your own terminal, or confirm it now with --yes:`);
|
|
256
|
+
console.error(` ${cliCommand(`${command} --yes`)}`);
|
|
257
|
+
console.error(`Scripts, CI and container builds should always pass --yes.`);
|
|
175
258
|
process.exit(1);
|
|
176
259
|
}
|
|
177
260
|
function runInit(args) {
|
|
178
|
-
requireInteractive("init", args);
|
|
179
261
|
const harness = selectedHarness(args);
|
|
180
262
|
const dir = configDir();
|
|
181
|
-
|
|
263
|
+
// A dry run changes nothing, so it needs no terminal and no --yes.
|
|
264
|
+
if (args.includes("--dry-run")) {
|
|
265
|
+
const shared = projectHarnessFile(harness, process.cwd());
|
|
266
|
+
const local = localHarnessFile(harness, process.cwd());
|
|
267
|
+
const personal = args.includes("--shared") || args.includes("--npx") ? null
|
|
268
|
+
: local ?? (gitShareState(shared) === "tracked" ? null : shared);
|
|
269
|
+
console.log(`Dry run — nothing is written.\n`);
|
|
270
|
+
console.log(`Would scaffold ${dir} (machine key, countersigning key, starter policy, .gitignore)`);
|
|
271
|
+
if (args.includes("--no-install")) {
|
|
272
|
+
console.log(`Would print the ${harnessFileName(harness)} snippet instead of writing it`);
|
|
273
|
+
}
|
|
274
|
+
else if (personal) {
|
|
275
|
+
console.log(`Would configure ${personal} (this machine only; kept out of git)`);
|
|
276
|
+
console.log(` adding hook "<node>" "${pinnedCliPath(hookVersion())}" ${harness}`);
|
|
277
|
+
console.log(` (or the path this copy runs from, when it is already installed durably)`);
|
|
278
|
+
if (local)
|
|
279
|
+
console.log(` and remove any machine-specific Scopebond entry from ${shared}`);
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
console.log(`Would configure ${shared} (shared — safe to commit)`);
|
|
283
|
+
console.log(` adding hook ${hookCommand(harness)}`);
|
|
284
|
+
}
|
|
285
|
+
console.log(`\nNothing else in those files is changed. Run without --dry-run to apply.`);
|
|
286
|
+
process.exit(0);
|
|
287
|
+
}
|
|
288
|
+
requireInteractive("init", args);
|
|
289
|
+
const { agentKid, policyPath, rulesPath: rulesFile } = scaffold(dir, { force: args.includes("--force") });
|
|
182
290
|
console.log(`Scopebond hook enrolled in ${dir}`);
|
|
183
291
|
console.log(` machine key ${agentKid}`);
|
|
184
|
-
console.log(`
|
|
292
|
+
console.log(` rules ${rulesFile} (the readable list — edit this)`);
|
|
293
|
+
console.log(` policy ${policyPath} (compiled from the rules; don't hand-edit)`);
|
|
185
294
|
// With a user-level install present, a project policy governs only once trusted.
|
|
186
295
|
// Running init here is that decision, so pin this policy now.
|
|
187
296
|
if (!process.env.SCOPEBOND_HOOK_DIR && existsSync(join(userHome(), "policy.json"))) {
|
|
188
297
|
trustProjectPolicy(dir);
|
|
189
298
|
console.log(` trusted overrides ${userHome()} here; after editing it, run \`${cliCommand("trust")}\``);
|
|
190
299
|
}
|
|
191
|
-
|
|
300
|
+
// The hook command runs once per tool call, so it must start fast. `npx` re-resolves
|
|
301
|
+
// a package that is already on disk and costs ~830 ms a call; the same CLI invoked
|
|
302
|
+
// directly costs ~110 ms. Pin a durable copy and use that, and fall back to `npx`
|
|
303
|
+
// (slow, but it always starts) when no durable copy can be made. `--npx` forces the
|
|
304
|
+
// portable form for anyone who wants it.
|
|
305
|
+
const shared = args.includes("--shared");
|
|
306
|
+
const pin = args.includes("--npx") || shared ? { cli: null, how: "unavailable" } : ensureDurableRuntime(cliPath(), hookVersion());
|
|
307
|
+
const command = pin.cli ? absoluteHookCommand(pin.cli, harness) : undefined;
|
|
192
308
|
// Configure the agent automatically by default (idempotent), so there is no
|
|
193
309
|
// hand-editing step; --no-install prints the snippet instead.
|
|
194
310
|
if (!args.includes("--no-install")) {
|
|
195
|
-
let
|
|
311
|
+
let placed;
|
|
196
312
|
try {
|
|
197
|
-
|
|
313
|
+
placed = placeHook(harness, process.cwd(), command, { shared });
|
|
198
314
|
}
|
|
199
315
|
catch (error) {
|
|
200
316
|
console.error(error.message);
|
|
201
317
|
process.exit(1);
|
|
202
318
|
}
|
|
203
|
-
|
|
319
|
+
// No per-action millisecond claim here: it varies by machine, and this project only
|
|
320
|
+
// states numbers it has measured. The measured comparison lives in the changelog.
|
|
321
|
+
console.log(` hook runtime ${placed.scope === "personal"
|
|
322
|
+
? `${pin.cli}\n pinned — no npx resolution per action`
|
|
323
|
+
: `npx @scopebond/hook@${hookVersion()} — portable, but re-resolves on every action`}`);
|
|
324
|
+
console.log("");
|
|
325
|
+
console.log(`✓ ${harnessName(harness)} configured in ${placed.file}`);
|
|
326
|
+
console.log(placed.scope === "personal"
|
|
327
|
+
? ` this machine only — kept out of git, so no teammate inherits a path that does not exist for them`
|
|
328
|
+
: ` shared — the portable command starts on any machine that clones this project`);
|
|
329
|
+
if (placed.repaired > 0)
|
|
330
|
+
console.log(` moved a machine-specific hook out of ${projectHarnessFile(harness, process.cwd())}; commit that change`);
|
|
331
|
+
if (placed.note)
|
|
332
|
+
console.log(` note: ${placed.note}`);
|
|
204
333
|
if (harness === "codex")
|
|
205
334
|
console.log(`\nOne last step: ${codexTrustStep}`);
|
|
335
|
+
if (harness === "cursor")
|
|
336
|
+
console.log(`\n${cursorCoverageNote}`);
|
|
206
337
|
}
|
|
207
338
|
else {
|
|
339
|
+
// The snippet is for a file the user will likely commit, so it carries the portable
|
|
340
|
+
// command; the pinned one is offered separately, for a file only this machine uses.
|
|
208
341
|
console.log(`Add this to your ${harnessFileName(harness)}:`);
|
|
209
342
|
console.log(harnessSnippet(harness));
|
|
343
|
+
const local = localHarnessFile(harness, process.cwd());
|
|
344
|
+
if (command && local) {
|
|
345
|
+
console.log(`\nFaster, for this machine only — use this command in ${local} instead (keep that file out of git):`);
|
|
346
|
+
console.log(` ${command}`);
|
|
347
|
+
}
|
|
348
|
+
if (harness === "cursor")
|
|
349
|
+
console.log(`\n${cursorCoverageNote}`);
|
|
210
350
|
}
|
|
211
351
|
console.log("");
|
|
212
352
|
// Print the runnable `npx` form: after `npx @scopebond/hook init` there is no
|
|
@@ -225,15 +365,14 @@ function decisionOf(payload) {
|
|
|
225
365
|
return "allow";
|
|
226
366
|
}
|
|
227
367
|
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}` : ""}`;
|
|
368
|
+
return describeAction(payload.intent);
|
|
236
369
|
}
|
|
370
|
+
/** `log [-n N] [--deny] [--since <when>]` — the recent decisions.
|
|
371
|
+
*
|
|
372
|
+
* "What did my agents get blocked on this week" had no answer at the CLI: the only
|
|
373
|
+
* output was an unfiltered tail. `--deny` and `--since` make it answerable, and the
|
|
374
|
+
* tail is read as a tail (`ORDER BY id DESC LIMIT`) rather than by loading and parsing
|
|
375
|
+
* every receipt ever recorded and slicing the end off. */
|
|
237
376
|
async function runLog(args) {
|
|
238
377
|
const dir = resolveConfigDir(process.cwd());
|
|
239
378
|
const dbPath = join(dir, "receipts.db");
|
|
@@ -243,18 +382,259 @@ async function runLog(args) {
|
|
|
243
382
|
}
|
|
244
383
|
const nIdx = args.indexOf("-n");
|
|
245
384
|
const n = nIdx >= 0 ? Math.max(1, Number(args[nIdx + 1]) || 20) : 20;
|
|
385
|
+
const denyOnly = args.includes("--deny");
|
|
386
|
+
const sinceIdx = args.indexOf("--since");
|
|
387
|
+
const since = sinceIdx >= 0 ? parseSince(args[sinceIdx + 1]) : null;
|
|
388
|
+
if (sinceIdx >= 0 && since === null) {
|
|
389
|
+
console.error(`--since wants a duration (7d, 24h, 30m) or a date (2026-09-25); got ${args[sinceIdx + 1] ?? "nothing"}`);
|
|
390
|
+
process.exit(1);
|
|
391
|
+
}
|
|
246
392
|
const { store } = openReceiptStore({ db: dbPath });
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
393
|
+
try {
|
|
394
|
+
const total = store.count ? await Promise.resolve(store.count()) : (await Promise.resolve(store.list())).length;
|
|
395
|
+
// A filter has to look past the last N to find N matches; without one, read only the
|
|
396
|
+
// tail. The scan is still bounded so a huge log cannot hang the command.
|
|
397
|
+
const budget = denyOnly || since ? Math.min(Math.max(n * 50, 1000), 20000) : n;
|
|
398
|
+
const scanned = store.recent
|
|
399
|
+
? await Promise.resolve(store.recent(budget))
|
|
400
|
+
: (await Promise.resolve(store.list())).slice(-budget).reverse();
|
|
401
|
+
const matching = scanned.filter((r) => {
|
|
402
|
+
const p = r.payload;
|
|
403
|
+
if (denyOnly && decisionOf(p) !== "deny")
|
|
404
|
+
return false;
|
|
405
|
+
if (since) {
|
|
406
|
+
const at = Date.parse(String(p.timestamp ?? ""));
|
|
407
|
+
if (!Number.isFinite(at) || at < since)
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
return true;
|
|
411
|
+
});
|
|
412
|
+
const shown = matching.slice(0, n).reverse(); // oldest-first on screen, newest last
|
|
413
|
+
if (shown.length === 0) {
|
|
414
|
+
console.log(denyOnly || since ? "no receipts match that filter." : "no receipts yet.");
|
|
415
|
+
process.exit(0);
|
|
416
|
+
}
|
|
417
|
+
for (const r of shown) {
|
|
418
|
+
const p = r.payload;
|
|
419
|
+
console.log(`${String(p.timestamp ?? "")} ${decisionOf(p).padEnd(13)} ${describeIntent(p)}`);
|
|
420
|
+
}
|
|
421
|
+
const filters = [denyOnly ? "denied" : "", since ? `since ${new Date(since).toISOString()}` : ""].filter(Boolean).join(", ");
|
|
422
|
+
const scope = filters ? ` matching ${filters}` : "";
|
|
423
|
+
const capped = (denyOnly || since) && scanned.length >= budget && total > budget;
|
|
424
|
+
console.log(`\n${shown.length}${scope} of ${total} receipt(s)${capped ? ` — searched the most recent ${budget}` : ""}. Verify them: ${cliCommand("verify")}`);
|
|
425
|
+
}
|
|
426
|
+
finally {
|
|
427
|
+
try {
|
|
428
|
+
store.close?.();
|
|
429
|
+
}
|
|
430
|
+
catch { /* read-only */ }
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
/** `rules` — read and change the limits in plain terms.
|
|
434
|
+
*
|
|
435
|
+
* Without this, "edit the limits" meant hand-writing a ~700-character case-folded
|
|
436
|
+
* negative lookahead, which nobody does — so the starter policy was effectively the only
|
|
437
|
+
* policy. The lists live in `.scopebond/rules.json`; `policy.json` is compiled from them. */
|
|
438
|
+
function runRules(args) {
|
|
439
|
+
const dir = resolveConfigDir(process.cwd());
|
|
440
|
+
if (!existsSync(join(dir, "policy.json"))) {
|
|
441
|
+
console.error(`no policy here yet — run \`${cliCommand("init")}\` first.`);
|
|
442
|
+
process.exit(1);
|
|
443
|
+
}
|
|
444
|
+
const rules = loadRules(dir) ?? defaultRules();
|
|
445
|
+
const [verb, ...values] = args.filter((a) => !a.startsWith("--"));
|
|
446
|
+
const value = values.join(" ").trim();
|
|
447
|
+
if (!verb || verb === "show") {
|
|
448
|
+
console.log(`Rules for ${dir}`);
|
|
449
|
+
if (!loadRules(dir))
|
|
450
|
+
console.log(`(showing the defaults — ${rulesPath(dir)} will be written on your first change)`);
|
|
451
|
+
console.log("");
|
|
452
|
+
console.log(describeRules(rules));
|
|
453
|
+
console.log("");
|
|
454
|
+
console.log(`Change them:`);
|
|
455
|
+
console.log(` ${cliCommand("rules allow <program>")} stop blocking a program`);
|
|
456
|
+
console.log(` ${cliCommand("rules block <program>")} start blocking one`);
|
|
457
|
+
console.log(` ${cliCommand("rules protect <path>")} never write there`);
|
|
458
|
+
console.log(` ${cliCommand("rules unprotect <path>")} allow writing there again`);
|
|
459
|
+
console.log(` ${cliCommand("rules protect-branch <name>")} never push there`);
|
|
460
|
+
console.log(` ${cliCommand("rules unprotect-branch <name>")}`);
|
|
461
|
+
console.log(`Or edit ${rulesPath(dir)} directly, then \`${cliCommand("rules apply")}\`.`);
|
|
462
|
+
process.exit(0);
|
|
463
|
+
}
|
|
464
|
+
const needsValue = ["allow", "block", "protect", "unprotect", "protect-branch", "unprotect-branch"];
|
|
465
|
+
if (needsValue.includes(verb) && !value) {
|
|
466
|
+
console.error(`\`rules ${verb}\` needs a value, e.g. \`${cliCommand(`rules ${verb} ${verb.includes("branch") ? "production" : verb.includes("protect") ? "infra/" : "dd"}`)}\``);
|
|
467
|
+
process.exit(1);
|
|
468
|
+
}
|
|
469
|
+
let changed = "";
|
|
470
|
+
switch (verb) {
|
|
471
|
+
case "allow": {
|
|
472
|
+
const before = rules.destructive_programs.length;
|
|
473
|
+
rules.destructive_programs = rules.destructive_programs.filter((p) => p.toLowerCase() !== value.toLowerCase());
|
|
474
|
+
if (rules.destructive_programs.length === before) {
|
|
475
|
+
console.log(`${value} was not in the blocked list; nothing to change.`);
|
|
476
|
+
process.exit(0);
|
|
477
|
+
}
|
|
478
|
+
changed = `${value} is no longer blocked`;
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
481
|
+
case "block": {
|
|
482
|
+
if (rules.destructive_programs.some((p) => p.toLowerCase() === value.toLowerCase())) {
|
|
483
|
+
console.log(`${value} is already blocked.`);
|
|
484
|
+
process.exit(0);
|
|
485
|
+
}
|
|
486
|
+
rules.destructive_programs.push(value.toLowerCase());
|
|
487
|
+
changed = `${value} is now blocked`;
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
case "protect":
|
|
491
|
+
case "unprotect": {
|
|
492
|
+
const list = verb === "protect" ? "protected_write" : "protected_write";
|
|
493
|
+
const rule = pathRuleFor(value);
|
|
494
|
+
if (verb === "protect") {
|
|
495
|
+
if (rules[list].some((r) => r.label === rule.label)) {
|
|
496
|
+
console.log(`${value} is already protected.`);
|
|
497
|
+
process.exit(0);
|
|
498
|
+
}
|
|
499
|
+
rules[list].push(rule);
|
|
500
|
+
changed = `writes to ${rule.label} are now blocked`;
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
const before = rules[list].length;
|
|
504
|
+
rules[list] = rules[list].filter((r) => r.label !== rule.label && !r.label.startsWith(`${value.replace(/\/$/, "")}/`));
|
|
505
|
+
if (rules[list].length === before) {
|
|
506
|
+
console.log(`${value} is not in the protected list. Current list:`);
|
|
507
|
+
for (const r of rules[list])
|
|
508
|
+
console.log(` ${r.label}`);
|
|
509
|
+
process.exit(1);
|
|
510
|
+
}
|
|
511
|
+
changed = `writes to ${value} are allowed again`;
|
|
512
|
+
}
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
case "protect-branch": {
|
|
516
|
+
if (rules.protected_branches.some((b) => b.toLowerCase() === value.toLowerCase())) {
|
|
517
|
+
console.log(`${value} is already protected.`);
|
|
518
|
+
process.exit(0);
|
|
519
|
+
}
|
|
520
|
+
rules.protected_branches.push(value);
|
|
521
|
+
changed = `pushes to ${value} are now blocked`;
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
case "unprotect-branch": {
|
|
525
|
+
const before = rules.protected_branches.length;
|
|
526
|
+
rules.protected_branches = rules.protected_branches.filter((b) => b.toLowerCase() !== value.toLowerCase());
|
|
527
|
+
if (rules.protected_branches.length === before) {
|
|
528
|
+
console.log(`${value} was not protected; nothing to change.`);
|
|
529
|
+
process.exit(0);
|
|
530
|
+
}
|
|
531
|
+
changed = `pushes to ${value} are allowed again`;
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
case "apply":
|
|
535
|
+
changed = `recompiled from ${rulesPath(dir)}`;
|
|
536
|
+
break;
|
|
537
|
+
default:
|
|
538
|
+
console.error(`unknown: rules ${verb}`);
|
|
539
|
+
printHelp("rules", true);
|
|
540
|
+
process.exit(1);
|
|
541
|
+
}
|
|
542
|
+
// Changing what governs the agent is the same class of action as `init`.
|
|
543
|
+
requireInteractive("rules", args);
|
|
544
|
+
const policyPath = join(dir, "policy.json");
|
|
545
|
+
const agentKid = createSigner({ privateKeyPem: readFileSync(join(dir, "agent.key"), "utf8") }).kid;
|
|
546
|
+
saveRules(dir, rules);
|
|
547
|
+
writeFileSync(policyPath, `${JSON.stringify(compile(rules, agentKid), null, 2)}\n`);
|
|
548
|
+
console.log(`✓ ${changed}`);
|
|
549
|
+
console.log(` rules ${rulesPath(dir)}`);
|
|
550
|
+
console.log(` policy ${policyPath} (recompiled)`);
|
|
551
|
+
// A project policy governs only once trusted, and the hash just changed.
|
|
552
|
+
if (!process.env.SCOPEBOND_HOOK_DIR && existsSync(join(userHome(), "policy.json"))) {
|
|
553
|
+
trustProjectPolicy(dir);
|
|
554
|
+
console.log(` trusted re-pinned for this project`);
|
|
555
|
+
}
|
|
556
|
+
console.log(`\nCheck it: ${cliCommand('test "rm -rf /"')}`);
|
|
557
|
+
process.exit(0);
|
|
558
|
+
}
|
|
559
|
+
/** `prune --before <when> [--yes]` — bound the local receipt store.
|
|
560
|
+
*
|
|
561
|
+
* Signed receipts are the product, so this never runs on its own and never quietly
|
|
562
|
+
* destroys anything: it archives what it will remove to a JSONL file beside the
|
|
563
|
+
* database first, and it refuses entirely once the log has been anchored, because a
|
|
564
|
+
* receipt's position is its anchor leaf index. Without a `--before` it reports the
|
|
565
|
+
* footprint and exits. */
|
|
566
|
+
async function runPrune(args) {
|
|
567
|
+
const dir = resolveConfigDir(process.cwd());
|
|
568
|
+
const dbPath = join(dir, "receipts.db");
|
|
569
|
+
if (!existsSync(dbPath)) {
|
|
570
|
+
console.log("no local receipts yet — nothing to prune.");
|
|
571
|
+
process.exit(0);
|
|
572
|
+
}
|
|
573
|
+
const beforeIdx = args.indexOf("--before");
|
|
574
|
+
if (beforeIdx < 0) {
|
|
575
|
+
console.log(`local receipts ${dbPath}`);
|
|
576
|
+
console.log(` ${describeStore(dbPath)}`);
|
|
577
|
+
console.log(`\nNothing is removed automatically. To bound it, name a cutoff:`);
|
|
578
|
+
console.log(` ${cliCommand("prune --before 90d")} # older than 90 days`);
|
|
579
|
+
console.log(` ${cliCommand("prune --before 2026-01-01")}`);
|
|
580
|
+
console.log(`Receipts are archived beside the database before removal.`);
|
|
581
|
+
process.exit(0);
|
|
582
|
+
}
|
|
583
|
+
const cutoff = parseSince(args[beforeIdx + 1]);
|
|
584
|
+
if (cutoff === null) {
|
|
585
|
+
console.error(`--before wants a duration (90d, 24h) or a date (2026-01-01); got ${args[beforeIdx + 1] ?? "nothing"}`);
|
|
586
|
+
process.exit(1);
|
|
587
|
+
}
|
|
588
|
+
const iso = new Date(cutoff).toISOString();
|
|
589
|
+
const { store } = openReceiptStore({ db: dbPath });
|
|
590
|
+
const sqlite = store;
|
|
591
|
+
if (!sqlite.before || !sqlite.removeBefore) {
|
|
592
|
+
console.error("this receipt store does not support pruning (no SQLite available).");
|
|
593
|
+
process.exit(1);
|
|
594
|
+
}
|
|
595
|
+
try {
|
|
596
|
+
const doomed = sqlite.before(iso);
|
|
597
|
+
if (doomed.length === 0) {
|
|
598
|
+
console.log(`no receipts older than ${iso}.`);
|
|
599
|
+
process.exit(0);
|
|
600
|
+
}
|
|
601
|
+
const before = describeStore(dbPath);
|
|
602
|
+
console.log(`${doomed.length} receipt(s) recorded before ${iso} (store is currently ${before}).`);
|
|
603
|
+
if (!args.includes("--yes") && !process.stdin.isTTY) {
|
|
604
|
+
console.error(`\nThis removes signed evidence, so it needs an explicit confirmation:`);
|
|
605
|
+
console.error(` ${cliCommand(`prune --before ${args[beforeIdx + 1]} --yes`)}`);
|
|
606
|
+
process.exit(1);
|
|
607
|
+
}
|
|
608
|
+
const archive = join(dir, `receipts-archived-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`);
|
|
609
|
+
writeFileSync(archive, `${doomed.map((r) => JSON.stringify(r)).join("\n")}\n`);
|
|
610
|
+
console.log(`archived to ${archive}`);
|
|
611
|
+
const { removed } = sqlite.removeBefore(iso);
|
|
612
|
+
console.log(`removed ${removed} receipt(s)`);
|
|
613
|
+
store.close?.();
|
|
614
|
+
console.log(`store now ${describeStore(dbPath)}`);
|
|
615
|
+
console.log(`\nThe archive is a plain JSONL of signed receipts — still verifiable, still yours.`);
|
|
251
616
|
process.exit(0);
|
|
252
617
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
618
|
+
catch (error) {
|
|
619
|
+
try {
|
|
620
|
+
store.close?.();
|
|
621
|
+
}
|
|
622
|
+
catch { /* closing after a failure */ }
|
|
623
|
+
console.error(`prune refused: ${error.message}`);
|
|
624
|
+
process.exit(1);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
/** A `--since` value: a duration (`7d`, `24h`, `30m`) or an ISO-ish date. */
|
|
628
|
+
export function parseSince(value, now = Date.now()) {
|
|
629
|
+
if (!value)
|
|
630
|
+
return null;
|
|
631
|
+
const duration = /^(\d+)\s*([dhm])$/i.exec(value.trim());
|
|
632
|
+
if (duration) {
|
|
633
|
+
const scale = { d: 86_400_000, h: 3_600_000, m: 60_000 }[duration[2].toLowerCase()];
|
|
634
|
+
return now - Number(duration[1]) * scale;
|
|
256
635
|
}
|
|
257
|
-
|
|
636
|
+
const at = Date.parse(value);
|
|
637
|
+
return Number.isFinite(at) ? at : null;
|
|
258
638
|
}
|
|
259
639
|
async function runVerify() {
|
|
260
640
|
const dir = resolveConfigDir(process.cwd());
|
|
@@ -266,10 +646,18 @@ async function runVerify() {
|
|
|
266
646
|
}
|
|
267
647
|
const { attester } = loadOrCreateAttester({ file: attesterPath });
|
|
268
648
|
const { store } = openReceiptStore({ db: dbPath });
|
|
649
|
+
// Verification is the one command that must read everything — that is the point of it.
|
|
650
|
+
// It just should not look hung while doing so: at ~2.6 s per 20,000 receipts a long
|
|
651
|
+
// history is a visible wait, so report progress on a TTY.
|
|
269
652
|
const all = await Promise.resolve(store.list());
|
|
653
|
+
try {
|
|
654
|
+
store.close?.();
|
|
655
|
+
}
|
|
656
|
+
catch { /* read-only */ }
|
|
657
|
+
const progress = process.stdout.isTTY && all.length >= 2000;
|
|
270
658
|
let ok = 0;
|
|
271
659
|
const bad = [];
|
|
272
|
-
for (const r of all) {
|
|
660
|
+
for (const [index, r] of all.entries()) {
|
|
273
661
|
const result = verifyReceipt(r, attester.publicKeyPem);
|
|
274
662
|
if (result.valid)
|
|
275
663
|
ok += 1;
|
|
@@ -277,7 +665,11 @@ async function runVerify() {
|
|
|
277
665
|
const failed = Object.entries(result).filter(([k, v]) => k.endsWith("_valid") && v === false).map(([k]) => k);
|
|
278
666
|
bad.push(`${String(r.payload.timestamp ?? "")}: ${failed.join(", ") || "invalid"}`);
|
|
279
667
|
}
|
|
668
|
+
if (progress && (index + 1) % 1000 === 0)
|
|
669
|
+
process.stderr.write(`\rverifying ${index + 1}/${all.length}…`);
|
|
280
670
|
}
|
|
671
|
+
if (progress)
|
|
672
|
+
process.stderr.write("\r".padEnd(40) + "\r");
|
|
281
673
|
console.log(`${ok}/${all.length} receipt(s) verify offline against ${attesterPath}.`);
|
|
282
674
|
if (bad.length) {
|
|
283
675
|
for (const b of bad)
|
|
@@ -294,7 +686,7 @@ async function runTest(args) {
|
|
|
294
686
|
}
|
|
295
687
|
const dir = resolveConfigDir(process.cwd());
|
|
296
688
|
if (!existsSync(join(dir, "policy.json"))) {
|
|
297
|
-
console.error(
|
|
689
|
+
console.error(`no policy yet — run \`${cliCommand("init")}\` first.`);
|
|
298
690
|
process.exit(1);
|
|
299
691
|
}
|
|
300
692
|
// Evaluate against the real policy and keys, but a throwaway store, so `test`
|
|
@@ -310,10 +702,17 @@ async function runTest(args) {
|
|
|
310
702
|
console.log(`command: ${command}`);
|
|
311
703
|
for (const m of mapped) {
|
|
312
704
|
const d = await runtime.evaluateOne(m);
|
|
313
|
-
|
|
705
|
+
// One tidy row per action. A deny's full explanation is multi-line, so the
|
|
706
|
+
// row carries only the deciding rule and the whole message is printed once,
|
|
707
|
+
// below — exactly as the agent will receive it.
|
|
708
|
+
const note = d.decision === "deny" ? (d.clauseId ? `rule "${d.clauseId}"` : "")
|
|
709
|
+
: d.decision === "not_evaluated" ? d.reason : "";
|
|
710
|
+
console.log(` ${describeIntent({ intent: m.intent }).padEnd(28)} → ${d.decision.padEnd(14)}${note}`);
|
|
314
711
|
}
|
|
315
712
|
const overall = await runtime.evaluate(mapped);
|
|
316
|
-
console.log(`\noverall: ${overall.decision}
|
|
713
|
+
console.log(`\noverall: ${overall.decision}`);
|
|
714
|
+
if (overall.decision === "deny" && overall.reason)
|
|
715
|
+
console.log(`\n${overall.reason}`);
|
|
317
716
|
process.exit(overall.decision === "deny" ? 2 : 0);
|
|
318
717
|
}
|
|
319
718
|
finally {
|
|
@@ -326,7 +725,8 @@ async function runConnect(args) {
|
|
|
326
725
|
const bundleArg = positional[1];
|
|
327
726
|
const harness = selectedHarness(args);
|
|
328
727
|
if (!url) {
|
|
329
|
-
console.error(
|
|
728
|
+
console.error(`usage: ${cliCommand("connect <workspace-url> <enrollment> [--claude|--cursor|--codex] [--no-install]")}`);
|
|
729
|
+
console.error(enrollmentHelp);
|
|
330
730
|
process.exit(1);
|
|
331
731
|
}
|
|
332
732
|
const dir = configDir();
|
|
@@ -340,16 +740,28 @@ async function runConnect(args) {
|
|
|
340
740
|
bundle = readBundleArg(bundleArg, readStdin);
|
|
341
741
|
}
|
|
342
742
|
catch {
|
|
343
|
-
console.error(
|
|
743
|
+
console.error(`could not read the enrollment (expected a file, inline blob, or JSON on stdin)\n${enrollmentHelp}`);
|
|
344
744
|
process.exit(1);
|
|
345
745
|
}
|
|
746
|
+
await finishConnect(dir, url, bundle, harness, args);
|
|
747
|
+
}
|
|
748
|
+
/** Enroll with a bundle and wire the agent: shared by `connect` (a pasted enrollment)
|
|
749
|
+
* and `login` (one received through device-code approval). */
|
|
750
|
+
async function finishConnect(dir, url, bundle, harness, args) {
|
|
346
751
|
try {
|
|
347
752
|
const c = await connectCloud(dir, url, bundle);
|
|
348
753
|
console.log(`✓ Connected to ${c.url}`);
|
|
349
754
|
// Configure the agent automatically (merges into the existing config), unless the
|
|
350
|
-
// caller opts out. This removes the "paste this snippet" step.
|
|
351
|
-
|
|
352
|
-
|
|
755
|
+
// caller opts out. This removes the "paste this snippet" step. A hook that is already
|
|
756
|
+
// configured — pinned by `init`, or user-level by `install` — is left as it is:
|
|
757
|
+
// connecting changes where receipts go, not how the hook starts.
|
|
758
|
+
const scopes = harnessScopes(harness, process.cwd());
|
|
759
|
+
const existing = scopes.local ?? scopes.project ?? scopes.user;
|
|
760
|
+
if (existing && !args.includes("--no-install")) {
|
|
761
|
+
console.log(`✓ ${harnessName(harness)} already configured in ${existing}`);
|
|
762
|
+
}
|
|
763
|
+
else if (!args.includes("--no-install")) {
|
|
764
|
+
const { file } = placeHook(harness, process.cwd(), undefined);
|
|
353
765
|
console.log(`✓ ${harnessName(harness)} configured in ${file}`);
|
|
354
766
|
if (harness === "codex")
|
|
355
767
|
console.log(`\nOne last step: ${codexTrustStep}`);
|
|
@@ -362,14 +774,26 @@ async function runConnect(args) {
|
|
|
362
774
|
console.log("Run your agent — the first action appears in your workspace within seconds.");
|
|
363
775
|
}
|
|
364
776
|
catch (error) {
|
|
365
|
-
|
|
777
|
+
const message = error.message;
|
|
778
|
+
console.error(`connect failed: ${message}`);
|
|
779
|
+
if (/enrollment|expired|401|403/i.test(message))
|
|
780
|
+
console.error(enrollmentHelp);
|
|
366
781
|
process.exit(1);
|
|
367
782
|
}
|
|
368
783
|
}
|
|
784
|
+
/** Where an enrollment comes from, for every connect error that means "this one will
|
|
785
|
+
* not work": the bare "invalid enrollment token" told the reader nothing about what to
|
|
786
|
+
* do next. */
|
|
787
|
+
const enrollmentHelp = [
|
|
788
|
+
"An enrollment comes from your Scopebond workspace: open it, choose to connect an agent,",
|
|
789
|
+
"and copy the command it shows — it includes the workspace URL and a fresh enrollment.",
|
|
790
|
+
"Each enrollment is single-use and expires soon after it is created; if this one was",
|
|
791
|
+
"used or has expired, create a new one there.",
|
|
792
|
+
].join("\n");
|
|
369
793
|
async function runFlush() {
|
|
370
794
|
const dir = resolveConfigDir(process.cwd());
|
|
371
795
|
if (!loadConnection(dir)) {
|
|
372
|
-
console.error(
|
|
796
|
+
console.error(`not connected to a workspace; run \`${cliCommand("connect <workspace-url> <enrollment>")}\` first`);
|
|
373
797
|
process.exit(1);
|
|
374
798
|
}
|
|
375
799
|
const runtime = createHookRuntime(runtimePaths(dir));
|
|
@@ -389,20 +813,53 @@ function cliPath() {
|
|
|
389
813
|
* so every project a developer opens is governed without a per-repo `init`. */
|
|
390
814
|
function runInstall(args) {
|
|
391
815
|
const dir = userHome();
|
|
816
|
+
// Run through `npx`, this CLI lives in npm's throwaway cache; registering that path
|
|
817
|
+
// would leave a hook that stops starting whenever npm clears it — and a hook that
|
|
818
|
+
// cannot start lets every action through. Pin the durable copy, as `init` does, and
|
|
819
|
+
// fall back to the portable `npx` command when none can be made.
|
|
820
|
+
const commandFor = (h) => {
|
|
821
|
+
const pin = ensureDurableRuntime(cliPath(), hookVersion());
|
|
822
|
+
return pin.cli ? absoluteHookCommand(pin.cli, h) : hookCommand(h);
|
|
823
|
+
};
|
|
824
|
+
const harnessesFor = () => args.includes("--codex") ? ["codex"]
|
|
825
|
+
: args.includes("--cursor") ? ["cursor"]
|
|
826
|
+
: args.includes("--claude") ? ["claude"]
|
|
827
|
+
: ["claude", ...(cursorDetected() ? ["cursor"] : []), ...(codexDetected() ? ["codex"] : [])];
|
|
828
|
+
// `install` rewrites agent config files the user did not create — their theme, plugins
|
|
829
|
+
// and permissions live in ~/.claude/settings.json — so there is a way to see exactly
|
|
830
|
+
// what it would touch before it touches anything.
|
|
831
|
+
if (args.includes("--dry-run")) {
|
|
832
|
+
console.log(`Dry run — nothing is written.\n`);
|
|
833
|
+
console.log(`Would scaffold ${dir} (machine key, countersigning key, starter policy)`);
|
|
834
|
+
for (const h of harnessesFor()) {
|
|
835
|
+
const file = userHarnessFile(h);
|
|
836
|
+
const exists = existsSync(file);
|
|
837
|
+
console.log(`Would ${exists ? "modify" : "create"} ${file}`);
|
|
838
|
+
if (exists)
|
|
839
|
+
console.log(` backing it up to ${file}.scopebond-backup`);
|
|
840
|
+
// Previewed without copying anything: the path the real run would pin.
|
|
841
|
+
console.log(` adding hook ${absoluteHookCommand(isEphemeralPath(cliPath()) ? pinnedCliPath(hookVersion()) : cliPath(), h)}`);
|
|
842
|
+
if (exists && isHarnessConfigured(file))
|
|
843
|
+
console.log(` (a Scopebond hook is already there; it would be replaced, not duplicated)`);
|
|
844
|
+
}
|
|
845
|
+
console.log(`\nNothing else in those files is changed. Run without --dry-run to apply.`);
|
|
846
|
+
process.exit(0);
|
|
847
|
+
}
|
|
392
848
|
const { agentKid, policyPath } = scaffold(dir, { force: args.includes("--force") });
|
|
393
849
|
console.log(`Scopebond installed for this user in ${dir}`);
|
|
394
850
|
console.log(` machine key ${agentKid}`);
|
|
395
851
|
console.log(` policy ${policyPath} (starter — edit the limits)`);
|
|
396
852
|
console.log("");
|
|
397
|
-
const harnesses =
|
|
398
|
-
: args.includes("--cursor") ? ["cursor"]
|
|
399
|
-
: args.includes("--claude") ? ["claude"]
|
|
400
|
-
: ["claude", ...(cursorDetected() ? ["cursor"] : []), ...(codexDetected() ? ["codex"] : [])];
|
|
853
|
+
const harnesses = harnessesFor();
|
|
401
854
|
if (!args.includes("--no-install")) {
|
|
402
855
|
for (const h of harnesses) {
|
|
403
856
|
try {
|
|
404
|
-
const
|
|
857
|
+
const target = userHarnessFile(h);
|
|
858
|
+
const backup = existsSync(target) ? `${target}.scopebond-backup` : null;
|
|
859
|
+
const file = writeHarnessConfig(target, h, commandFor(h));
|
|
405
860
|
console.log(`✓ ${harnessName(h)} configured in ${file}`);
|
|
861
|
+
if (backup && existsSync(backup))
|
|
862
|
+
console.log(` original kept at ${backup}`);
|
|
406
863
|
}
|
|
407
864
|
catch (error) {
|
|
408
865
|
console.error(`✗ ${harnessName(h)}: ${error.message}`);
|
|
@@ -421,25 +878,59 @@ function runInstall(args) {
|
|
|
421
878
|
console.log(`Check it: ${cliCommand("doctor")} · see decisions: ${cliCommand("log")}`);
|
|
422
879
|
console.log(`To send receipts to a workspace: ${cliCommand("connect <workspace-url> <enrollment>")}`);
|
|
423
880
|
}
|
|
881
|
+
/** Size and count of the local receipt store, plus its write-ahead log. Signed evidence
|
|
882
|
+
* accumulates in the user's project directory and there is no automatic deletion — so
|
|
883
|
+
* the footprint is reported rather than left to be discovered. */
|
|
884
|
+
function describeStore(dbPath) {
|
|
885
|
+
const bytes = (file) => { try {
|
|
886
|
+
return statSync(file).size;
|
|
887
|
+
}
|
|
888
|
+
catch {
|
|
889
|
+
return 0;
|
|
890
|
+
} };
|
|
891
|
+
const total = bytes(dbPath) + bytes(`${dbPath}-wal`) + bytes(`${dbPath}-shm`);
|
|
892
|
+
const human = total >= 1024 * 1024 ? `${(total / 1024 / 1024).toFixed(1)} MiB` : `${Math.max(1, Math.round(total / 1024))} KiB`;
|
|
893
|
+
let count = null;
|
|
894
|
+
try {
|
|
895
|
+
const { store } = openReceiptStore({ db: dbPath });
|
|
896
|
+
try {
|
|
897
|
+
count = store.count ? Number(store.count()) : null;
|
|
898
|
+
}
|
|
899
|
+
finally {
|
|
900
|
+
store.close?.();
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
catch {
|
|
904
|
+
count = null;
|
|
905
|
+
}
|
|
906
|
+
return count === null ? human : `${count} receipt(s), ${human}`;
|
|
907
|
+
}
|
|
424
908
|
function runStatus() {
|
|
425
909
|
const home = userHome();
|
|
426
910
|
const installed = existsSync(join(home, "policy.json"));
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
const
|
|
911
|
+
// Both scopes, always: `init` writes the project config and `install` writes the
|
|
912
|
+
// user one, so a single-scope check contradicts whichever command the user ran.
|
|
913
|
+
const claude = harnessScopes("claude", process.cwd());
|
|
914
|
+
const cursor = harnessScopes("cursor", process.cwd());
|
|
915
|
+
const codex = harnessScopes("codex", process.cwd());
|
|
430
916
|
const connected = !!loadConnection(resolveConfigDir(process.cwd()));
|
|
431
917
|
const dbPath = join(resolveConfigDir(process.cwd()), "receipts.db");
|
|
432
918
|
console.log(`Scopebond hook ${hookVersion()}`);
|
|
433
|
-
console.log(` user home ${home} ${installed ? "(installed)" :
|
|
919
|
+
console.log(` user home ${home} ${installed ? "(installed)" : `(not installed — run \`${cliCommand("install")}\`)`}`);
|
|
434
920
|
console.log(` active config ${resolveConfigDir(process.cwd())}`);
|
|
435
921
|
const ignored = untrustedProjectPolicy(process.cwd());
|
|
436
922
|
if (ignored)
|
|
437
923
|
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 ?
|
|
924
|
+
console.log(` Claude Code ${harnessScopeLabel(claude) || "not configured"}`);
|
|
925
|
+
console.log(` Cursor ${harnessScopeLabel(cursor) || (cursorDetected() ? "detected, not configured" : "not detected")}`);
|
|
926
|
+
console.log(` Codex ${codex.project || codex.user ? `${harnessScopeLabel(codex)} — approve once with /hooks` : codexDetected() ? "detected, not configured" : "not detected"}`);
|
|
441
927
|
console.log(` cloud workspace ${connected ? "connected" : "not connected (local only)"}`);
|
|
442
|
-
console.log(` local receipts ${existsSync(dbPath) ? dbPath : "none yet"}`);
|
|
928
|
+
console.log(` local receipts ${existsSync(dbPath) ? `${dbPath} (${describeStore(dbPath)})` : "none yet"}`);
|
|
929
|
+
for (const [name, scopes] of [["Claude Code", claude], ["Cursor", cursor], ["Codex", codex]]) {
|
|
930
|
+
for (const file of [scopes.project, scopes.local, scopes.user])
|
|
931
|
+
if (file)
|
|
932
|
+
console.log(` ${name}: ${file}`);
|
|
933
|
+
}
|
|
443
934
|
}
|
|
444
935
|
async function runDoctor() {
|
|
445
936
|
const problems = [];
|
|
@@ -453,16 +944,55 @@ async function runDoctor() {
|
|
|
453
944
|
console.log(` cli ${cli} ${existsSync(cli) ? "ok" : "MISSING"}`);
|
|
454
945
|
const active = resolveConfigDir(process.cwd());
|
|
455
946
|
const hasPolicy = existsSync(join(active, "policy.json"));
|
|
456
|
-
console.log(` active config ${active} ${hasPolicy ? "ok" :
|
|
947
|
+
console.log(` active config ${active} ${hasPolicy ? "ok" : `no policy (run \`${cliCommand("init")}\` here, or \`${cliCommand("install")}\` once for your user)`}`);
|
|
457
948
|
if (!hasPolicy)
|
|
458
949
|
problems.push("no policy found in the active config dir");
|
|
459
950
|
const ignored = untrustedProjectPolicy(process.cwd());
|
|
460
951
|
if (ignored)
|
|
461
952
|
console.log(` project policy ${ignored} IGNORED — not trusted (never trusted, or edited since). Review it, then \`${cliCommand("trust")}\``);
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
953
|
+
// Every harness, in both scopes, plus a check that each configured command can
|
|
954
|
+
// actually start. A pinned path that has gone missing is the one failure mode of
|
|
955
|
+
// the fast absolute-path install, so doctor is where it must surface.
|
|
956
|
+
let anyHarness = false;
|
|
957
|
+
for (const harness of ["claude", "cursor", "codex"]) {
|
|
958
|
+
const scopes = harnessScopes(harness, process.cwd());
|
|
959
|
+
const label = harnessScopeLabel(scopes);
|
|
960
|
+
const name = harnessName(harness);
|
|
961
|
+
if (!label) {
|
|
962
|
+
const detected = harness === "claude" || (harness === "cursor" ? cursorDetected() : codexDetected());
|
|
963
|
+
console.log(` ${name.padEnd(15)} ${detected ? `not configured — run \`${cliCommand(`init${harness === "claude" ? "" : ` --${harness}`}`)}\`` : "not detected"}`);
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
anyHarness = true;
|
|
967
|
+
console.log(` ${name.padEnd(15)} ${label}`);
|
|
968
|
+
for (const file of [scopes.project, scopes.local, scopes.user]) {
|
|
969
|
+
if (!file)
|
|
970
|
+
continue;
|
|
971
|
+
// A project file git shares must not name a path on this machine: it starts here,
|
|
972
|
+
// so the resolve check below passes, but on every teammate's machine it cannot
|
|
973
|
+
// start — and a hook that cannot start is a non-blocking error, so their agent runs
|
|
974
|
+
// unchecked. Only the doctor on the machine that wrote it can see this coming.
|
|
975
|
+
const share = file === scopes.project ? gitShareState(file) : "none";
|
|
976
|
+
const shared = share === "tracked" || share === "untracked";
|
|
977
|
+
for (const command of configuredHookCommands(file)) {
|
|
978
|
+
const ok = hookCommandResolves(command);
|
|
979
|
+
const leaks = ok && shared && isMachineSpecificCommand(command);
|
|
980
|
+
console.log(` ${ok && !leaks ? "ok " : "BAD "} ${file}`);
|
|
981
|
+
if (!ok) {
|
|
982
|
+
console.log(` command cannot start: ${command}`);
|
|
983
|
+
problems.push(`${name} hook command no longer resolves in ${file} — run \`${cliCommand("init")}\` to repair it`);
|
|
984
|
+
}
|
|
985
|
+
else if (leaks) {
|
|
986
|
+
console.log(` machine-specific command in a file git shares: ${command}`);
|
|
987
|
+
problems.push(`${name} hook in ${file} names a path on this machine and git shares that file — anyone who clones it gets a hook that cannot start, and their agent runs unchecked. Run \`${cliCommand(`init${harness === "claude" ? "" : ` --${harness}`}`)}\` to move it, then commit the change`);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
if (harness === "codex")
|
|
992
|
+
console.log(` run /hooks in Codex and approve Scopebond once`);
|
|
993
|
+
}
|
|
994
|
+
if (!anyHarness)
|
|
995
|
+
problems.push(`no coding agent is configured — run \`${cliCommand("init")}\` in your project root`);
|
|
466
996
|
const connection = loadConnection(active);
|
|
467
997
|
if (!connection) {
|
|
468
998
|
console.log(` cloud not connected (local only) — receipts stay on this machine`);
|
|
@@ -484,20 +1014,29 @@ async function runDoctor() {
|
|
|
484
1014
|
function runUninstall(args) {
|
|
485
1015
|
requireInteractive("uninstall", args);
|
|
486
1016
|
let removed = 0;
|
|
1017
|
+
// Both scopes. Checking only the user config meant that after a per-project `init` —
|
|
1018
|
+
// the install the site actually tells people to run — `uninstall` reported "no
|
|
1019
|
+
// user-level harness config found" and left the project hook in place.
|
|
487
1020
|
for (const h of ["claude", "cursor", "codex"]) {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
1021
|
+
for (const file of [projectHarnessFile(h, process.cwd()), localHarnessFile(h, process.cwd()), userHarnessFile(h)]) {
|
|
1022
|
+
if (file && removeHarnessConfig(file)) {
|
|
1023
|
+
console.log(`✓ removed the Scopebond hook from ${file}`);
|
|
1024
|
+
removed++;
|
|
1025
|
+
}
|
|
491
1026
|
}
|
|
492
1027
|
}
|
|
493
1028
|
if (removed === 0)
|
|
494
|
-
console.log("no user
|
|
1029
|
+
console.log("no Scopebond hook found in this project or your user config.");
|
|
495
1030
|
if (args.includes("--purge")) {
|
|
496
1031
|
purgeHome();
|
|
497
1032
|
console.log(`✓ purged ${userHome()} (keys, policy, receipts)`);
|
|
498
1033
|
}
|
|
499
1034
|
else
|
|
500
1035
|
console.log(`Kept ${userHome()} (keys, policy, receipts). Use --purge to remove it too.`);
|
|
1036
|
+
const projectDir = resolveConfigDir(process.cwd());
|
|
1037
|
+
if (existsSync(join(projectDir, "policy.json"))) {
|
|
1038
|
+
console.log(`Kept ${projectDir} (this project's keys, policy and receipts) — delete it by hand if you want it gone.`);
|
|
1039
|
+
}
|
|
501
1040
|
}
|
|
502
1041
|
/** `trust` — let this project's .scopebond policy govern here instead of the user
|
|
503
1042
|
* home, pinned to its current contents. Run by the user, not the agent: the file it
|
|
@@ -513,11 +1052,195 @@ function runTrust(args) {
|
|
|
513
1052
|
console.log(`✓ trusted ${join(dir, "policy.json")} (sha256 ${digest.slice(0, 12)}…)`);
|
|
514
1053
|
console.log("It governs agents in this project until it changes; after any edit, review it and run trust again.");
|
|
515
1054
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
1055
|
+
/** `login <workspace-url>` — connect this computer without pasting anything. It asks
|
|
1056
|
+
* the workspace for a short code, shows it with the page to open, and waits while a
|
|
1057
|
+
* person who can manage the workspace approves it there for an environment and agent.
|
|
1058
|
+
* The approval hands back a single-use enrollment, which completes exactly as
|
|
1059
|
+
* `connect` does. Nothing secret is printed: the device code stays in memory. */
|
|
1060
|
+
async function runLogin(args) {
|
|
1061
|
+
const positional = args.filter((a) => !a.startsWith("--"));
|
|
1062
|
+
const harness = selectedHarness(args);
|
|
1063
|
+
let origin;
|
|
1064
|
+
try {
|
|
1065
|
+
const parsed = new URL(positional[0] ?? "");
|
|
1066
|
+
const local = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]";
|
|
1067
|
+
if (parsed.protocol !== "https:" && !(local && parsed.protocol === "http:"))
|
|
1068
|
+
throw new Error("https required");
|
|
1069
|
+
origin = parsed.origin;
|
|
1070
|
+
}
|
|
1071
|
+
catch {
|
|
1072
|
+
console.error(`usage: ${cliCommand("login <workspace-url> [--claude|--cursor|--codex] [--no-install]")}`);
|
|
1073
|
+
console.error("The workspace URL is the address of your Scopebond workspace, for example https://cloud.scopebond.com.");
|
|
1074
|
+
process.exit(1);
|
|
1075
|
+
}
|
|
1076
|
+
const post = async (path, body) => {
|
|
1077
|
+
const response = await fetch(new URL(path, origin), {
|
|
1078
|
+
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
|
|
1079
|
+
redirect: "error", signal: AbortSignal.timeout(15_000),
|
|
1080
|
+
});
|
|
1081
|
+
return { status: response.status, json: await response.json().catch(() => ({})) };
|
|
1082
|
+
};
|
|
1083
|
+
let start;
|
|
1084
|
+
try {
|
|
1085
|
+
start = await post("/v1/device/code", { client_name: hostname(), harness });
|
|
1086
|
+
}
|
|
1087
|
+
catch (error) {
|
|
1088
|
+
console.error(`could not reach ${origin}: ${error.message}`);
|
|
1089
|
+
process.exit(1);
|
|
1090
|
+
}
|
|
1091
|
+
const deviceCode = typeof start.json.device_code === "string" ? start.json.device_code : "";
|
|
1092
|
+
if (start.status !== 200 || !deviceCode) {
|
|
1093
|
+
console.error(`${origin} did not start a login (HTTP ${start.status}). Check the workspace URL, or use ${cliCommand("connect <workspace-url> <enrollment>")}.`);
|
|
1094
|
+
process.exit(1);
|
|
1095
|
+
}
|
|
1096
|
+
const userCode = String(start.json.user_code ?? "");
|
|
1097
|
+
const verify = String(start.json.verification_uri_complete ?? start.json.verification_uri ?? origin);
|
|
1098
|
+
let intervalMs = Math.max(1, Number(start.json.interval ?? 5)) * 1000;
|
|
1099
|
+
const deadline = Date.now() + Math.max(60, Number(start.json.expires_in ?? 600)) * 1000;
|
|
1100
|
+
console.log(`To connect this computer, open:\n\n ${verify}\n\nand check that it shows the code ${userCode}\n`);
|
|
1101
|
+
console.log("Waiting for approval (the code expires in 10 minutes; Ctrl+C to stop)…");
|
|
1102
|
+
const dir = configDir();
|
|
1103
|
+
scaffold(dir, {});
|
|
1104
|
+
while (Date.now() < deadline) {
|
|
1105
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
1106
|
+
let polled;
|
|
1107
|
+
try {
|
|
1108
|
+
polled = await post("/v1/device/token", { device_code: deviceCode });
|
|
1109
|
+
}
|
|
1110
|
+
catch {
|
|
1111
|
+
continue;
|
|
1112
|
+
} // a transient network error: keep waiting until the deadline
|
|
1113
|
+
if (polled.status === 200 && polled.json.enrollment && typeof polled.json.enrollment === "object") {
|
|
1114
|
+
console.log("✓ Approved");
|
|
1115
|
+
await finishConnect(dir, origin, polled.json.enrollment, harness, args);
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
const error = polled.json.error;
|
|
1119
|
+
if (error === "authorization_pending")
|
|
1120
|
+
continue;
|
|
1121
|
+
if (error === "slow_down") {
|
|
1122
|
+
intervalMs += 5_000;
|
|
1123
|
+
continue;
|
|
1124
|
+
}
|
|
1125
|
+
if (error === "access_denied") {
|
|
1126
|
+
console.error("The request was denied in the workspace. Nothing was connected.");
|
|
1127
|
+
process.exit(1);
|
|
1128
|
+
}
|
|
1129
|
+
if (error === "expired_token")
|
|
1130
|
+
break;
|
|
1131
|
+
console.error(`login failed (${String(error ?? `HTTP ${polled.status}`)}). Run the command again for a new code.`);
|
|
1132
|
+
process.exit(1);
|
|
1133
|
+
}
|
|
1134
|
+
console.error("The code expired before it was approved. Run the command again for a new one.");
|
|
1135
|
+
process.exit(1);
|
|
1136
|
+
}
|
|
1137
|
+
/** What each command does, its arguments, and one example. The whole help used to be a
|
|
1138
|
+
* single usage line listing 15 command names, which told a reader nothing about what any
|
|
1139
|
+
* of them did or what arguments they take. */
|
|
1140
|
+
const COMMANDS = [
|
|
1141
|
+
{ name: "init", args: "[--cursor|--codex] [--shared] [--dry-run] [--no-install] [--npx] [--force] [--yes]",
|
|
1142
|
+
summary: "set this project up: keys, a starter policy, and your agent wired to the hook",
|
|
1143
|
+
detail: [
|
|
1144
|
+
"Writes .scopebond/ (machine key, countersigning key, starter policy, .gitignore) and",
|
|
1145
|
+
"wires your agent to the hook. It pins a durable copy of this package so the hook starts",
|
|
1146
|
+
"fast, and because that command names paths on this machine it goes where git will not",
|
|
1147
|
+
"share it: .claude/settings.local.json (kept out of git for this clone), or for Cursor",
|
|
1148
|
+
"and Codex their project file only while git does not track it. --shared writes the",
|
|
1149
|
+
"portable npx command to .claude/settings.json, .cursor/hooks.json or .codex/hooks.json",
|
|
1150
|
+
"instead, so everyone who clones the project gets the hook. --dry-run shows what it",
|
|
1151
|
+
"would write. --no-install prints the config snippet rather than writing it.",
|
|
1152
|
+
"Needs a terminal, or --yes in a script, because it changes what governs your agent.",
|
|
1153
|
+
] },
|
|
1154
|
+
{ name: "install", args: "[--claude] [--cursor] [--codex] [--dry-run] [--force] [--yes]",
|
|
1155
|
+
summary: "set up once for this user, so every project you open is governed",
|
|
1156
|
+
detail: [
|
|
1157
|
+
"Scaffolds ~/.scopebond and registers the hook in your user-level agent config.",
|
|
1158
|
+
"--dry-run prints exactly which files it would touch and changes nothing. Each config",
|
|
1159
|
+
"is copied to <file>.scopebond-backup before its first modification.",
|
|
1160
|
+
] },
|
|
1161
|
+
{ name: "rules", args: "[show|allow|block|protect|unprotect|protect-branch|unprotect-branch|apply] [value]",
|
|
1162
|
+
summary: "read and change the limits in plain terms",
|
|
1163
|
+
detail: [
|
|
1164
|
+
"With no arguments, prints what is blocked in plain English — no regular expressions.",
|
|
1165
|
+
"The editable lists are .scopebond/rules.json; policy.json is compiled from them, so",
|
|
1166
|
+
"you never hand-write a lookahead.",
|
|
1167
|
+
" rules allow dd stop blocking a program",
|
|
1168
|
+
" rules protect infra/ never write there",
|
|
1169
|
+
" rules protect-branch production",
|
|
1170
|
+
" rules apply recompile after editing rules.json by hand",
|
|
1171
|
+
] },
|
|
1172
|
+
{ name: "status", summary: "what is configured, where, and how big the local log is" },
|
|
1173
|
+
{ name: "doctor", summary: "check the setup and whether each configured hook command can start",
|
|
1174
|
+
detail: ["Exits non-zero when something is wrong, so it works in a script."] },
|
|
1175
|
+
{ name: "log", args: "[-n N] [--deny] [--since 7d]",
|
|
1176
|
+
summary: "the recent decisions",
|
|
1177
|
+
detail: [`--deny shows only blocked actions; --since takes 7d, 24h, 30m or a date.`, `e.g. ${cliCommand("log --deny --since 7d")}`] },
|
|
1178
|
+
{ name: "verify", summary: "check every local receipt offline against the countersigning key",
|
|
1179
|
+
detail: ["No network, no account. Exits non-zero if any receipt fails."] },
|
|
1180
|
+
{ name: "test", args: '"<shell command>"',
|
|
1181
|
+
summary: "show the decision for a command without running or recording it",
|
|
1182
|
+
detail: [`e.g. ${cliCommand('test "rm -rf /"')}`] },
|
|
1183
|
+
{ name: "prune", args: "[--before 90d] [--yes]",
|
|
1184
|
+
summary: "report the local store's size, or bound it",
|
|
1185
|
+
detail: [
|
|
1186
|
+
"With no --before it only reports. With one, it archives the receipts it will remove",
|
|
1187
|
+
"to a JSONL file beside the database, then removes them. Refuses once the log has been",
|
|
1188
|
+
"anchored, because a receipt's position is its anchor leaf index.",
|
|
1189
|
+
] },
|
|
1190
|
+
{ name: "login", args: "<workspace-url> [--claude|--cursor|--codex] [--no-install]",
|
|
1191
|
+
summary: "connect this computer to a Scopebond Cloud workspace by approving a short code there",
|
|
1192
|
+
detail: [
|
|
1193
|
+
"Prints a code and a link; someone who manages the workspace opens it, checks the code",
|
|
1194
|
+
"and approves it for an environment and agent. Nothing is copied or pasted.",
|
|
1195
|
+
] },
|
|
1196
|
+
{ name: "connect", args: "<workspace-url> <enrollment> [--claude|--cursor|--codex]",
|
|
1197
|
+
summary: "send receipts to a Scopebond Cloud workspace as well as keeping them locally" },
|
|
1198
|
+
{ name: "flush", summary: "deliver any receipts still queued for the workspace now" },
|
|
1199
|
+
{ name: "trust", args: "[--yes]", summary: "let this project's .scopebond policy govern here (pinned by hash)" },
|
|
1200
|
+
{ name: "uninstall", args: "[--purge] [--yes]", summary: "remove the hook from your agent config; --purge also deletes the home" },
|
|
1201
|
+
{ name: "claude", summary: "(internal) decide one Claude Code PreToolUse call, JSON on stdin" },
|
|
1202
|
+
{ name: "cursor", summary: "(internal) decide one Cursor hook event, JSON on stdin" },
|
|
1203
|
+
{ name: "codex", summary: "(internal) decide one Codex PreToolUse call, JSON on stdin" },
|
|
1204
|
+
];
|
|
1205
|
+
function printHelp(topic, toStderr = false) {
|
|
1206
|
+
const out = toStderr ? console.error : console.log;
|
|
1207
|
+
const match = topic ? COMMANDS.find((c) => c.name === topic.replace(/^--?/, "")) : undefined;
|
|
1208
|
+
if (match) {
|
|
1209
|
+
out(`scopebond-hook ${match.name}${match.args ? ` ${match.args}` : ""}`);
|
|
1210
|
+
out("");
|
|
1211
|
+
out(` ${match.summary}`);
|
|
1212
|
+
if (match.detail) {
|
|
1213
|
+
out("");
|
|
1214
|
+
for (const line of match.detail)
|
|
1215
|
+
out(` ${line}`);
|
|
1216
|
+
}
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
if (topic) {
|
|
1220
|
+
out(`no such command: ${topic}`);
|
|
1221
|
+
out("");
|
|
1222
|
+
}
|
|
1223
|
+
out(`scopebond-hook — govern a coding agent's tool calls against policy, before they run.`);
|
|
1224
|
+
out("");
|
|
1225
|
+
out(`Usage: scopebond-hook <command> [options]`);
|
|
1226
|
+
out("");
|
|
1227
|
+
out(` ${cliCommand("init")} set up this project`);
|
|
1228
|
+
out(` ${cliCommand('test "rm -rf /"')} see a decision without running it`);
|
|
1229
|
+
out(` ${cliCommand("log --deny")} what got blocked`);
|
|
1230
|
+
out(` ${cliCommand("rules")} what is blocked, in plain English`);
|
|
1231
|
+
out("");
|
|
1232
|
+
out("Commands:");
|
|
1233
|
+
const width = Math.max(...COMMANDS.map((c) => c.name.length));
|
|
1234
|
+
for (const command of COMMANDS) {
|
|
1235
|
+
if (command.summary.startsWith("(internal)"))
|
|
1236
|
+
continue;
|
|
1237
|
+
out(` ${command.name.padEnd(width)} ${command.summary}`);
|
|
1238
|
+
}
|
|
1239
|
+
out("");
|
|
1240
|
+
out(` ${"help".padEnd(width)} \`help <command>\` for that command's arguments and examples`);
|
|
1241
|
+
out("");
|
|
1242
|
+
out(`Receipts and keys stay in .scopebond/ in this project. Nothing leaves your machine`);
|
|
1243
|
+
out(`unless you run \`connect\`. Docs: https://github.com/avouro-com/scopebond`);
|
|
521
1244
|
}
|
|
522
1245
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
523
1246
|
if (cmd === "claude") {
|
|
@@ -560,13 +1283,23 @@ else if (cmd === "uninstall") {
|
|
|
560
1283
|
runUninstall(rest);
|
|
561
1284
|
}
|
|
562
1285
|
else if (cmd === "login") {
|
|
563
|
-
runLogin();
|
|
1286
|
+
await runLogin(rest);
|
|
564
1287
|
}
|
|
565
1288
|
else if (cmd === "trust") {
|
|
566
1289
|
runTrust(rest);
|
|
567
1290
|
}
|
|
1291
|
+
else if (cmd === "prune") {
|
|
1292
|
+
await runPrune(rest);
|
|
1293
|
+
}
|
|
1294
|
+
else if (cmd === "rules") {
|
|
1295
|
+
runRules(rest);
|
|
1296
|
+
}
|
|
1297
|
+
else if (cmd === "help" || cmd === "--help" || cmd === "-h" || cmd === undefined) {
|
|
1298
|
+
printHelp(rest[0]);
|
|
1299
|
+
}
|
|
568
1300
|
else {
|
|
569
|
-
console.error(
|
|
1301
|
+
console.error(`unknown command: ${cmd}`);
|
|
1302
|
+
printHelp(undefined, true);
|
|
570
1303
|
process.exit(1);
|
|
571
1304
|
}
|
|
572
1305
|
//# sourceMappingURL=cli.js.map
|