@scopebond/hook 0.8.0 → 0.9.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 +210 -0
- package/dist/budget-load.d.ts +68 -0
- package/dist/budget-load.d.ts.map +1 -0
- package/dist/budget-load.js +164 -0
- package/dist/budget-load.js.map +1 -0
- package/dist/capabilities.d.ts +103 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +212 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/classify.d.ts +17 -0
- package/dist/classify.d.ts.map +1 -0
- package/dist/classify.js +78 -0
- package/dist/classify.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +546 -16
- package/dist/cli.js.map +1 -1
- package/dist/cloud.d.ts +8 -0
- package/dist/cloud.d.ts.map +1 -1
- package/dist/cloud.js.map +1 -1
- package/dist/dispatch-cli.d.ts +2 -0
- package/dist/dispatch-cli.d.ts.map +1 -0
- package/dist/dispatch-cli.js +128 -0
- package/dist/dispatch-cli.js.map +1 -0
- package/dist/explain.d.ts.map +1 -1
- package/dist/explain.js +2 -1
- package/dist/explain.js.map +1 -1
- package/dist/group.d.ts +12 -0
- package/dist/group.d.ts.map +1 -0
- package/dist/group.js +55 -0
- package/dist/group.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -1
- package/dist/install.d.ts +10 -0
- package/dist/install.d.ts.map +1 -1
- package/dist/install.js +56 -0
- package/dist/install.js.map +1 -1
- package/dist/map.d.ts +4 -0
- package/dist/map.d.ts.map +1 -1
- package/dist/map.js +29 -1
- package/dist/map.js.map +1 -1
- package/dist/obs-emitter.d.ts +132 -0
- package/dist/obs-emitter.d.ts.map +1 -0
- package/dist/obs-emitter.js +415 -0
- package/dist/obs-emitter.js.map +1 -0
- package/dist/obs-store.d.ts +160 -0
- package/dist/obs-store.d.ts.map +1 -0
- package/dist/obs-store.js +367 -0
- package/dist/obs-store.js.map +1 -0
- package/dist/obs-upload.d.ts +24 -0
- package/dist/obs-upload.d.ts.map +1 -0
- package/dist/obs-upload.js +231 -0
- package/dist/obs-upload.js.map +1 -0
- package/dist/observation.d.ts +232 -0
- package/dist/observation.d.ts.map +1 -0
- package/dist/observation.js +308 -0
- package/dist/observation.js.map +1 -0
- package/dist/paths.d.ts +15 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +79 -0
- package/dist/paths.js.map +1 -0
- package/dist/policy-load.d.ts +60 -0
- package/dist/policy-load.d.ts.map +1 -0
- package/dist/policy-load.js +151 -0
- package/dist/policy-load.js.map +1 -0
- package/dist/proof.d.ts +30 -0
- package/dist/proof.d.ts.map +1 -0
- package/dist/proof.js +186 -0
- package/dist/proof.js.map +1 -0
- package/dist/rules.d.ts +20 -0
- package/dist/rules.d.ts.map +1 -1
- package/dist/rules.js +50 -0
- package/dist/rules.js.map +1 -1
- package/dist/runtime.d.ts +18 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +74 -10
- package/dist/runtime.js.map +1 -1
- package/dist/scan.d.ts +7 -0
- package/dist/scan.d.ts.map +1 -0
- package/dist/scan.js +38 -0
- package/dist/scan.js.map +1 -0
- package/dist/shell.d.ts.map +1 -1
- package/dist/shell.js +12 -3
- package/dist/shell.js.map +1 -1
- package/dist/sql-classify.d.ts +11 -0
- package/dist/sql-classify.d.ts.map +1 -0
- package/dist/sql-classify.js +251 -0
- package/dist/sql-classify.js.map +1 -0
- package/dist/typed-infra.d.ts +73 -0
- package/dist/typed-infra.d.ts.map +1 -0
- package/dist/typed-infra.js +758 -0
- package/dist/typed-infra.js.map +1 -0
- package/dist/typed-ops.d.ts +140 -0
- package/dist/typed-ops.d.ts.map +1 -0
- package/dist/typed-ops.js +549 -0
- package/dist/typed-ops.js.map +1 -0
- package/dist/vectors.d.ts +40 -0
- package/dist/vectors.d.ts.map +1 -0
- package/dist/vectors.js +219 -0
- package/dist/vectors.js.map +1 -0
- package/package.json +4 -4
package/dist/cli.js
CHANGED
|
@@ -24,22 +24,33 @@
|
|
|
24
24
|
});
|
|
25
25
|
}
|
|
26
26
|
import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
|
|
27
|
-
import { join } from "node:path";
|
|
27
|
+
import { join, resolve } from "node:path";
|
|
28
28
|
import { hostname, tmpdir } from "node:os";
|
|
29
29
|
import { execFileSync } from "node:child_process";
|
|
30
30
|
import { verifyReceipt } from "@scopebond/gateway";
|
|
31
31
|
import { openReceiptStore, loadOrCreateAttester } from "@scopebond/gateway/node";
|
|
32
|
+
import { callRequestOf, keyedIdFor, TYPED_ACTION_TYPES } from "./typed-ops.js";
|
|
33
|
+
import { databaseGuardActions } from "./typed-infra.js";
|
|
32
34
|
import { mapClaudeToolUse, mapCodexToolUse, mapCursorEvent, fillPushBranch } from "./map.js";
|
|
33
35
|
import { createHookRuntime } from "./runtime.js";
|
|
34
36
|
import { useDigestKey, loadOrCreateDigestKey } from "./minimize.js";
|
|
35
37
|
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";
|
|
38
|
+
import { userHome, userHarnessFile, resolveConfigDir, writeHarnessConfig, removeHarnessConfig, cursorDetected, codexDetected, absoluteHookCommand, isHarnessConfigured, purgeHome, harnessScopes, harnessScopeLabel, configuredHookCommands, hookCommandResolves, projectHarnessFile, localHarnessFile, gitShareState, isMachineSpecificCommand, trustProjectPolicy, untrustedProjectPolicy, wireLifecycleHooks, unwireLifecycleHooks, } from "./install.js";
|
|
39
|
+
import { openObservations, describeObservations, observationStatus, stopReasonFromClaude, exitFromClaudeFailure, HEARTBEAT_INTERVAL_MS, OBSERVATIONS_SCOPE, } from "./obs-emitter.js";
|
|
40
|
+
import { OBSERVATION_DB, ObservationStore } from "./obs-store.js";
|
|
41
|
+
import { loadOrCreateBindingKey } from "./observation.js";
|
|
42
|
+
import { uploadPending } from "./obs-upload.js";
|
|
37
43
|
import { connectCloud, loadConnection } from "./cloud.js";
|
|
44
|
+
import { loadPolicyExport } from "./policy-load.js";
|
|
45
|
+
import { loadBudgetExport } from "./budget-load.js";
|
|
38
46
|
import { compile, defaultRules, describeRules, loadRules, saveRules, rulesPath, pathRuleFor } from "./rules.js";
|
|
39
47
|
import { createSigner } from "@scopebond/sdk";
|
|
48
|
+
import { runDispatchCommand } from "./dispatch-cli.js";
|
|
40
49
|
import { describeAction } from "./explain.js";
|
|
41
50
|
import { ensureDurableRuntime, pinnedCliPath, isEphemeralPath } from "./runtime-install.js";
|
|
42
51
|
import { cliCommand, hookCommand, hookVersion } from "./version.js";
|
|
52
|
+
import { computeManifest, renderManifest } from "./capabilities.js";
|
|
53
|
+
import { runProofFixtures, loadProofs, saveProofs, proofPassed, deliverProofReceipts } from "./proof.js";
|
|
43
54
|
import { fileURLToPath } from "node:url";
|
|
44
55
|
/** The current git branch in `cwd` (best-effort). A bare `git push` pushes it, so
|
|
45
56
|
* the runtime fills it in before evaluating; on failure the ref stays absent and
|
|
@@ -77,13 +88,14 @@ function readBundleArg(bundleArg, stdin) {
|
|
|
77
88
|
function configDir() {
|
|
78
89
|
return process.env.SCOPEBOND_HOOK_DIR ?? join(process.cwd(), ".scopebond");
|
|
79
90
|
}
|
|
80
|
-
function runtimePaths(dir) {
|
|
91
|
+
function runtimePaths(dir, cwd) {
|
|
81
92
|
const connection = loadConnection(dir);
|
|
82
93
|
return {
|
|
83
94
|
policyPath: join(dir, "policy.json"),
|
|
84
95
|
keyPath: join(dir, "agent.key"),
|
|
85
96
|
attesterPath: join(dir, "attester.key"),
|
|
86
97
|
dbPath: join(dir, "receipts.db"),
|
|
98
|
+
...(cwd ? { cwd } : {}),
|
|
87
99
|
// Strict: deny (not just observe) tools with no taxonomy mapping.
|
|
88
100
|
strict: process.argv.includes("--strict") || process.env.SCOPEBOND_HOOK_STRICT === "1",
|
|
89
101
|
// When connected, auto-export receipts. A short bounded flush keeps the hot path
|
|
@@ -92,6 +104,16 @@ function runtimePaths(dir) {
|
|
|
92
104
|
...(connection ? { cloud: { connection, flushTimeoutMs: Number(process.env.SCOPEBOND_HOOK_FLUSH_MS ?? 800) } } : {}),
|
|
93
105
|
};
|
|
94
106
|
}
|
|
107
|
+
/** The harness's own id for this tool call, when it gives one (Claude Code and Codex send
|
|
108
|
+
* `tool_use_id`), so the receipts of one call share a stable action group. */
|
|
109
|
+
function callId(input) {
|
|
110
|
+
for (const key of ["tool_use_id", "tool_call_id", "call_id"]) {
|
|
111
|
+
const value = input?.[key];
|
|
112
|
+
if (typeof value === "string" && value !== "")
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
95
117
|
function readStdin() {
|
|
96
118
|
try {
|
|
97
119
|
return readFileSync(0, "utf8");
|
|
@@ -134,10 +156,53 @@ const cursorCoverageNote = [
|
|
|
134
156
|
"For edits that must be blocked before they land, use the GitHub Action as a required",
|
|
135
157
|
"check on pull requests.",
|
|
136
158
|
].join("\n");
|
|
137
|
-
|
|
159
|
+
/** Record what was dispatched as observations, when this workspace enrolled for them. Purely
|
|
160
|
+
* additive: it runs after the decision is made and can neither change it nor fail it. */
|
|
161
|
+
function recordObservations(dir, cwd, input, decision, harness) {
|
|
162
|
+
try {
|
|
163
|
+
const { emitter } = openObservations(dir, { adapterVersion: hookVersion() });
|
|
164
|
+
if (!emitter)
|
|
165
|
+
return undefined;
|
|
166
|
+
const sessionId = typeof input.session_id === "string" && input.session_id !== "" ? input.session_id : undefined;
|
|
167
|
+
// Session lifecycle is Claude Code only: its SessionStart/SessionEnd hooks are the ones
|
|
168
|
+
// the tested mapping covers. Other hosts still get tool intents from their PreToolUse.
|
|
169
|
+
if (harness === "claude" && sessionId)
|
|
170
|
+
emitter.activity(sessionId, cwd);
|
|
171
|
+
emitter.toolIntents({ harnessSessionId: sessionId, callId: callId(input), cwd, dispatched: decision.dispatched ?? [], request: callRequestOf(input) });
|
|
172
|
+
return emitter;
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/** The remote-database actions of a shell call, only when the rule set opts in (`protect_remote_database`).
|
|
179
|
+
* They are extra evaluated intents read from the raw command, so the deny happens before it runs. */
|
|
180
|
+
function databaseGuard(dir, cwd, input) {
|
|
181
|
+
let enabled = false;
|
|
182
|
+
try {
|
|
183
|
+
enabled = loadRules(dir)?.protect_remote_database === true;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
if (!enabled)
|
|
189
|
+
return [];
|
|
190
|
+
const request = callRequestOf(input);
|
|
191
|
+
if (request?.command === undefined)
|
|
192
|
+
return [];
|
|
193
|
+
const intent = (params) => ({ intent: { action_type: "db.exec", params }, evaluated: true, source: "shell" });
|
|
194
|
+
try {
|
|
195
|
+
return databaseGuardActions(request.command, request.dialect ?? "posix", { cwd }).map((action) => intent({ ...action }));
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// The rule is on and the reader failed: a command that names a database tool is not waved through.
|
|
199
|
+
return /(?:wrangler|psql)/i.test(request.command) ? [intent({ provider: "unknown", verb: "unknown", scope: "unknown", risk: "unknown" })] : [];
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
async function runPreToolUse(mapper, deny = denyClaude, raw, harness = "claude") {
|
|
138
203
|
let input;
|
|
139
204
|
try {
|
|
140
|
-
input = JSON.parse(readStdin());
|
|
205
|
+
input = JSON.parse(raw ?? readStdin());
|
|
141
206
|
}
|
|
142
207
|
catch {
|
|
143
208
|
deny("hook received invalid JSON on stdin");
|
|
@@ -146,10 +211,12 @@ async function runPreToolUse(mapper, deny = denyClaude) {
|
|
|
146
211
|
try {
|
|
147
212
|
const cwd = input?.cwd ? String(input.cwd) : process.cwd();
|
|
148
213
|
const dir = resolveConfigDir(cwd);
|
|
149
|
-
runtime = createHookRuntime(runtimePaths(dir));
|
|
214
|
+
runtime = createHookRuntime(runtimePaths(dir, cwd));
|
|
150
215
|
useDigestKey(loadOrCreateDigestKey(dir));
|
|
151
|
-
const decision = await runtime.evaluate(fillPushBranch(mapper(input), currentBranch(cwd)));
|
|
152
|
-
|
|
216
|
+
const decision = await runtime.evaluate([...fillPushBranch(mapper(input), currentBranch(cwd)), ...databaseGuard(dir, cwd, input)], { groupKey: callId(input) });
|
|
217
|
+
const observer = recordObservations(dir, cwd, input, decision, harness);
|
|
218
|
+
await Promise.all([runtime.flush(), observer?.flush() ?? Promise.resolve()]);
|
|
219
|
+
observer?.close();
|
|
153
220
|
// Close before deciding: the receipt is already committed, and leaving the handle
|
|
154
221
|
// open is what made the write-ahead log grow without bound.
|
|
155
222
|
runtime.close();
|
|
@@ -168,11 +235,63 @@ async function runPreToolUse(mapper, deny = denyClaude) {
|
|
|
168
235
|
deny(`Scopebond hook failed closed: ${error.message}. Repair: run \`${cliCommand("init")}\`.`);
|
|
169
236
|
}
|
|
170
237
|
}
|
|
238
|
+
/** Claude Code events other than PreToolUse: session start and end, and the after-action
|
|
239
|
+
* events. They only feed observations; they never print a decision and always exit 0. */
|
|
240
|
+
async function runClaudeLifecycle(input) {
|
|
241
|
+
try {
|
|
242
|
+
const cwd = input.cwd ? String(input.cwd) : process.cwd();
|
|
243
|
+
const { emitter } = openObservations(resolveConfigDir(cwd), { adapterVersion: hookVersion() });
|
|
244
|
+
const sessionId = typeof input.session_id === "string" && input.session_id !== "" ? input.session_id : undefined;
|
|
245
|
+
if (emitter) {
|
|
246
|
+
try {
|
|
247
|
+
switch (input.hook_event_name) {
|
|
248
|
+
case "SessionStart":
|
|
249
|
+
if (sessionId)
|
|
250
|
+
emitter.sessionStart(sessionId, cwd);
|
|
251
|
+
break;
|
|
252
|
+
case "SessionEnd":
|
|
253
|
+
if (sessionId)
|
|
254
|
+
emitter.sessionStop(sessionId, stopReasonFromClaude(input.reason));
|
|
255
|
+
break;
|
|
256
|
+
case "PostToolUse": {
|
|
257
|
+
const id = callId(input);
|
|
258
|
+
if (id)
|
|
259
|
+
emitter.toolOutcomes(sessionId, id, "ok");
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
case "PostToolUseFailure": {
|
|
263
|
+
const id = callId(input);
|
|
264
|
+
if (id)
|
|
265
|
+
emitter.toolOutcomes(sessionId, id, exitFromClaudeFailure(input.is_interrupt));
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
await emitter.flush(input.hook_event_name === "SessionEnd" ? 1500 : 800);
|
|
270
|
+
}
|
|
271
|
+
finally {
|
|
272
|
+
emitter.close();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
catch { /* observations are best effort; nothing here may affect the agent */ }
|
|
277
|
+
process.exit(0);
|
|
278
|
+
}
|
|
279
|
+
const CLAUDE_OBSERVATION_EVENTS = new Set(["SessionStart", "SessionEnd", "PostToolUse", "PostToolUseFailure"]);
|
|
171
280
|
async function runClaude() {
|
|
172
|
-
|
|
281
|
+
const raw = readStdin();
|
|
282
|
+
let event;
|
|
283
|
+
let input;
|
|
284
|
+
try {
|
|
285
|
+
input = JSON.parse(raw);
|
|
286
|
+
event = input?.hook_event_name;
|
|
287
|
+
}
|
|
288
|
+
catch { /* PreToolUse reports invalid input */ }
|
|
289
|
+
if (input && typeof event === "string" && CLAUDE_OBSERVATION_EVENTS.has(event))
|
|
290
|
+
await runClaudeLifecycle(input);
|
|
291
|
+
await runPreToolUse(mapClaudeToolUse, denyClaude, raw, "claude");
|
|
173
292
|
}
|
|
174
293
|
async function runCodex() {
|
|
175
|
-
await runPreToolUse(mapCodexToolUse, denyCodex);
|
|
294
|
+
await runPreToolUse(mapCodexToolUse, denyCodex, undefined, "codex");
|
|
176
295
|
}
|
|
177
296
|
function denyCursor(reason) {
|
|
178
297
|
process.stdout.write(JSON.stringify({ permission: "deny", agentMessage: reason }) + "\n");
|
|
@@ -202,11 +321,13 @@ async function runCursor() {
|
|
|
202
321
|
try {
|
|
203
322
|
const cwd = input?.cwd ? String(input.cwd) : process.cwd();
|
|
204
323
|
const dir = resolveConfigDir(cwd);
|
|
205
|
-
runtime = createHookRuntime(runtimePaths(dir));
|
|
324
|
+
runtime = createHookRuntime(runtimePaths(dir, cwd));
|
|
206
325
|
useDigestKey(loadOrCreateDigestKey(dir));
|
|
207
326
|
const mapped = fillPushBranch(mapCursorEvent(event, input), currentBranch(cwd));
|
|
208
|
-
const decision = await runtime.evaluate(mapped);
|
|
209
|
-
|
|
327
|
+
const decision = await runtime.evaluate(mapped, { groupKey: callId(input) });
|
|
328
|
+
const observer = recordObservations(dir, cwd, input, decision, "cursor");
|
|
329
|
+
await Promise.all([runtime.flush(), observer?.flush() ?? Promise.resolve()]);
|
|
330
|
+
observer?.close();
|
|
210
331
|
// An `afterFileEdit` violation is real and recorded, but the edit has already
|
|
211
332
|
// landed. Say so rather than letting "blocked" imply it was stopped.
|
|
212
333
|
postHoc = mapped.some((m) => m.postHoc);
|
|
@@ -458,6 +579,7 @@ function runRules(args) {
|
|
|
458
579
|
console.log(` ${cliCommand("rules unprotect <path>")} allow writing there again`);
|
|
459
580
|
console.log(` ${cliCommand("rules protect-branch <name>")} never push there`);
|
|
460
581
|
console.log(` ${cliCommand("rules unprotect-branch <name>")}`);
|
|
582
|
+
console.log(` ${cliCommand("rules protect-remote-database")} block destructive or unreadable SQL on a remote database (off by default)`);
|
|
461
583
|
console.log(`Or edit ${rulesPath(dir)} directly, then \`${cliCommand("rules apply")}\`.`);
|
|
462
584
|
process.exit(0);
|
|
463
585
|
}
|
|
@@ -531,6 +653,22 @@ function runRules(args) {
|
|
|
531
653
|
changed = `pushes to ${value} are allowed again`;
|
|
532
654
|
break;
|
|
533
655
|
}
|
|
656
|
+
case "protect-remote-database":
|
|
657
|
+
if (rules.protect_remote_database === true) {
|
|
658
|
+
console.log("Remote databases are already protected.");
|
|
659
|
+
process.exit(0);
|
|
660
|
+
}
|
|
661
|
+
rules.protect_remote_database = true;
|
|
662
|
+
changed = "remote SQL that drops, deletes or updates every row, or cannot be read, is now blocked";
|
|
663
|
+
break;
|
|
664
|
+
case "unprotect-remote-database":
|
|
665
|
+
if (rules.protect_remote_database !== true) {
|
|
666
|
+
console.log("Remote databases were not protected; nothing to change.");
|
|
667
|
+
process.exit(0);
|
|
668
|
+
}
|
|
669
|
+
delete rules.protect_remote_database;
|
|
670
|
+
changed = "remote SQL is no longer checked";
|
|
671
|
+
break;
|
|
534
672
|
case "apply":
|
|
535
673
|
changed = `recompiled from ${rulesPath(dir)}`;
|
|
536
674
|
break;
|
|
@@ -751,6 +889,14 @@ async function finishConnect(dir, url, bundle, harness, args) {
|
|
|
751
889
|
try {
|
|
752
890
|
const c = await connectCloud(dir, url, bundle);
|
|
753
891
|
console.log(`✓ Connected to ${c.url}`);
|
|
892
|
+
// With a user-level install present, the hook ignores a project policy until it is
|
|
893
|
+
// trusted, and would fall back to the user home, which holds no cloud.json: the agent
|
|
894
|
+
// stays governed, but nothing reaches the workspace. Connecting this project is the
|
|
895
|
+
// user's decision to use it, exactly as running `init` here is, so pin it the same way.
|
|
896
|
+
if (!process.env.SCOPEBOND_HOOK_DIR && resolve(dir) !== resolve(userHome()) && existsSync(join(userHome(), "policy.json"))) {
|
|
897
|
+
trustProjectPolicy(dir);
|
|
898
|
+
console.log(`✓ This project's rules are trusted (they override ${userHome()} here)`);
|
|
899
|
+
}
|
|
754
900
|
// Configure the agent automatically (merges into the existing config), unless the
|
|
755
901
|
// caller opts out. This removes the "paste this snippet" step. A hook that is already
|
|
756
902
|
// configured — pinned by `init`, or user-level by `install` — is left as it is:
|
|
@@ -770,6 +916,21 @@ async function finishConnect(dir, url, bundle, harness, args) {
|
|
|
770
916
|
console.log(`Add this to your ${harnessFileName(harness)}:`);
|
|
771
917
|
console.log(harnessSnippet(harness));
|
|
772
918
|
}
|
|
919
|
+
// Session and after-action observations are opt-in through the enrollment: wired only
|
|
920
|
+
// when this workspace granted observations:write and Claude Code is the host. Off is
|
|
921
|
+
// silent here (`status` says why); a granted scope the hook cannot use is worth a line.
|
|
922
|
+
const observing = observationStatus(loadConnection(dir));
|
|
923
|
+
if (observing.state === "on" && harness === "claude" && !args.includes("--no-install")) {
|
|
924
|
+
const placed = harnessScopes("claude", process.cwd());
|
|
925
|
+
const target = placed.local ?? placed.project ?? placed.user;
|
|
926
|
+
const command = target ? configuredHookCommands(target)[0] : undefined;
|
|
927
|
+
if (target && command) {
|
|
928
|
+
wireLifecycleHooks(target, command);
|
|
929
|
+
console.log(`✓ Session and after-action observations enabled in ${target}`);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
else if (observing.state === "unsupported")
|
|
933
|
+
console.log(`Observations: ${observing.reason}`);
|
|
773
934
|
console.log("");
|
|
774
935
|
console.log("Run your agent — the first action appears in your workspace within seconds.");
|
|
775
936
|
}
|
|
@@ -804,6 +965,219 @@ async function runFlush() {
|
|
|
804
965
|
// Let pending HTTP handles close normally (forced exit can abort on Windows).
|
|
805
966
|
process.exitCode = status && status.pending > 0 ? 1 : 0;
|
|
806
967
|
}
|
|
968
|
+
/** `observations`: what the observation emitters are doing, and the local queue. */
|
|
969
|
+
/** `policy load <export.json> [--yes]`: check a policy exported from the workspace and, with
|
|
970
|
+
* `--yes`, make it the active policy here; then acknowledge it (or its refusal) to the workspace. */
|
|
971
|
+
async function runPolicy(args) {
|
|
972
|
+
const [sub, file] = args;
|
|
973
|
+
if (sub !== "load" || !file) {
|
|
974
|
+
console.error(`usage: ${cliCommand("policy load <export.json> [--yes]")}`);
|
|
975
|
+
process.exitCode = 2;
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
const dir = resolveConfigDir(process.cwd());
|
|
979
|
+
const apply = args.includes("--yes");
|
|
980
|
+
const connection = loadConnection(dir);
|
|
981
|
+
const outcome = loadPolicyExport(dir, file, { apply, environmentId: connection?.environment_id });
|
|
982
|
+
let ack;
|
|
983
|
+
if (outcome.state === "rejected") {
|
|
984
|
+
console.error(`refused: ${outcome.message} (${outcome.error})`);
|
|
985
|
+
if (outcome.ack)
|
|
986
|
+
ack = { ...outcome.ack, error: outcome.error };
|
|
987
|
+
process.exitCode = 1;
|
|
988
|
+
}
|
|
989
|
+
else if (outcome.state === "would_load") {
|
|
990
|
+
console.log(`This export checks out (policy ${outcome.facts.policyId} v${outcome.facts.policyVersion}, digest ${outcome.facts.policyDigest.slice(0, 12)}...).`);
|
|
991
|
+
console.log(`Loading it REPLACES the active policy at ${join(dir, "policy.json")} (the old one is kept as policy.previous.json), so the starter protections apply only if the export includes them.`);
|
|
992
|
+
console.log("Run again with --yes to load it.");
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
else {
|
|
996
|
+
console.log(`loaded policy ${outcome.facts.policyId} v${outcome.facts.policyVersion} (digest ${outcome.facts.policyDigest.slice(0, 12)}...) from export ${outcome.facts.exportId}`);
|
|
997
|
+
console.log(` active policy ${outcome.policyPath}${outcome.previous ? ` (previous kept at ${outcome.previous})` : ""}`);
|
|
998
|
+
console.log(` note \`${cliCommand("rules apply")}\` recompiles policy.json from rules.json and would replace it`);
|
|
999
|
+
ack = { exportId: outcome.facts.exportId, policyId: outcome.facts.policyId, policyVersion: outcome.facts.policyVersion, policyDigest: outcome.facts.policyDigest, scopeDigest: outcome.facts.scopeDigest };
|
|
1000
|
+
}
|
|
1001
|
+
if (!ack)
|
|
1002
|
+
return;
|
|
1003
|
+
await sendPolicyAck(dir, ack);
|
|
1004
|
+
}
|
|
1005
|
+
/** Queue a `policy_ack` (loaded or rejected) through the observation outbox and try to deliver it now. */
|
|
1006
|
+
async function sendPolicyAck(dir, ack) {
|
|
1007
|
+
const observed = openObservations(dir, { adapterVersion: hookVersion(), spawnHeartbeat: false });
|
|
1008
|
+
if (!observed.emitter) {
|
|
1009
|
+
console.error(`not acknowledged to the workspace: observations are ${observed.status.state}${"reason" in observed.status ? ` (${observed.status.reason})` : ""}`);
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
try {
|
|
1013
|
+
const queued = observed.emitter.policyAck(ack);
|
|
1014
|
+
await observed.emitter.flush(3000);
|
|
1015
|
+
console.error(queued?.queued ? `queued the ${ack.error ? "rejection" : "load"} acknowledgement for the workspace` : "could not queue the acknowledgement");
|
|
1016
|
+
}
|
|
1017
|
+
finally {
|
|
1018
|
+
observed.emitter.close();
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
/** `budget load <export.json> [--yes]`: check an action budget exported from the workspace and, with `--yes`,
|
|
1022
|
+
* make it the budget this machine enforces; then acknowledge it (or its refusal) to the workspace. */
|
|
1023
|
+
async function runBudgetLoad(args) {
|
|
1024
|
+
const file = args.find((a) => !a.startsWith("--"));
|
|
1025
|
+
if (!file) {
|
|
1026
|
+
console.error(`usage: ${cliCommand("budget load <export.json> [--yes]")}`);
|
|
1027
|
+
process.exitCode = 2;
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
const dir = resolveConfigDir(process.cwd());
|
|
1031
|
+
const agentKid = (() => { try {
|
|
1032
|
+
return createSigner({ privateKeyPem: readFileSync(join(dir, "agent.key"), "utf8") }).kid;
|
|
1033
|
+
}
|
|
1034
|
+
catch {
|
|
1035
|
+
return "";
|
|
1036
|
+
} })();
|
|
1037
|
+
const outcome = loadBudgetExport(dir, file, { apply: args.includes("--yes"), agentKid, environmentId: loadConnection(dir)?.environment_id });
|
|
1038
|
+
let ack;
|
|
1039
|
+
if (outcome.state === "rejected") {
|
|
1040
|
+
console.error(`refused: ${outcome.message} (${outcome.error})`);
|
|
1041
|
+
if (outcome.ack && outcome.error !== "expired")
|
|
1042
|
+
ack = { ...outcome.ack, error: outcome.error };
|
|
1043
|
+
process.exitCode = 1;
|
|
1044
|
+
}
|
|
1045
|
+
else if (outcome.state === "would_load") {
|
|
1046
|
+
const p = outcome.facts.policy;
|
|
1047
|
+
for (const w of outcome.warnings)
|
|
1048
|
+
console.error(`warning: ${w}`);
|
|
1049
|
+
console.log(`This export checks out: ${p.mode} budget ${outcome.facts.budgetId} v${outcome.facts.budgetVersion}, ${p.max_dispatch} dispatches in ${p.window_seconds}s for this agent on this installation.`);
|
|
1050
|
+
console.log(`Loading it writes the budget to ${join(dir, "dispatch.json")} as acknowledged, and replaces an older workspace budget for this agent. Run again with --yes to load it.`);
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
else {
|
|
1054
|
+
const p = outcome.facts.policy;
|
|
1055
|
+
for (const w of outcome.warnings)
|
|
1056
|
+
console.error(`warning: ${w}`);
|
|
1057
|
+
console.log(`loaded ${p.mode} budget ${outcome.facts.budgetId} v${outcome.facts.budgetVersion} from export ${outcome.facts.exportId}: ${p.max_dispatch} dispatches in ${p.window_seconds}s`);
|
|
1058
|
+
if (outcome.replaced.length > 0)
|
|
1059
|
+
console.log(` replaced ${outcome.replaced.join(", ")}`);
|
|
1060
|
+
console.log(` valid until ${new Date(outcome.facts.validUntil).toISOString()} (after that an enforced budget denies new dispatch until you load a new export)`);
|
|
1061
|
+
ack = { exportId: outcome.facts.exportId, policyId: outcome.facts.budgetId, policyVersion: outcome.facts.budgetVersion, policyDigest: outcome.facts.policyDigest, scopeDigest: outcome.facts.scopeDigest };
|
|
1062
|
+
}
|
|
1063
|
+
if (!ack)
|
|
1064
|
+
return;
|
|
1065
|
+
await sendPolicyAck(dir, ack);
|
|
1066
|
+
}
|
|
1067
|
+
async function runObservations(args) {
|
|
1068
|
+
const sub = args[0] ?? "status";
|
|
1069
|
+
const dir = resolveConfigDir(process.cwd());
|
|
1070
|
+
if (sub === "heartbeat") {
|
|
1071
|
+
await runHeartbeatLoop(args[1] ?? "");
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
if (sub === "status") {
|
|
1075
|
+
const lines = describeObservations(dir);
|
|
1076
|
+
console.log(`observations: ${lines[0]}`);
|
|
1077
|
+
for (const line of lines.slice(1))
|
|
1078
|
+
console.log(` ${line}`);
|
|
1079
|
+
const file = join(dir, OBSERVATION_DB);
|
|
1080
|
+
if (args.includes("--refused") && existsSync(file)) {
|
|
1081
|
+
const store = new ObservationStore(file);
|
|
1082
|
+
try {
|
|
1083
|
+
for (const row of store.terminal())
|
|
1084
|
+
console.log(` refused #${row.sequence} gen ${row.generation} ${row.kind} ${row.observation_id} ${row.code} ${new Date(row.at).toISOString()}`);
|
|
1085
|
+
}
|
|
1086
|
+
finally {
|
|
1087
|
+
store.close();
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
if (sub === "id") {
|
|
1093
|
+
// The opaque id this installation gives a ref, remote or repository, for writing reference
|
|
1094
|
+
// sets. Local only: it reads the binding key and prints; nothing is sent anywhere.
|
|
1095
|
+
const id = keyedIdFor(loadOrCreateBindingKey(dir), args[1] ?? "", args.slice(2));
|
|
1096
|
+
if (!id) {
|
|
1097
|
+
console.error("usage: observations id <ref <name> | remote <url> | ghrepo <owner/name> | repo <workspace-path> | mcp <server> <tool> | mcp-resource <kind> <value> | net-dest <host> <port> | cf <kind> <name> | database <pg|sqlite> <key>>");
|
|
1098
|
+
process.exit(1);
|
|
1099
|
+
}
|
|
1100
|
+
console.log(id);
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
if (sub === "wire" || sub === "unwire") {
|
|
1104
|
+
const scopes = harnessScopes("claude", process.cwd());
|
|
1105
|
+
if (sub === "unwire") {
|
|
1106
|
+
let removed = 0;
|
|
1107
|
+
for (const file of [scopes.project, scopes.local, scopes.user])
|
|
1108
|
+
if (file)
|
|
1109
|
+
removed += unwireLifecycleHooks(file);
|
|
1110
|
+
console.log(`removed ${removed} lifecycle hook entr${removed === 1 ? "y" : "ies"}`);
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
const target = scopes.local ?? scopes.project ?? scopes.user;
|
|
1114
|
+
const command = target ? configuredHookCommands(target)[0] : undefined;
|
|
1115
|
+
if (!target || !command) {
|
|
1116
|
+
console.error("Claude Code is not configured with this hook; run install or init first");
|
|
1117
|
+
process.exit(1);
|
|
1118
|
+
}
|
|
1119
|
+
wireLifecycleHooks(target, command);
|
|
1120
|
+
console.log(`session and after-action hooks wired in ${target}`);
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
const opened = openObservations(dir, { adapterVersion: hookVersion(), spawnHeartbeat: false });
|
|
1124
|
+
if (!opened.emitter) {
|
|
1125
|
+
console.error(`observations are ${opened.status.state}${"reason" in opened.status ? `: ${opened.status.reason}` : ""}`);
|
|
1126
|
+
process.exit(1);
|
|
1127
|
+
}
|
|
1128
|
+
const emitter = opened.emitter;
|
|
1129
|
+
try {
|
|
1130
|
+
if (sub === "flush") {
|
|
1131
|
+
const outcome = await uploadPending(emitter.store, { url: emitter.connection.url, credential: emitter.connection.credential, timeoutMs: 10_000 });
|
|
1132
|
+
const left = emitter.store.pendingSummary().count;
|
|
1133
|
+
console.log(`${outcome.result}: ${outcome.acknowledged} acknowledged, ${outcome.deferred} deferred, ${outcome.rejected} refused; ${left} still pending${outcome.detail ? ` (${outcome.detail})` : ""}`);
|
|
1134
|
+
process.exitCode = left > 0 ? 1 : 0;
|
|
1135
|
+
}
|
|
1136
|
+
else if (sub === "retry") {
|
|
1137
|
+
const state = emitter.store.state();
|
|
1138
|
+
if (state?.capability === "unsupported") {
|
|
1139
|
+
emitter.store.setCapability("active", null);
|
|
1140
|
+
console.log("will try the workspace again");
|
|
1141
|
+
}
|
|
1142
|
+
else
|
|
1143
|
+
console.log("nothing to retry (a stale generation clears when you reconnect)");
|
|
1144
|
+
}
|
|
1145
|
+
else {
|
|
1146
|
+
console.error("usage: observations [status [--refused]|flush|retry|wire|unwire]");
|
|
1147
|
+
process.exit(1);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
finally {
|
|
1151
|
+
emitter.close();
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
/** The single heartbeat helper for one active session (started by the hook, never by hand). */
|
|
1155
|
+
async function runHeartbeatLoop(sessionId) {
|
|
1156
|
+
if (!sessionId)
|
|
1157
|
+
return;
|
|
1158
|
+
const dir = resolveConfigDir(process.cwd());
|
|
1159
|
+
const { emitter } = openObservations(dir, { adapterVersion: hookVersion(), spawnHeartbeat: false, flushTimeoutMs: 3000 });
|
|
1160
|
+
if (!emitter)
|
|
1161
|
+
return;
|
|
1162
|
+
const override = Number(process.env.SCOPEBOND_HEARTBEAT_INTERVAL_MS);
|
|
1163
|
+
const interval = Number.isFinite(override) && override >= 100 ? override : HEARTBEAT_INTERVAL_MS;
|
|
1164
|
+
const endsAt = Date.now() + 24 * 60 * 60 * 1000;
|
|
1165
|
+
let last = Date.now();
|
|
1166
|
+
try {
|
|
1167
|
+
for (;;) {
|
|
1168
|
+
const verdict = emitter.heartbeatTick(sessionId, last);
|
|
1169
|
+
await emitter.flush();
|
|
1170
|
+
if (verdict === "stop" || Date.now() > endsAt)
|
|
1171
|
+
break;
|
|
1172
|
+
last = Date.now();
|
|
1173
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
finally {
|
|
1177
|
+
emitter.store.releaseHeartbeat(sessionId);
|
|
1178
|
+
emitter.close();
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
807
1181
|
/** The absolute path to this CLI file, for registering the hook by absolute path. */
|
|
808
1182
|
function cliPath() {
|
|
809
1183
|
return fileURLToPath(import.meta.url);
|
|
@@ -925,6 +1299,10 @@ function runStatus() {
|
|
|
925
1299
|
console.log(` Cursor ${harnessScopeLabel(cursor) || (cursorDetected() ? "detected, not configured" : "not detected")}`);
|
|
926
1300
|
console.log(` Codex ${codex.project || codex.user ? `${harnessScopeLabel(codex)} — approve once with /hooks` : codexDetected() ? "detected, not configured" : "not detected"}`);
|
|
927
1301
|
console.log(` cloud workspace ${connected ? "connected" : "not connected (local only)"}`);
|
|
1302
|
+
const observationLines = describeObservations(resolveConfigDir(process.cwd()));
|
|
1303
|
+
console.log(` observations ${observationLines[0]}`);
|
|
1304
|
+
for (const line of observationLines.slice(1))
|
|
1305
|
+
console.log(` ${line}`);
|
|
928
1306
|
console.log(` local receipts ${existsSync(dbPath) ? `${dbPath} (${describeStore(dbPath)})` : "none yet"}`);
|
|
929
1307
|
for (const [name, scopes] of [["Claude Code", claude], ["Cursor", cursor], ["Codex", codex]]) {
|
|
930
1308
|
for (const file of [scopes.project, scopes.local, scopes.user])
|
|
@@ -932,10 +1310,78 @@ function runStatus() {
|
|
|
932
1310
|
console.log(` ${name}: ${file}`);
|
|
933
1311
|
}
|
|
934
1312
|
}
|
|
1313
|
+
/** `capabilities`: the manifest of what this hook can honestly claim, cell by cell.
|
|
1314
|
+
* `--prove` runs the safe fixtures in temp directories (never touching agent settings);
|
|
1315
|
+
* `--save` records the result beside the policy; `--json` prints the manifest. */
|
|
1316
|
+
async function runCapabilities(args) {
|
|
1317
|
+
const cwd = process.cwd();
|
|
1318
|
+
const dir = resolveConfigDir(cwd);
|
|
1319
|
+
const configured = {
|
|
1320
|
+
claude: !!harnessScopeLabel(harnessScopes("claude", cwd)),
|
|
1321
|
+
cursor: !!harnessScopeLabel(harnessScopes("cursor", cwd)),
|
|
1322
|
+
codex: !!harnessScopeLabel(harnessScopes("codex", cwd)),
|
|
1323
|
+
};
|
|
1324
|
+
const version = hookVersion();
|
|
1325
|
+
let proofs = loadProofs(dir);
|
|
1326
|
+
let failed = false;
|
|
1327
|
+
if (args.includes("--prove")) {
|
|
1328
|
+
// When the workspace accepts observations, the fixtures are signed with this machine's
|
|
1329
|
+
// own keys (copied into the temp home) and their receipts are delivered first, so the
|
|
1330
|
+
// proof can name them and the workspace can resolve them. Otherwise a fixture stays local.
|
|
1331
|
+
const observed = openObservations(dir, { adapterVersion: version, spawnHeartbeat: false });
|
|
1332
|
+
const collected = [];
|
|
1333
|
+
const fresh = await runProofFixtures(version, observed.emitter ? { identityDir: dir, collect: collected } : {});
|
|
1334
|
+
const proven = computeManifest({ adapterVersion: version, configured: { claude: true, codex: true, cursor: true }, proofs: fresh });
|
|
1335
|
+
for (const cell of proven.cells) {
|
|
1336
|
+
const proof = fresh[cell.key];
|
|
1337
|
+
if (proof && !proofPassed(proof, cell)) {
|
|
1338
|
+
failed = true;
|
|
1339
|
+
console.error(`fixture failed: ${cell.key} (allow ${proof.safe_allow}, deny ${proof.safe_deny}, signature ${proof.signature}, grouping ${proof.grouping})`);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
if (observed.emitter) {
|
|
1343
|
+
try {
|
|
1344
|
+
const delivered = await deliverProofReceipts(observed.emitter.connection, collected);
|
|
1345
|
+
if (!delivered)
|
|
1346
|
+
console.error("could not deliver the fixture receipts to the workspace; the capability proofs were not sent (run it again when it is reachable)");
|
|
1347
|
+
else {
|
|
1348
|
+
const queued = observed.emitter.capabilityProofs(proven.cells.flatMap((cell) => {
|
|
1349
|
+
// A typed-operation cell has no receipt of its own action type for a proof to name, so no proof is sent for it.
|
|
1350
|
+
if (TYPED_ACTION_TYPES.has(cell.action_type))
|
|
1351
|
+
return [];
|
|
1352
|
+
const proof = cell.state === "unsupported" ? undefined : fresh[cell.key];
|
|
1353
|
+
return proof ? [{ adapterVersion: cell.adapter_version, hostVariant: cell.host_variant, actionType: cell.action_type, phase: cell.event_phase, requiredFields: cell.emitted_required_fields, fixtureVersion: `fixture/${proof.test_vector_digest}`, passed: proofPassed(proof, cell), proofDigests: proof.proof_digests }] : [];
|
|
1354
|
+
}));
|
|
1355
|
+
await observed.emitter.flush(3000);
|
|
1356
|
+
console.error(`queued ${queued} capability proof observation(s) (fixture origin, ${collected.length} fixture receipt(s) delivered)`);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
finally {
|
|
1360
|
+
observed.emitter.close();
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
if (args.includes("--save")) {
|
|
1364
|
+
if (!existsSync(dir)) {
|
|
1365
|
+
console.error(`no ${dir} to record into - run \`${cliCommand("init")}\` first.`);
|
|
1366
|
+
process.exit(1);
|
|
1367
|
+
}
|
|
1368
|
+
console.error(`recorded fixture proofs in ${saveProofs(dir, fresh)}`);
|
|
1369
|
+
}
|
|
1370
|
+
proofs = fresh;
|
|
1371
|
+
}
|
|
1372
|
+
const manifest = computeManifest({ adapterVersion: version, configured, proofs });
|
|
1373
|
+
if (args.includes("--json"))
|
|
1374
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
1375
|
+
else {
|
|
1376
|
+
console.log(renderManifest(manifest));
|
|
1377
|
+
if (args.includes("--prove"))
|
|
1378
|
+
console.log(`\nFixture run ${failed ? "FAILED - see degraded cells" : "passed"}. It used temporary directories only; no agent setting or policy was changed and no key was modified.`);
|
|
1379
|
+
}
|
|
1380
|
+
process.exitCode = failed ? 1 : 0;
|
|
1381
|
+
}
|
|
935
1382
|
async function runDoctor() {
|
|
936
1383
|
const problems = [];
|
|
937
|
-
const
|
|
938
|
-
const nodeOk = major > 22 || (major === 22 && minor >= 13);
|
|
1384
|
+
const nodeOk = nodeSupported();
|
|
939
1385
|
console.log(`Scopebond doctor`);
|
|
940
1386
|
console.log(` node ${process.versions.node} ${nodeOk ? "ok" : "TOO OLD (need >=22.13)"}`);
|
|
941
1387
|
if (!nodeOk)
|
|
@@ -1158,7 +1604,7 @@ const COMMANDS = [
|
|
|
1158
1604
|
"--dry-run prints exactly which files it would touch and changes nothing. Each config",
|
|
1159
1605
|
"is copied to <file>.scopebond-backup before its first modification.",
|
|
1160
1606
|
] },
|
|
1161
|
-
{ name: "rules", args: "[show|allow|block|protect|unprotect|protect-branch|unprotect-branch|apply] [value]",
|
|
1607
|
+
{ name: "rules", args: "[show|allow|block|protect|unprotect|protect-branch|unprotect-branch|protect-remote-database|unprotect-remote-database|apply] [value]",
|
|
1162
1608
|
summary: "read and change the limits in plain terms",
|
|
1163
1609
|
detail: [
|
|
1164
1610
|
"With no arguments, prints what is blocked in plain English — no regular expressions.",
|
|
@@ -1170,6 +1616,52 @@ const COMMANDS = [
|
|
|
1170
1616
|
" rules apply recompile after editing rules.json by hand",
|
|
1171
1617
|
] },
|
|
1172
1618
|
{ name: "status", summary: "what is configured, where, and how big the local log is" },
|
|
1619
|
+
{ name: "capabilities", args: "[--prove [--save]] [--json]",
|
|
1620
|
+
summary: "what this hook can honestly claim, per agent host, action and phase",
|
|
1621
|
+
detail: [
|
|
1622
|
+
"Prints the capability manifest: for Claude Code, Codex and Cursor, each action type and",
|
|
1623
|
+
"phase is unsupported, inactive, configured_unverified, degraded or verified_reporting.",
|
|
1624
|
+
"Unsupported stays unsupported. A cell is never verified from a local run alone.",
|
|
1625
|
+
"--prove runs safe fixtures (allow, deny, signature, action group) in temporary",
|
|
1626
|
+
"directories; it does not read or change any agent settings. --save records the result.",
|
|
1627
|
+
] },
|
|
1628
|
+
{ name: "policy", args: "load <export.json> [--yes]",
|
|
1629
|
+
summary: "check a policy exported from your workspace and make it the active policy here",
|
|
1630
|
+
detail: [
|
|
1631
|
+
"Without --yes it only checks the export (its policy hash and scope digest, and that the",
|
|
1632
|
+
"gateway can load it) and says what loading would replace. With --yes the policy is",
|
|
1633
|
+
"written atomically as policy.json, the old one kept as policy.previous.json. If this",
|
|
1634
|
+
"machine is enrolled for observations, the load (or the refusal) is then acknowledged to",
|
|
1635
|
+
"the workspace, echoing the export's digests exactly. An export carries no signature;",
|
|
1636
|
+
"get the file from your workspace.",
|
|
1637
|
+
] },
|
|
1638
|
+
{ name: "observations", args: "[status [--refused]|flush|retry|wire|unwire|id <kind> <value>]",
|
|
1639
|
+
summary: "session, health and action observations sent to your workspace (opt-in)",
|
|
1640
|
+
detail: [
|
|
1641
|
+
`On only when your workspace enrollment grants ${OBSERVATIONS_SCOPE}; otherwise off, and status says why.`,
|
|
1642
|
+
"Local enforcement never depends on it: uploads are best effort and bounded. status shows",
|
|
1643
|
+
"what is pending and what the workspace refused (kept locally, never retried). flush",
|
|
1644
|
+
"sends now; retry re-checks a workspace that lacked the route; wire and unwire add or",
|
|
1645
|
+
"remove the Claude Code session and after-action hook entries.",
|
|
1646
|
+
] },
|
|
1647
|
+
{ name: "budget", args: "init|ack <id>|status|load <export.json>",
|
|
1648
|
+
summary: "per-agent action budgets: how many actions this agent may dispatch in a window",
|
|
1649
|
+
detail: [
|
|
1650
|
+
"load checks a budget exported from your workspace (digests, environment, validity, fail-closed contract) and, with --yes,",
|
|
1651
|
+
"makes it the acknowledged budget here, then acknowledges it to the workspace so its export stops showing as pending.",
|
|
1652
|
+
"init writes the suggested 100 actions per 60 seconds, monitor-only (nothing is limited).",
|
|
1653
|
+
"Enforcing needs the mode set to enforce and an acknowledgement of the exact policy (ack).",
|
|
1654
|
+
"Counters persist across hook processes and restarts. They count dispatched parent actions on",
|
|
1655
|
+
"THIS installation only; a limit shared across installations needs a shared in-path gateway,",
|
|
1656
|
+
"which an independent hook is not, so it refuses to enforce one.",
|
|
1657
|
+
] },
|
|
1658
|
+
{ name: "delegation", args: "add <file>|list|revoke <id>|import <file>",
|
|
1659
|
+
summary: "delegated child scopes: a child may do no more than its parent and never outlives it",
|
|
1660
|
+
detail: [
|
|
1661
|
+
"A session runs under one with SCOPEBOND_DELEGATION=<id>. Every action is checked against it,",
|
|
1662
|
+
"and against every ancestor, for scope, expiry and revocation. revoke takes effect on the next action.",
|
|
1663
|
+
"import adds revoked ids from a file exported from your workspace (add-only).",
|
|
1664
|
+
] },
|
|
1173
1665
|
{ name: "doctor", summary: "check the setup and whether each configured hook command can start",
|
|
1174
1666
|
detail: ["Exits non-zero when something is wrong, so it works in a script."] },
|
|
1175
1667
|
{ name: "log", args: "[-n N] [--deny] [--since 7d]",
|
|
@@ -1242,7 +1734,20 @@ function printHelp(topic, toStderr = false) {
|
|
|
1242
1734
|
out(`Receipts and keys stay in .scopebond/ in this project. Nothing leaves your machine`);
|
|
1243
1735
|
out(`unless you run \`connect\`. Docs: https://github.com/avouro-com/scopebond`);
|
|
1244
1736
|
}
|
|
1737
|
+
/** Whether this Node can run the receipt store (`node:sqlite`, 22.13+). */
|
|
1738
|
+
function nodeSupported(version = process.versions.node) {
|
|
1739
|
+
const [major, minor] = version.split(".").map(Number);
|
|
1740
|
+
return major > 22 || (major === 22 && minor >= 13);
|
|
1741
|
+
}
|
|
1245
1742
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
1743
|
+
// A setup command on an older Node would scaffold and wire the agent, then fail on the
|
|
1744
|
+
// first action with an error about a missing module. Stop before changing anything, and
|
|
1745
|
+
// say what to do. The hook subcommands are left alone: they already fail closed.
|
|
1746
|
+
if (["init", "install", "connect", "login"].includes(cmd ?? "") && !nodeSupported()) {
|
|
1747
|
+
console.error(`Scopebond needs Node.js 22.13 or later; this is Node ${process.versions.node}.`);
|
|
1748
|
+
console.error("Install the current Node.js LTS from https://nodejs.org, open a new terminal, and run the command again.");
|
|
1749
|
+
process.exit(1);
|
|
1750
|
+
}
|
|
1246
1751
|
if (cmd === "claude") {
|
|
1247
1752
|
await runClaude();
|
|
1248
1753
|
}
|
|
@@ -1279,6 +1784,31 @@ else if (cmd === "status") {
|
|
|
1279
1784
|
else if (cmd === "doctor") {
|
|
1280
1785
|
await runDoctor();
|
|
1281
1786
|
}
|
|
1787
|
+
else if (cmd === "capabilities") {
|
|
1788
|
+
await runCapabilities(rest);
|
|
1789
|
+
}
|
|
1790
|
+
else if (cmd === "observations") {
|
|
1791
|
+
await runObservations(rest);
|
|
1792
|
+
}
|
|
1793
|
+
else if (cmd === "policy") {
|
|
1794
|
+
await runPolicy(rest);
|
|
1795
|
+
}
|
|
1796
|
+
else if (cmd === "budget" && rest[0] === "load") {
|
|
1797
|
+
await runBudgetLoad(rest.slice(1));
|
|
1798
|
+
}
|
|
1799
|
+
else if (cmd === "budget" || cmd === "delegation") {
|
|
1800
|
+
// These change what the agent may do, so they are for a person at a terminal.
|
|
1801
|
+
if (rest[0] !== "status" && rest[0] !== "list")
|
|
1802
|
+
requireInteractive(cmd, rest);
|
|
1803
|
+
const dir = resolveConfigDir(process.cwd());
|
|
1804
|
+
const kid = (() => { try {
|
|
1805
|
+
return createSigner({ privateKeyPem: readFileSync(join(dir, "agent.key"), "utf8") }).kid;
|
|
1806
|
+
}
|
|
1807
|
+
catch {
|
|
1808
|
+
return "";
|
|
1809
|
+
} })();
|
|
1810
|
+
process.exit(runDispatchCommand(cmd, rest, dir, kid));
|
|
1811
|
+
}
|
|
1282
1812
|
else if (cmd === "uninstall") {
|
|
1283
1813
|
runUninstall(rest);
|
|
1284
1814
|
}
|