@scopebond/hook 0.8.1 → 0.10.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 +239 -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 +585 -13
- 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 +29 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -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/managed.d.ts +69 -0
- package/dist/managed.d.ts.map +1 -0
- package/dist/managed.js +184 -0
- package/dist/managed.js.map +1 -0
- 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/policy-sync.d.ts +45 -0
- package/dist/policy-sync.d.ts.map +1 -0
- package/dist/policy-sync.js +171 -0
- package/dist/policy-sync.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 +21 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +77 -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 +5 -5
package/dist/cli.js
CHANGED
|
@@ -29,17 +29,30 @@ 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, isTrustedProject, 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, policyBuilds } from "./policy-load.js";
|
|
45
|
+
import { isManaged, readMeta, MANAGED_DOC_FILE } from "./managed.js";
|
|
46
|
+
import { syncIfDue, syncPolicy } from "./policy-sync.js";
|
|
47
|
+
import { loadBudgetExport } from "./budget-load.js";
|
|
38
48
|
import { compile, defaultRules, describeRules, loadRules, saveRules, rulesPath, pathRuleFor } from "./rules.js";
|
|
39
49
|
import { createSigner } from "@scopebond/sdk";
|
|
50
|
+
import { runDispatchCommand } from "./dispatch-cli.js";
|
|
40
51
|
import { describeAction } from "./explain.js";
|
|
41
52
|
import { ensureDurableRuntime, pinnedCliPath, isEphemeralPath } from "./runtime-install.js";
|
|
42
53
|
import { cliCommand, hookCommand, hookVersion } from "./version.js";
|
|
54
|
+
import { computeManifest, renderManifest } from "./capabilities.js";
|
|
55
|
+
import { runProofFixtures, loadProofs, saveProofs, proofPassed, deliverProofReceipts } from "./proof.js";
|
|
43
56
|
import { fileURLToPath } from "node:url";
|
|
44
57
|
/** The current git branch in `cwd` (best-effort). A bare `git push` pushes it, so
|
|
45
58
|
* the runtime fills it in before evaluating; on failure the ref stays absent and
|
|
@@ -77,13 +90,14 @@ function readBundleArg(bundleArg, stdin) {
|
|
|
77
90
|
function configDir() {
|
|
78
91
|
return process.env.SCOPEBOND_HOOK_DIR ?? join(process.cwd(), ".scopebond");
|
|
79
92
|
}
|
|
80
|
-
function runtimePaths(dir) {
|
|
93
|
+
function runtimePaths(dir, cwd) {
|
|
81
94
|
const connection = loadConnection(dir);
|
|
82
95
|
return {
|
|
83
96
|
policyPath: join(dir, "policy.json"),
|
|
84
97
|
keyPath: join(dir, "agent.key"),
|
|
85
98
|
attesterPath: join(dir, "attester.key"),
|
|
86
99
|
dbPath: join(dir, "receipts.db"),
|
|
100
|
+
...(cwd ? { cwd } : {}),
|
|
87
101
|
// Strict: deny (not just observe) tools with no taxonomy mapping.
|
|
88
102
|
strict: process.argv.includes("--strict") || process.env.SCOPEBOND_HOOK_STRICT === "1",
|
|
89
103
|
// When connected, auto-export receipts. A short bounded flush keeps the hot path
|
|
@@ -92,6 +106,16 @@ function runtimePaths(dir) {
|
|
|
92
106
|
...(connection ? { cloud: { connection, flushTimeoutMs: Number(process.env.SCOPEBOND_HOOK_FLUSH_MS ?? 800) } } : {}),
|
|
93
107
|
};
|
|
94
108
|
}
|
|
109
|
+
/** The harness's own id for this tool call, when it gives one (Claude Code and Codex send
|
|
110
|
+
* `tool_use_id`), so the receipts of one call share a stable action group. */
|
|
111
|
+
function callId(input) {
|
|
112
|
+
for (const key of ["tool_use_id", "tool_call_id", "call_id"]) {
|
|
113
|
+
const value = input?.[key];
|
|
114
|
+
if (typeof value === "string" && value !== "")
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
95
119
|
function readStdin() {
|
|
96
120
|
try {
|
|
97
121
|
return readFileSync(0, "utf8");
|
|
@@ -134,10 +158,53 @@ const cursorCoverageNote = [
|
|
|
134
158
|
"For edits that must be blocked before they land, use the GitHub Action as a required",
|
|
135
159
|
"check on pull requests.",
|
|
136
160
|
].join("\n");
|
|
137
|
-
|
|
161
|
+
/** Record what was dispatched as observations, when this workspace enrolled for them. Purely
|
|
162
|
+
* additive: it runs after the decision is made and can neither change it nor fail it. */
|
|
163
|
+
function recordObservations(dir, cwd, input, decision, harness) {
|
|
164
|
+
try {
|
|
165
|
+
const { emitter } = openObservations(dir, { adapterVersion: hookVersion() });
|
|
166
|
+
if (!emitter)
|
|
167
|
+
return undefined;
|
|
168
|
+
const sessionId = typeof input.session_id === "string" && input.session_id !== "" ? input.session_id : undefined;
|
|
169
|
+
// Session lifecycle is Claude Code only: its SessionStart/SessionEnd hooks are the ones
|
|
170
|
+
// the tested mapping covers. Other hosts still get tool intents from their PreToolUse.
|
|
171
|
+
if (harness === "claude" && sessionId)
|
|
172
|
+
emitter.activity(sessionId, cwd);
|
|
173
|
+
emitter.toolIntents({ harnessSessionId: sessionId, callId: callId(input), cwd, dispatched: decision.dispatched ?? [], request: callRequestOf(input) });
|
|
174
|
+
return emitter;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/** The remote-database actions of a shell call, only when the rule set opts in (`protect_remote_database`).
|
|
181
|
+
* They are extra evaluated intents read from the raw command, so the deny happens before it runs. */
|
|
182
|
+
function databaseGuard(dir, cwd, input) {
|
|
183
|
+
let enabled = false;
|
|
184
|
+
try {
|
|
185
|
+
enabled = loadRules(dir)?.protect_remote_database === true;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
if (!enabled)
|
|
191
|
+
return [];
|
|
192
|
+
const request = callRequestOf(input);
|
|
193
|
+
if (request?.command === undefined)
|
|
194
|
+
return [];
|
|
195
|
+
const intent = (params) => ({ intent: { action_type: "db.exec", params }, evaluated: true, source: "shell" });
|
|
196
|
+
try {
|
|
197
|
+
return databaseGuardActions(request.command, request.dialect ?? "posix", { cwd }).map((action) => intent({ ...action }));
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
// The rule is on and the reader failed: a command that names a database tool is not waved through.
|
|
201
|
+
return /(?:wrangler|psql)/i.test(request.command) ? [intent({ provider: "unknown", verb: "unknown", scope: "unknown", risk: "unknown" })] : [];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async function runPreToolUse(mapper, deny = denyClaude, raw, harness = "claude") {
|
|
138
205
|
let input;
|
|
139
206
|
try {
|
|
140
|
-
input = JSON.parse(readStdin());
|
|
207
|
+
input = JSON.parse(raw ?? readStdin());
|
|
141
208
|
}
|
|
142
209
|
catch {
|
|
143
210
|
deny("hook received invalid JSON on stdin");
|
|
@@ -146,10 +213,13 @@ async function runPreToolUse(mapper, deny = denyClaude) {
|
|
|
146
213
|
try {
|
|
147
214
|
const cwd = input?.cwd ? String(input.cwd) : process.cwd();
|
|
148
215
|
const dir = resolveConfigDir(cwd);
|
|
149
|
-
runtime = createHookRuntime(runtimePaths(dir));
|
|
216
|
+
runtime = createHookRuntime(runtimePaths(dir, cwd));
|
|
150
217
|
useDigestKey(loadOrCreateDigestKey(dir));
|
|
151
|
-
const decision = await runtime.evaluate(fillPushBranch(mapper(input), currentBranch(cwd)));
|
|
152
|
-
|
|
218
|
+
const decision = await runtime.evaluate([...fillPushBranch(mapper(input), currentBranch(cwd)), ...databaseGuard(dir, cwd, input)], { groupKey: callId(input) });
|
|
219
|
+
const observer = recordObservations(dir, cwd, input, decision, harness);
|
|
220
|
+
// Workspace rules: at most every five minutes, a capped check alongside the record delivery. It never fails the call.
|
|
221
|
+
await Promise.all([runtime.flush(), observer?.flush() ?? Promise.resolve(), syncIfDue(dir, () => syncOptionsFor(dir))]);
|
|
222
|
+
observer?.close();
|
|
153
223
|
// Close before deciding: the receipt is already committed, and leaving the handle
|
|
154
224
|
// open is what made the write-ahead log grow without bound.
|
|
155
225
|
runtime.close();
|
|
@@ -168,11 +238,63 @@ async function runPreToolUse(mapper, deny = denyClaude) {
|
|
|
168
238
|
deny(`Scopebond hook failed closed: ${error.message}. Repair: run \`${cliCommand("init")}\`.`);
|
|
169
239
|
}
|
|
170
240
|
}
|
|
241
|
+
/** Claude Code events other than PreToolUse: session start and end, and the after-action
|
|
242
|
+
* events. They only feed observations; they never print a decision and always exit 0. */
|
|
243
|
+
async function runClaudeLifecycle(input) {
|
|
244
|
+
try {
|
|
245
|
+
const cwd = input.cwd ? String(input.cwd) : process.cwd();
|
|
246
|
+
const { emitter } = openObservations(resolveConfigDir(cwd), { adapterVersion: hookVersion() });
|
|
247
|
+
const sessionId = typeof input.session_id === "string" && input.session_id !== "" ? input.session_id : undefined;
|
|
248
|
+
if (emitter) {
|
|
249
|
+
try {
|
|
250
|
+
switch (input.hook_event_name) {
|
|
251
|
+
case "SessionStart":
|
|
252
|
+
if (sessionId)
|
|
253
|
+
emitter.sessionStart(sessionId, cwd);
|
|
254
|
+
break;
|
|
255
|
+
case "SessionEnd":
|
|
256
|
+
if (sessionId)
|
|
257
|
+
emitter.sessionStop(sessionId, stopReasonFromClaude(input.reason));
|
|
258
|
+
break;
|
|
259
|
+
case "PostToolUse": {
|
|
260
|
+
const id = callId(input);
|
|
261
|
+
if (id)
|
|
262
|
+
emitter.toolOutcomes(sessionId, id, "ok");
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
case "PostToolUseFailure": {
|
|
266
|
+
const id = callId(input);
|
|
267
|
+
if (id)
|
|
268
|
+
emitter.toolOutcomes(sessionId, id, exitFromClaudeFailure(input.is_interrupt));
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
await emitter.flush(input.hook_event_name === "SessionEnd" ? 1500 : 800);
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
emitter.close();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
catch { /* observations are best effort; nothing here may affect the agent */ }
|
|
280
|
+
process.exit(0);
|
|
281
|
+
}
|
|
282
|
+
const CLAUDE_OBSERVATION_EVENTS = new Set(["SessionStart", "SessionEnd", "PostToolUse", "PostToolUseFailure"]);
|
|
171
283
|
async function runClaude() {
|
|
172
|
-
|
|
284
|
+
const raw = readStdin();
|
|
285
|
+
let event;
|
|
286
|
+
let input;
|
|
287
|
+
try {
|
|
288
|
+
input = JSON.parse(raw);
|
|
289
|
+
event = input?.hook_event_name;
|
|
290
|
+
}
|
|
291
|
+
catch { /* PreToolUse reports invalid input */ }
|
|
292
|
+
if (input && typeof event === "string" && CLAUDE_OBSERVATION_EVENTS.has(event))
|
|
293
|
+
await runClaudeLifecycle(input);
|
|
294
|
+
await runPreToolUse(mapClaudeToolUse, denyClaude, raw, "claude");
|
|
173
295
|
}
|
|
174
296
|
async function runCodex() {
|
|
175
|
-
await runPreToolUse(mapCodexToolUse, denyCodex);
|
|
297
|
+
await runPreToolUse(mapCodexToolUse, denyCodex, undefined, "codex");
|
|
176
298
|
}
|
|
177
299
|
function denyCursor(reason) {
|
|
178
300
|
process.stdout.write(JSON.stringify({ permission: "deny", agentMessage: reason }) + "\n");
|
|
@@ -202,11 +324,13 @@ async function runCursor() {
|
|
|
202
324
|
try {
|
|
203
325
|
const cwd = input?.cwd ? String(input.cwd) : process.cwd();
|
|
204
326
|
const dir = resolveConfigDir(cwd);
|
|
205
|
-
runtime = createHookRuntime(runtimePaths(dir));
|
|
327
|
+
runtime = createHookRuntime(runtimePaths(dir, cwd));
|
|
206
328
|
useDigestKey(loadOrCreateDigestKey(dir));
|
|
207
329
|
const mapped = fillPushBranch(mapCursorEvent(event, input), currentBranch(cwd));
|
|
208
|
-
const decision = await runtime.evaluate(mapped);
|
|
209
|
-
|
|
330
|
+
const decision = await runtime.evaluate(mapped, { groupKey: callId(input) });
|
|
331
|
+
const observer = recordObservations(dir, cwd, input, decision, "cursor");
|
|
332
|
+
await Promise.all([runtime.flush(), observer?.flush() ?? Promise.resolve(), syncIfDue(dir, () => syncOptionsFor(dir))]);
|
|
333
|
+
observer?.close();
|
|
210
334
|
// An `afterFileEdit` violation is real and recorded, but the edit has already
|
|
211
335
|
// landed. Say so rather than letting "blocked" imply it was stopped.
|
|
212
336
|
postHoc = mapped.some((m) => m.postHoc);
|
|
@@ -458,6 +582,7 @@ function runRules(args) {
|
|
|
458
582
|
console.log(` ${cliCommand("rules unprotect <path>")} allow writing there again`);
|
|
459
583
|
console.log(` ${cliCommand("rules protect-branch <name>")} never push there`);
|
|
460
584
|
console.log(` ${cliCommand("rules unprotect-branch <name>")}`);
|
|
585
|
+
console.log(` ${cliCommand("rules protect-remote-database")} block destructive or unreadable SQL on a remote database (off by default)`);
|
|
461
586
|
console.log(`Or edit ${rulesPath(dir)} directly, then \`${cliCommand("rules apply")}\`.`);
|
|
462
587
|
process.exit(0);
|
|
463
588
|
}
|
|
@@ -531,6 +656,22 @@ function runRules(args) {
|
|
|
531
656
|
changed = `pushes to ${value} are allowed again`;
|
|
532
657
|
break;
|
|
533
658
|
}
|
|
659
|
+
case "protect-remote-database":
|
|
660
|
+
if (rules.protect_remote_database === true) {
|
|
661
|
+
console.log("Remote databases are already protected.");
|
|
662
|
+
process.exit(0);
|
|
663
|
+
}
|
|
664
|
+
rules.protect_remote_database = true;
|
|
665
|
+
changed = "remote SQL that drops, deletes or updates every row, or cannot be read, is now blocked";
|
|
666
|
+
break;
|
|
667
|
+
case "unprotect-remote-database":
|
|
668
|
+
if (rules.protect_remote_database !== true) {
|
|
669
|
+
console.log("Remote databases were not protected; nothing to change.");
|
|
670
|
+
process.exit(0);
|
|
671
|
+
}
|
|
672
|
+
delete rules.protect_remote_database;
|
|
673
|
+
changed = "remote SQL is no longer checked";
|
|
674
|
+
break;
|
|
534
675
|
case "apply":
|
|
535
676
|
changed = `recompiled from ${rulesPath(dir)}`;
|
|
536
677
|
break;
|
|
@@ -539,6 +680,11 @@ function runRules(args) {
|
|
|
539
680
|
printHelp("rules", true);
|
|
540
681
|
process.exit(1);
|
|
541
682
|
}
|
|
683
|
+
if (isManaged(dir)) {
|
|
684
|
+
console.error("The rules on this computer are set by your Scopebond workspace, so they cannot be changed here.");
|
|
685
|
+
console.error("Change them in the workspace (Rules), or disconnect this computer to manage its rules locally again.");
|
|
686
|
+
process.exit(1);
|
|
687
|
+
}
|
|
542
688
|
// Changing what governs the agent is the same class of action as `init`.
|
|
543
689
|
requireInteractive("rules", args);
|
|
544
690
|
const policyPath = join(dir, "policy.json");
|
|
@@ -778,6 +924,21 @@ async function finishConnect(dir, url, bundle, harness, args) {
|
|
|
778
924
|
console.log(`Add this to your ${harnessFileName(harness)}:`);
|
|
779
925
|
console.log(harnessSnippet(harness));
|
|
780
926
|
}
|
|
927
|
+
// Session and after-action observations are opt-in through the enrollment: wired only
|
|
928
|
+
// when this workspace granted observations:write and Claude Code is the host. Off is
|
|
929
|
+
// silent here (`status` says why); a granted scope the hook cannot use is worth a line.
|
|
930
|
+
const observing = observationStatus(loadConnection(dir));
|
|
931
|
+
if (observing.state === "on" && harness === "claude" && !args.includes("--no-install")) {
|
|
932
|
+
const placed = harnessScopes("claude", process.cwd());
|
|
933
|
+
const target = placed.local ?? placed.project ?? placed.user;
|
|
934
|
+
const command = target ? configuredHookCommands(target)[0] : undefined;
|
|
935
|
+
if (target && command) {
|
|
936
|
+
wireLifecycleHooks(target, command);
|
|
937
|
+
console.log(`✓ Session and after-action observations enabled in ${target}`);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
else if (observing.state === "unsupported")
|
|
941
|
+
console.log(`Observations: ${observing.reason}`);
|
|
781
942
|
console.log("");
|
|
782
943
|
console.log("Run your agent — the first action appears in your workspace within seconds.");
|
|
783
944
|
}
|
|
@@ -812,6 +973,263 @@ async function runFlush() {
|
|
|
812
973
|
// Let pending HTTP handles close normally (forced exit can abort on Windows).
|
|
813
974
|
process.exitCode = status && status.pending > 0 ? 1 : 0;
|
|
814
975
|
}
|
|
976
|
+
/** `observations`: what the observation emitters are doing, and the local queue. */
|
|
977
|
+
/** `policy load <export.json> [--yes]`: check a policy exported from the workspace and, with
|
|
978
|
+
* `--yes`, make it the active policy here; then acknowledge it (or its refusal) to the workspace. */
|
|
979
|
+
async function runPolicy(args) {
|
|
980
|
+
const [sub, file] = args;
|
|
981
|
+
if (sub === "sync") {
|
|
982
|
+
await runPolicySync(args.includes("--background"));
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
if (sub !== "load" || !file) {
|
|
986
|
+
console.error(`usage: ${cliCommand("policy sync")} | ${cliCommand("policy load <export.json> [--yes]")}`);
|
|
987
|
+
process.exitCode = 2;
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
const dir = resolveConfigDir(process.cwd());
|
|
991
|
+
if (isManaged(dir)) {
|
|
992
|
+
console.error("The rules on this computer are set by your Scopebond workspace; a policy file cannot replace them here.");
|
|
993
|
+
process.exitCode = 1;
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
const apply = args.includes("--yes");
|
|
997
|
+
const connection = loadConnection(dir);
|
|
998
|
+
const outcome = loadPolicyExport(dir, file, { apply, environmentId: connection?.environment_id });
|
|
999
|
+
let ack;
|
|
1000
|
+
if (outcome.state === "rejected") {
|
|
1001
|
+
console.error(`refused: ${outcome.message} (${outcome.error})`);
|
|
1002
|
+
if (outcome.ack)
|
|
1003
|
+
ack = { ...outcome.ack, error: outcome.error };
|
|
1004
|
+
process.exitCode = 1;
|
|
1005
|
+
}
|
|
1006
|
+
else if (outcome.state === "would_load") {
|
|
1007
|
+
console.log(`This export checks out (policy ${outcome.facts.policyId} v${outcome.facts.policyVersion}, digest ${outcome.facts.policyDigest.slice(0, 12)}...).`);
|
|
1008
|
+
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.`);
|
|
1009
|
+
console.log("Run again with --yes to load it.");
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
else {
|
|
1013
|
+
console.log(`loaded policy ${outcome.facts.policyId} v${outcome.facts.policyVersion} (digest ${outcome.facts.policyDigest.slice(0, 12)}...) from export ${outcome.facts.exportId}`);
|
|
1014
|
+
console.log(` active policy ${outcome.policyPath}${outcome.previous ? ` (previous kept at ${outcome.previous})` : ""}`);
|
|
1015
|
+
console.log(` note \`${cliCommand("rules apply")}\` recompiles policy.json from rules.json and would replace it`);
|
|
1016
|
+
ack = { exportId: outcome.facts.exportId, policyId: outcome.facts.policyId, policyVersion: outcome.facts.policyVersion, policyDigest: outcome.facts.policyDigest, scopeDigest: outcome.facts.scopeDigest };
|
|
1017
|
+
}
|
|
1018
|
+
if (!ack)
|
|
1019
|
+
return;
|
|
1020
|
+
await sendPolicyAck(dir, ack);
|
|
1021
|
+
}
|
|
1022
|
+
/** What a rules check needs for this config directory. A project policy governs only once trusted, so it is re-pinned after a
|
|
1023
|
+
* write, exactly when `rules apply` would. */
|
|
1024
|
+
function syncOptionsFor(dir) {
|
|
1025
|
+
const home = userHome();
|
|
1026
|
+
const repin = dir !== home && existsSync(join(home, "policy.json")) && isTrustedProject(dir);
|
|
1027
|
+
const agentKid = createSigner({ privateKeyPem: readFileSync(join(dir, "agent.key"), "utf8") }).kid;
|
|
1028
|
+
return { agentKid, hookVersion: hookVersion(), policyBuilds, afterPolicyWrite: repin ? (d) => { trustProjectPolicy(d); } : undefined };
|
|
1029
|
+
}
|
|
1030
|
+
/** `policy sync`: bring this computer's rules in line with its workspace now (the hook also checks every five minutes). */
|
|
1031
|
+
async function runPolicySync(background) {
|
|
1032
|
+
const dir = resolveConfigDir(process.cwd());
|
|
1033
|
+
let outcome;
|
|
1034
|
+
try {
|
|
1035
|
+
outcome = await syncPolicy(dir, syncOptionsFor(dir));
|
|
1036
|
+
}
|
|
1037
|
+
catch (error) {
|
|
1038
|
+
outcome = { state: "unavailable", message: error.message };
|
|
1039
|
+
}
|
|
1040
|
+
if (background)
|
|
1041
|
+
return;
|
|
1042
|
+
const lines = {
|
|
1043
|
+
not_connected: "This computer is not connected to a Scopebond workspace; it uses its own rules.",
|
|
1044
|
+
own_rules: "Your workspace does not set rules for this computer; it uses its own rules.",
|
|
1045
|
+
unchanged: "Up to date with your workspace.",
|
|
1046
|
+
applied: "Updated to your workspace's latest rules.",
|
|
1047
|
+
refused: "Could not apply your workspace's rules; the rules already in force stay.",
|
|
1048
|
+
disconnected: "The workspace connection is no longer valid; this computer now uses its own rules.",
|
|
1049
|
+
unavailable: "Could not reach your workspace; the rules already in force stay.",
|
|
1050
|
+
};
|
|
1051
|
+
console.log(lines[outcome.state]);
|
|
1052
|
+
if (outcome.state === "refused" || outcome.state === "unavailable") {
|
|
1053
|
+
console.log(` ${outcome.message}`);
|
|
1054
|
+
process.exitCode = 1;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
/** Queue a `policy_ack` (loaded or rejected) through the observation outbox and try to deliver it now. */
|
|
1058
|
+
async function sendPolicyAck(dir, ack) {
|
|
1059
|
+
const observed = openObservations(dir, { adapterVersion: hookVersion(), spawnHeartbeat: false });
|
|
1060
|
+
if (!observed.emitter) {
|
|
1061
|
+
console.error(`not acknowledged to the workspace: observations are ${observed.status.state}${"reason" in observed.status ? ` (${observed.status.reason})` : ""}`);
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
try {
|
|
1065
|
+
const queued = observed.emitter.policyAck(ack);
|
|
1066
|
+
await observed.emitter.flush(3000);
|
|
1067
|
+
console.error(queued?.queued ? `queued the ${ack.error ? "rejection" : "load"} acknowledgement for the workspace` : "could not queue the acknowledgement");
|
|
1068
|
+
}
|
|
1069
|
+
finally {
|
|
1070
|
+
observed.emitter.close();
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
/** `budget load <export.json> [--yes]`: check an action budget exported from the workspace and, with `--yes`,
|
|
1074
|
+
* make it the budget this machine enforces; then acknowledge it (or its refusal) to the workspace. */
|
|
1075
|
+
async function runBudgetLoad(args) {
|
|
1076
|
+
const file = args.find((a) => !a.startsWith("--"));
|
|
1077
|
+
if (!file) {
|
|
1078
|
+
console.error(`usage: ${cliCommand("budget load <export.json> [--yes]")}`);
|
|
1079
|
+
process.exitCode = 2;
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
const dir = resolveConfigDir(process.cwd());
|
|
1083
|
+
const agentKid = (() => { try {
|
|
1084
|
+
return createSigner({ privateKeyPem: readFileSync(join(dir, "agent.key"), "utf8") }).kid;
|
|
1085
|
+
}
|
|
1086
|
+
catch {
|
|
1087
|
+
return "";
|
|
1088
|
+
} })();
|
|
1089
|
+
const outcome = loadBudgetExport(dir, file, { apply: args.includes("--yes"), agentKid, environmentId: loadConnection(dir)?.environment_id });
|
|
1090
|
+
let ack;
|
|
1091
|
+
if (outcome.state === "rejected") {
|
|
1092
|
+
console.error(`refused: ${outcome.message} (${outcome.error})`);
|
|
1093
|
+
if (outcome.ack && outcome.error !== "expired")
|
|
1094
|
+
ack = { ...outcome.ack, error: outcome.error };
|
|
1095
|
+
process.exitCode = 1;
|
|
1096
|
+
}
|
|
1097
|
+
else if (outcome.state === "would_load") {
|
|
1098
|
+
const p = outcome.facts.policy;
|
|
1099
|
+
for (const w of outcome.warnings)
|
|
1100
|
+
console.error(`warning: ${w}`);
|
|
1101
|
+
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.`);
|
|
1102
|
+
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.`);
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
else {
|
|
1106
|
+
const p = outcome.facts.policy;
|
|
1107
|
+
for (const w of outcome.warnings)
|
|
1108
|
+
console.error(`warning: ${w}`);
|
|
1109
|
+
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`);
|
|
1110
|
+
if (outcome.replaced.length > 0)
|
|
1111
|
+
console.log(` replaced ${outcome.replaced.join(", ")}`);
|
|
1112
|
+
console.log(` valid until ${new Date(outcome.facts.validUntil).toISOString()} (after that an enforced budget denies new dispatch until you load a new export)`);
|
|
1113
|
+
ack = { exportId: outcome.facts.exportId, policyId: outcome.facts.budgetId, policyVersion: outcome.facts.budgetVersion, policyDigest: outcome.facts.policyDigest, scopeDigest: outcome.facts.scopeDigest };
|
|
1114
|
+
}
|
|
1115
|
+
if (!ack)
|
|
1116
|
+
return;
|
|
1117
|
+
await sendPolicyAck(dir, ack);
|
|
1118
|
+
}
|
|
1119
|
+
async function runObservations(args) {
|
|
1120
|
+
const sub = args[0] ?? "status";
|
|
1121
|
+
const dir = resolveConfigDir(process.cwd());
|
|
1122
|
+
if (sub === "heartbeat") {
|
|
1123
|
+
await runHeartbeatLoop(args[1] ?? "");
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
if (sub === "status") {
|
|
1127
|
+
const lines = describeObservations(dir);
|
|
1128
|
+
console.log(`observations: ${lines[0]}`);
|
|
1129
|
+
for (const line of lines.slice(1))
|
|
1130
|
+
console.log(` ${line}`);
|
|
1131
|
+
const file = join(dir, OBSERVATION_DB);
|
|
1132
|
+
if (args.includes("--refused") && existsSync(file)) {
|
|
1133
|
+
const store = new ObservationStore(file);
|
|
1134
|
+
try {
|
|
1135
|
+
for (const row of store.terminal())
|
|
1136
|
+
console.log(` refused #${row.sequence} gen ${row.generation} ${row.kind} ${row.observation_id} ${row.code} ${new Date(row.at).toISOString()}`);
|
|
1137
|
+
}
|
|
1138
|
+
finally {
|
|
1139
|
+
store.close();
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
if (sub === "id") {
|
|
1145
|
+
// The opaque id this installation gives a ref, remote or repository, for writing reference
|
|
1146
|
+
// sets. Local only: it reads the binding key and prints; nothing is sent anywhere.
|
|
1147
|
+
const id = keyedIdFor(loadOrCreateBindingKey(dir), args[1] ?? "", args.slice(2));
|
|
1148
|
+
if (!id) {
|
|
1149
|
+
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>>");
|
|
1150
|
+
process.exit(1);
|
|
1151
|
+
}
|
|
1152
|
+
console.log(id);
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
if (sub === "wire" || sub === "unwire") {
|
|
1156
|
+
const scopes = harnessScopes("claude", process.cwd());
|
|
1157
|
+
if (sub === "unwire") {
|
|
1158
|
+
let removed = 0;
|
|
1159
|
+
for (const file of [scopes.project, scopes.local, scopes.user])
|
|
1160
|
+
if (file)
|
|
1161
|
+
removed += unwireLifecycleHooks(file);
|
|
1162
|
+
console.log(`removed ${removed} lifecycle hook entr${removed === 1 ? "y" : "ies"}`);
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
const target = scopes.local ?? scopes.project ?? scopes.user;
|
|
1166
|
+
const command = target ? configuredHookCommands(target)[0] : undefined;
|
|
1167
|
+
if (!target || !command) {
|
|
1168
|
+
console.error("Claude Code is not configured with this hook; run install or init first");
|
|
1169
|
+
process.exit(1);
|
|
1170
|
+
}
|
|
1171
|
+
wireLifecycleHooks(target, command);
|
|
1172
|
+
console.log(`session and after-action hooks wired in ${target}`);
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
const opened = openObservations(dir, { adapterVersion: hookVersion(), spawnHeartbeat: false });
|
|
1176
|
+
if (!opened.emitter) {
|
|
1177
|
+
console.error(`observations are ${opened.status.state}${"reason" in opened.status ? `: ${opened.status.reason}` : ""}`);
|
|
1178
|
+
process.exit(1);
|
|
1179
|
+
}
|
|
1180
|
+
const emitter = opened.emitter;
|
|
1181
|
+
try {
|
|
1182
|
+
if (sub === "flush") {
|
|
1183
|
+
const outcome = await uploadPending(emitter.store, { url: emitter.connection.url, credential: emitter.connection.credential, timeoutMs: 10_000 });
|
|
1184
|
+
const left = emitter.store.pendingSummary().count;
|
|
1185
|
+
console.log(`${outcome.result}: ${outcome.acknowledged} acknowledged, ${outcome.deferred} deferred, ${outcome.rejected} refused; ${left} still pending${outcome.detail ? ` (${outcome.detail})` : ""}`);
|
|
1186
|
+
process.exitCode = left > 0 ? 1 : 0;
|
|
1187
|
+
}
|
|
1188
|
+
else if (sub === "retry") {
|
|
1189
|
+
const state = emitter.store.state();
|
|
1190
|
+
if (state?.capability === "unsupported") {
|
|
1191
|
+
emitter.store.setCapability("active", null);
|
|
1192
|
+
console.log("will try the workspace again");
|
|
1193
|
+
}
|
|
1194
|
+
else
|
|
1195
|
+
console.log("nothing to retry (a stale generation clears when you reconnect)");
|
|
1196
|
+
}
|
|
1197
|
+
else {
|
|
1198
|
+
console.error("usage: observations [status [--refused]|flush|retry|wire|unwire]");
|
|
1199
|
+
process.exit(1);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
finally {
|
|
1203
|
+
emitter.close();
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
/** The single heartbeat helper for one active session (started by the hook, never by hand). */
|
|
1207
|
+
async function runHeartbeatLoop(sessionId) {
|
|
1208
|
+
if (!sessionId)
|
|
1209
|
+
return;
|
|
1210
|
+
const dir = resolveConfigDir(process.cwd());
|
|
1211
|
+
const { emitter } = openObservations(dir, { adapterVersion: hookVersion(), spawnHeartbeat: false, flushTimeoutMs: 3000 });
|
|
1212
|
+
if (!emitter)
|
|
1213
|
+
return;
|
|
1214
|
+
const override = Number(process.env.SCOPEBOND_HEARTBEAT_INTERVAL_MS);
|
|
1215
|
+
const interval = Number.isFinite(override) && override >= 100 ? override : HEARTBEAT_INTERVAL_MS;
|
|
1216
|
+
const endsAt = Date.now() + 24 * 60 * 60 * 1000;
|
|
1217
|
+
let last = Date.now();
|
|
1218
|
+
try {
|
|
1219
|
+
for (;;) {
|
|
1220
|
+
const verdict = emitter.heartbeatTick(sessionId, last);
|
|
1221
|
+
await emitter.flush();
|
|
1222
|
+
if (verdict === "stop" || Date.now() > endsAt)
|
|
1223
|
+
break;
|
|
1224
|
+
last = Date.now();
|
|
1225
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
finally {
|
|
1229
|
+
emitter.store.releaseHeartbeat(sessionId);
|
|
1230
|
+
emitter.close();
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
815
1233
|
/** The absolute path to this CLI file, for registering the hook by absolute path. */
|
|
816
1234
|
function cliPath() {
|
|
817
1235
|
return fileURLToPath(import.meta.url);
|
|
@@ -933,6 +1351,20 @@ function runStatus() {
|
|
|
933
1351
|
console.log(` Cursor ${harnessScopeLabel(cursor) || (cursorDetected() ? "detected, not configured" : "not detected")}`);
|
|
934
1352
|
console.log(` Codex ${codex.project || codex.user ? `${harnessScopeLabel(codex)} — approve once with /hooks` : codexDetected() ? "detected, not configured" : "not detected"}`);
|
|
935
1353
|
console.log(` cloud workspace ${connected ? "connected" : "not connected (local only)"}`);
|
|
1354
|
+
{
|
|
1355
|
+
const activeDir = resolveConfigDir(process.cwd());
|
|
1356
|
+
const meta = readMeta(activeDir);
|
|
1357
|
+
const managed = existsSync(join(activeDir, MANAGED_DOC_FILE));
|
|
1358
|
+
const checked = meta.checked_at ? `, last checked ${meta.checked_at}` : "";
|
|
1359
|
+
console.log(` rules ${managed ? `set by your workspace (version ${meta.revision})${checked}` : `this computer's own (${rulesPath(activeDir)})${connected ? checked : ""}`}`);
|
|
1360
|
+
console.log(` always on: protection of Scopebond's own settings and the agents' hook settings`);
|
|
1361
|
+
if (meta.last_error)
|
|
1362
|
+
console.log(` last problem: ${meta.last_error}`);
|
|
1363
|
+
}
|
|
1364
|
+
const observationLines = describeObservations(resolveConfigDir(process.cwd()));
|
|
1365
|
+
console.log(` observations ${observationLines[0]}`);
|
|
1366
|
+
for (const line of observationLines.slice(1))
|
|
1367
|
+
console.log(` ${line}`);
|
|
936
1368
|
console.log(` local receipts ${existsSync(dbPath) ? `${dbPath} (${describeStore(dbPath)})` : "none yet"}`);
|
|
937
1369
|
for (const [name, scopes] of [["Claude Code", claude], ["Cursor", cursor], ["Codex", codex]]) {
|
|
938
1370
|
for (const file of [scopes.project, scopes.local, scopes.user])
|
|
@@ -940,6 +1372,75 @@ function runStatus() {
|
|
|
940
1372
|
console.log(` ${name}: ${file}`);
|
|
941
1373
|
}
|
|
942
1374
|
}
|
|
1375
|
+
/** `capabilities`: the manifest of what this hook can honestly claim, cell by cell.
|
|
1376
|
+
* `--prove` runs the safe fixtures in temp directories (never touching agent settings);
|
|
1377
|
+
* `--save` records the result beside the policy; `--json` prints the manifest. */
|
|
1378
|
+
async function runCapabilities(args) {
|
|
1379
|
+
const cwd = process.cwd();
|
|
1380
|
+
const dir = resolveConfigDir(cwd);
|
|
1381
|
+
const configured = {
|
|
1382
|
+
claude: !!harnessScopeLabel(harnessScopes("claude", cwd)),
|
|
1383
|
+
cursor: !!harnessScopeLabel(harnessScopes("cursor", cwd)),
|
|
1384
|
+
codex: !!harnessScopeLabel(harnessScopes("codex", cwd)),
|
|
1385
|
+
};
|
|
1386
|
+
const version = hookVersion();
|
|
1387
|
+
let proofs = loadProofs(dir);
|
|
1388
|
+
let failed = false;
|
|
1389
|
+
if (args.includes("--prove")) {
|
|
1390
|
+
// When the workspace accepts observations, the fixtures are signed with this machine's
|
|
1391
|
+
// own keys (copied into the temp home) and their receipts are delivered first, so the
|
|
1392
|
+
// proof can name them and the workspace can resolve them. Otherwise a fixture stays local.
|
|
1393
|
+
const observed = openObservations(dir, { adapterVersion: version, spawnHeartbeat: false });
|
|
1394
|
+
const collected = [];
|
|
1395
|
+
const fresh = await runProofFixtures(version, observed.emitter ? { identityDir: dir, collect: collected } : {});
|
|
1396
|
+
const proven = computeManifest({ adapterVersion: version, configured: { claude: true, codex: true, cursor: true }, proofs: fresh });
|
|
1397
|
+
for (const cell of proven.cells) {
|
|
1398
|
+
const proof = fresh[cell.key];
|
|
1399
|
+
if (proof && !proofPassed(proof, cell)) {
|
|
1400
|
+
failed = true;
|
|
1401
|
+
console.error(`fixture failed: ${cell.key} (allow ${proof.safe_allow}, deny ${proof.safe_deny}, signature ${proof.signature}, grouping ${proof.grouping})`);
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
if (observed.emitter) {
|
|
1405
|
+
try {
|
|
1406
|
+
const delivered = await deliverProofReceipts(observed.emitter.connection, collected);
|
|
1407
|
+
if (!delivered)
|
|
1408
|
+
console.error("could not deliver the fixture receipts to the workspace; the capability proofs were not sent (run it again when it is reachable)");
|
|
1409
|
+
else {
|
|
1410
|
+
const queued = observed.emitter.capabilityProofs(proven.cells.flatMap((cell) => {
|
|
1411
|
+
// A typed-operation cell has no receipt of its own action type for a proof to name, so no proof is sent for it.
|
|
1412
|
+
if (TYPED_ACTION_TYPES.has(cell.action_type))
|
|
1413
|
+
return [];
|
|
1414
|
+
const proof = cell.state === "unsupported" ? undefined : fresh[cell.key];
|
|
1415
|
+
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 }] : [];
|
|
1416
|
+
}));
|
|
1417
|
+
await observed.emitter.flush(3000);
|
|
1418
|
+
console.error(`queued ${queued} capability proof observation(s) (fixture origin, ${collected.length} fixture receipt(s) delivered)`);
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
finally {
|
|
1422
|
+
observed.emitter.close();
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
if (args.includes("--save")) {
|
|
1426
|
+
if (!existsSync(dir)) {
|
|
1427
|
+
console.error(`no ${dir} to record into - run \`${cliCommand("init")}\` first.`);
|
|
1428
|
+
process.exit(1);
|
|
1429
|
+
}
|
|
1430
|
+
console.error(`recorded fixture proofs in ${saveProofs(dir, fresh)}`);
|
|
1431
|
+
}
|
|
1432
|
+
proofs = fresh;
|
|
1433
|
+
}
|
|
1434
|
+
const manifest = computeManifest({ adapterVersion: version, configured, proofs });
|
|
1435
|
+
if (args.includes("--json"))
|
|
1436
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
1437
|
+
else {
|
|
1438
|
+
console.log(renderManifest(manifest));
|
|
1439
|
+
if (args.includes("--prove"))
|
|
1440
|
+
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.`);
|
|
1441
|
+
}
|
|
1442
|
+
process.exitCode = failed ? 1 : 0;
|
|
1443
|
+
}
|
|
943
1444
|
async function runDoctor() {
|
|
944
1445
|
const problems = [];
|
|
945
1446
|
const nodeOk = nodeSupported();
|
|
@@ -1165,7 +1666,7 @@ const COMMANDS = [
|
|
|
1165
1666
|
"--dry-run prints exactly which files it would touch and changes nothing. Each config",
|
|
1166
1667
|
"is copied to <file>.scopebond-backup before its first modification.",
|
|
1167
1668
|
] },
|
|
1168
|
-
{ name: "rules", args: "[show|allow|block|protect|unprotect|protect-branch|unprotect-branch|apply] [value]",
|
|
1669
|
+
{ name: "rules", args: "[show|allow|block|protect|unprotect|protect-branch|unprotect-branch|protect-remote-database|unprotect-remote-database|apply] [value]",
|
|
1169
1670
|
summary: "read and change the limits in plain terms",
|
|
1170
1671
|
detail: [
|
|
1171
1672
|
"With no arguments, prints what is blocked in plain English — no regular expressions.",
|
|
@@ -1177,6 +1678,52 @@ const COMMANDS = [
|
|
|
1177
1678
|
" rules apply recompile after editing rules.json by hand",
|
|
1178
1679
|
] },
|
|
1179
1680
|
{ name: "status", summary: "what is configured, where, and how big the local log is" },
|
|
1681
|
+
{ name: "capabilities", args: "[--prove [--save]] [--json]",
|
|
1682
|
+
summary: "what this hook can honestly claim, per agent host, action and phase",
|
|
1683
|
+
detail: [
|
|
1684
|
+
"Prints the capability manifest: for Claude Code, Codex and Cursor, each action type and",
|
|
1685
|
+
"phase is unsupported, inactive, configured_unverified, degraded or verified_reporting.",
|
|
1686
|
+
"Unsupported stays unsupported. A cell is never verified from a local run alone.",
|
|
1687
|
+
"--prove runs safe fixtures (allow, deny, signature, action group) in temporary",
|
|
1688
|
+
"directories; it does not read or change any agent settings. --save records the result.",
|
|
1689
|
+
] },
|
|
1690
|
+
{ name: "policy", args: "load <export.json> [--yes]",
|
|
1691
|
+
summary: "check a policy exported from your workspace and make it the active policy here",
|
|
1692
|
+
detail: [
|
|
1693
|
+
"Without --yes it only checks the export (its policy hash and scope digest, and that the",
|
|
1694
|
+
"gateway can load it) and says what loading would replace. With --yes the policy is",
|
|
1695
|
+
"written atomically as policy.json, the old one kept as policy.previous.json. If this",
|
|
1696
|
+
"machine is enrolled for observations, the load (or the refusal) is then acknowledged to",
|
|
1697
|
+
"the workspace, echoing the export's digests exactly. An export carries no signature;",
|
|
1698
|
+
"get the file from your workspace.",
|
|
1699
|
+
] },
|
|
1700
|
+
{ name: "observations", args: "[status [--refused]|flush|retry|wire|unwire|id <kind> <value>]",
|
|
1701
|
+
summary: "session, health and action observations sent to your workspace (opt-in)",
|
|
1702
|
+
detail: [
|
|
1703
|
+
`On only when your workspace enrollment grants ${OBSERVATIONS_SCOPE}; otherwise off, and status says why.`,
|
|
1704
|
+
"Local enforcement never depends on it: uploads are best effort and bounded. status shows",
|
|
1705
|
+
"what is pending and what the workspace refused (kept locally, never retried). flush",
|
|
1706
|
+
"sends now; retry re-checks a workspace that lacked the route; wire and unwire add or",
|
|
1707
|
+
"remove the Claude Code session and after-action hook entries.",
|
|
1708
|
+
] },
|
|
1709
|
+
{ name: "budget", args: "init|ack <id>|status|load <export.json>",
|
|
1710
|
+
summary: "per-agent action budgets: how many actions this agent may dispatch in a window",
|
|
1711
|
+
detail: [
|
|
1712
|
+
"load checks a budget exported from your workspace (digests, environment, validity, fail-closed contract) and, with --yes,",
|
|
1713
|
+
"makes it the acknowledged budget here, then acknowledges it to the workspace so its export stops showing as pending.",
|
|
1714
|
+
"init writes the suggested 100 actions per 60 seconds, monitor-only (nothing is limited).",
|
|
1715
|
+
"Enforcing needs the mode set to enforce and an acknowledgement of the exact policy (ack).",
|
|
1716
|
+
"Counters persist across hook processes and restarts. They count dispatched parent actions on",
|
|
1717
|
+
"THIS installation only; a limit shared across installations needs a shared in-path gateway,",
|
|
1718
|
+
"which an independent hook is not, so it refuses to enforce one.",
|
|
1719
|
+
] },
|
|
1720
|
+
{ name: "delegation", args: "add <file>|list|revoke <id>|import <file>",
|
|
1721
|
+
summary: "delegated child scopes: a child may do no more than its parent and never outlives it",
|
|
1722
|
+
detail: [
|
|
1723
|
+
"A session runs under one with SCOPEBOND_DELEGATION=<id>. Every action is checked against it,",
|
|
1724
|
+
"and against every ancestor, for scope, expiry and revocation. revoke takes effect on the next action.",
|
|
1725
|
+
"import adds revoked ids from a file exported from your workspace (add-only).",
|
|
1726
|
+
] },
|
|
1180
1727
|
{ name: "doctor", summary: "check the setup and whether each configured hook command can start",
|
|
1181
1728
|
detail: ["Exits non-zero when something is wrong, so it works in a script."] },
|
|
1182
1729
|
{ name: "log", args: "[-n N] [--deny] [--since 7d]",
|
|
@@ -1299,6 +1846,31 @@ else if (cmd === "status") {
|
|
|
1299
1846
|
else if (cmd === "doctor") {
|
|
1300
1847
|
await runDoctor();
|
|
1301
1848
|
}
|
|
1849
|
+
else if (cmd === "capabilities") {
|
|
1850
|
+
await runCapabilities(rest);
|
|
1851
|
+
}
|
|
1852
|
+
else if (cmd === "observations") {
|
|
1853
|
+
await runObservations(rest);
|
|
1854
|
+
}
|
|
1855
|
+
else if (cmd === "policy") {
|
|
1856
|
+
await runPolicy(rest);
|
|
1857
|
+
}
|
|
1858
|
+
else if (cmd === "budget" && rest[0] === "load") {
|
|
1859
|
+
await runBudgetLoad(rest.slice(1));
|
|
1860
|
+
}
|
|
1861
|
+
else if (cmd === "budget" || cmd === "delegation") {
|
|
1862
|
+
// These change what the agent may do, so they are for a person at a terminal.
|
|
1863
|
+
if (rest[0] !== "status" && rest[0] !== "list")
|
|
1864
|
+
requireInteractive(cmd, rest);
|
|
1865
|
+
const dir = resolveConfigDir(process.cwd());
|
|
1866
|
+
const kid = (() => { try {
|
|
1867
|
+
return createSigner({ privateKeyPem: readFileSync(join(dir, "agent.key"), "utf8") }).kid;
|
|
1868
|
+
}
|
|
1869
|
+
catch {
|
|
1870
|
+
return "";
|
|
1871
|
+
} })();
|
|
1872
|
+
process.exit(runDispatchCommand(cmd, rest, dir, kid));
|
|
1873
|
+
}
|
|
1302
1874
|
else if (cmd === "uninstall") {
|
|
1303
1875
|
runUninstall(rest);
|
|
1304
1876
|
}
|