@scopebond/hook 0.9.0 → 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 +29 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +67 -5
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.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/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/runtime.d.ts +3 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +3 -0
- package/dist/runtime.js.map +1 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -216,6 +216,35 @@ locally and retried if the workspace is unreachable. `npx @scopebond/hook flush`
|
|
|
216
216
|
delivers anything still queued — run it on a session-end hook (and set
|
|
217
217
|
`SCOPEBOND_HOOK_FLUSH_MS=0`) if you want zero per-call latency.
|
|
218
218
|
|
|
219
|
+
### Rules set by your workspace
|
|
220
|
+
|
|
221
|
+
A connected computer keeps its own rules (`.scopebond/rules.json`) until someone who manages
|
|
222
|
+
the workspace changes a rule for it there. From then on the workspace decides, rule by rule,
|
|
223
|
+
whether a matching action is **blocked** or only **recorded**, and can add entries to this
|
|
224
|
+
computer's lists (protected branches, programs, allowed sites). The workspace never sends
|
|
225
|
+
patterns: the hook compiles its choices with the same compiler as `rules apply`.
|
|
226
|
+
|
|
227
|
+
- **When it applies.** At most once every five minutes, a tool call also checks for changes,
|
|
228
|
+
alongside sending its activity record and capped at about one and a half seconds
|
|
229
|
+
(`SCOPEBOND_POLICY_SYNC_MS`); every other call only reads two small files. A change therefore
|
|
230
|
+
applies within a few minutes of the agent's next action, and a workspace that is slow or
|
|
231
|
+
unreachable never holds up the agent for longer than the cap. The new rules govern from the
|
|
232
|
+
next action. `npx @scopebond/hook policy sync` checks right now.
|
|
233
|
+
- **What is checked.** A rules document must be complete, issued for this computer, match its
|
|
234
|
+
digest and be newer than the one in force; the resulting policy must load. Anything else is
|
|
235
|
+
refused, the rules already in force stay, and the refusal is reported to the workspace.
|
|
236
|
+
- **What is confirmed.** After loading, the hook tells the workspace exactly which version it
|
|
237
|
+
loaded, so the workspace shows *Applied* only for computers that confirmed it.
|
|
238
|
+
- **What the workspace cannot change.** Protection of Scopebond's own settings and of the
|
|
239
|
+
agents' hook settings, the machine key policy, fail-closed handling of anything unreadable,
|
|
240
|
+
and this computer's own opt-ins (`allowed_roots`, `protect_remote_database`).
|
|
241
|
+
- **Going back.** While the workspace sets the rules, `rules` edits and `policy load` are
|
|
242
|
+
refused here. If the connection is revoked, or the workspace stops setting rules for this
|
|
243
|
+
computer, the hook recompiles `policy.json` from `rules.json`: a computer is never left
|
|
244
|
+
without rules. `status` shows which rules are in force and when they were last checked.
|
|
245
|
+
|
|
246
|
+
Set `SCOPEBOND_POLICY_SYNC=off` to stop the five-minute check (the rules in force stay).
|
|
247
|
+
|
|
219
248
|
### Session, health and action observations (opt-in)
|
|
220
249
|
|
|
221
250
|
Beyond receipts, the hook can send a second kind of signed record, an *observation*,
|
package/dist/cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAgqBA,6EAA6E;AAC7E,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,EAAE,GAAG,GAAE,MAAmB,GAAG,MAAM,GAAG,IAAI,CAS7F"}
|
package/dist/cli.js
CHANGED
|
@@ -35,13 +35,15 @@ import { mapClaudeToolUse, mapCodexToolUse, mapCursorEvent, fillPushBranch } fro
|
|
|
35
35
|
import { createHookRuntime } from "./runtime.js";
|
|
36
36
|
import { useDigestKey, loadOrCreateDigestKey } from "./minimize.js";
|
|
37
37
|
import { scaffold, harnessSnippet, placeHook } from "./init.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";
|
|
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
39
|
import { openObservations, describeObservations, observationStatus, stopReasonFromClaude, exitFromClaudeFailure, HEARTBEAT_INTERVAL_MS, OBSERVATIONS_SCOPE, } from "./obs-emitter.js";
|
|
40
40
|
import { OBSERVATION_DB, ObservationStore } from "./obs-store.js";
|
|
41
41
|
import { loadOrCreateBindingKey } from "./observation.js";
|
|
42
42
|
import { uploadPending } from "./obs-upload.js";
|
|
43
43
|
import { connectCloud, loadConnection } from "./cloud.js";
|
|
44
|
-
import { loadPolicyExport } from "./policy-load.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";
|
|
45
47
|
import { loadBudgetExport } from "./budget-load.js";
|
|
46
48
|
import { compile, defaultRules, describeRules, loadRules, saveRules, rulesPath, pathRuleFor } from "./rules.js";
|
|
47
49
|
import { createSigner } from "@scopebond/sdk";
|
|
@@ -215,7 +217,8 @@ async function runPreToolUse(mapper, deny = denyClaude, raw, harness = "claude")
|
|
|
215
217
|
useDigestKey(loadOrCreateDigestKey(dir));
|
|
216
218
|
const decision = await runtime.evaluate([...fillPushBranch(mapper(input), currentBranch(cwd)), ...databaseGuard(dir, cwd, input)], { groupKey: callId(input) });
|
|
217
219
|
const observer = recordObservations(dir, cwd, input, decision, harness);
|
|
218
|
-
|
|
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))]);
|
|
219
222
|
observer?.close();
|
|
220
223
|
// Close before deciding: the receipt is already committed, and leaving the handle
|
|
221
224
|
// open is what made the write-ahead log grow without bound.
|
|
@@ -326,7 +329,7 @@ async function runCursor() {
|
|
|
326
329
|
const mapped = fillPushBranch(mapCursorEvent(event, input), currentBranch(cwd));
|
|
327
330
|
const decision = await runtime.evaluate(mapped, { groupKey: callId(input) });
|
|
328
331
|
const observer = recordObservations(dir, cwd, input, decision, "cursor");
|
|
329
|
-
await Promise.all([runtime.flush(), observer?.flush() ?? Promise.resolve()]);
|
|
332
|
+
await Promise.all([runtime.flush(), observer?.flush() ?? Promise.resolve(), syncIfDue(dir, () => syncOptionsFor(dir))]);
|
|
330
333
|
observer?.close();
|
|
331
334
|
// An `afterFileEdit` violation is real and recorded, but the edit has already
|
|
332
335
|
// landed. Say so rather than letting "blocked" imply it was stopped.
|
|
@@ -677,6 +680,11 @@ function runRules(args) {
|
|
|
677
680
|
printHelp("rules", true);
|
|
678
681
|
process.exit(1);
|
|
679
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
|
+
}
|
|
680
688
|
// Changing what governs the agent is the same class of action as `init`.
|
|
681
689
|
requireInteractive("rules", args);
|
|
682
690
|
const policyPath = join(dir, "policy.json");
|
|
@@ -970,12 +978,21 @@ async function runFlush() {
|
|
|
970
978
|
* `--yes`, make it the active policy here; then acknowledge it (or its refusal) to the workspace. */
|
|
971
979
|
async function runPolicy(args) {
|
|
972
980
|
const [sub, file] = args;
|
|
981
|
+
if (sub === "sync") {
|
|
982
|
+
await runPolicySync(args.includes("--background"));
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
973
985
|
if (sub !== "load" || !file) {
|
|
974
|
-
console.error(`usage: ${cliCommand("policy load <export.json> [--yes]")}`);
|
|
986
|
+
console.error(`usage: ${cliCommand("policy sync")} | ${cliCommand("policy load <export.json> [--yes]")}`);
|
|
975
987
|
process.exitCode = 2;
|
|
976
988
|
return;
|
|
977
989
|
}
|
|
978
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
|
+
}
|
|
979
996
|
const apply = args.includes("--yes");
|
|
980
997
|
const connection = loadConnection(dir);
|
|
981
998
|
const outcome = loadPolicyExport(dir, file, { apply, environmentId: connection?.environment_id });
|
|
@@ -1002,6 +1019,41 @@ async function runPolicy(args) {
|
|
|
1002
1019
|
return;
|
|
1003
1020
|
await sendPolicyAck(dir, ack);
|
|
1004
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
|
+
}
|
|
1005
1057
|
/** Queue a `policy_ack` (loaded or rejected) through the observation outbox and try to deliver it now. */
|
|
1006
1058
|
async function sendPolicyAck(dir, ack) {
|
|
1007
1059
|
const observed = openObservations(dir, { adapterVersion: hookVersion(), spawnHeartbeat: false });
|
|
@@ -1299,6 +1351,16 @@ function runStatus() {
|
|
|
1299
1351
|
console.log(` Cursor ${harnessScopeLabel(cursor) || (cursorDetected() ? "detected, not configured" : "not detected")}`);
|
|
1300
1352
|
console.log(` Codex ${codex.project || codex.user ? `${harnessScopeLabel(codex)} — approve once with /hooks` : codexDetected() ? "detected, not configured" : "not detected"}`);
|
|
1301
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
|
+
}
|
|
1302
1364
|
const observationLines = describeObservations(resolveConfigDir(process.cwd()));
|
|
1303
1365
|
console.log(` observations ${observationLines[0]}`);
|
|
1304
1366
|
for (const line of observationLines.slice(1))
|