role-os 2.7.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/hooks.mjs CHANGED
@@ -21,6 +21,8 @@
21
21
 
22
22
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
23
23
  import { join } from "node:path";
24
+ import { schemaFloor, contractFloor } from "./specialist/conformance-consult.mjs";
25
+ import { capabilityGate } from "./specialist/capability-gate.mjs";
24
26
 
25
27
  // ── Hook script generators ────────────────────────────────────────────────────
26
28
 
@@ -188,14 +190,57 @@ export function onPromptSubmit(input) {
188
190
  return {};
189
191
  }
190
192
 
193
+ const TOOL_CONTRACTS_FILE = ".claude/role-os/tool-contracts.json";
194
+
195
+ /**
196
+ * Load the Tool-Call Conformance contract catalog from the repo (the rollout's per-tool knowledge base):
197
+ * { "<tool_name>": { contract, params, constraints, state_struct? } }. Fail-safe — a missing or malformed
198
+ * file yields {} (conformance simply does not run), so this never breaks a tool call.
199
+ * @param {string} cwd
200
+ * @returns {Record<string, object>}
201
+ */
202
+ export function loadToolContracts(cwd) {
203
+ try {
204
+ const p = join(cwd, TOOL_CONTRACTS_FILE);
205
+ if (!existsSync(p)) return {};
206
+ return JSON.parse(readFileSync(p, "utf-8")) || {};
207
+ } catch {
208
+ return {};
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Deterministic conformance ADVISORY for a tool call — wedge #1's live seam. Runs the schema floor
214
+ * (L1-L3) + the computable contract floor (L4) against the catalogued tool. Returns an advisory string
215
+ * only when the floor PROVES a violation; otherwise null. Never throws, never denies — the watcher is
216
+ * advisory + fail-open by design (a false "conformant" is the costly error, never a blocked good call).
217
+ */
218
+ export function conformanceAdvisory(cwd, toolName, toolInput, opts = {}) {
219
+ try {
220
+ const catalog = opts.toolContracts || loadToolContracts(cwd);
221
+ const entry = catalog && catalog[toolName];
222
+ if (!entry) return null;
223
+ const tool = { name: toolName, contract: entry.contract, params: entry.params || [], constraints: entry.constraints || [] };
224
+ const call = toolInput && typeof toolInput === "object" ? toolInput : {};
225
+ const v = [...schemaFloor(tool, call).violations, ...contractFloor(tool, call, entry.state_struct || null).violations];
226
+ if (!v.length) return null;
227
+ return `Tool-Call Conformance (advisory): the "${toolName}" call appears NONCONFORMANT — ${v.join("; ")}. The deterministic floor proved this; review before relying on the result.`;
228
+ } catch {
229
+ return null; // a hook must never break a tool call
230
+ }
231
+ }
232
+
191
233
  /**
192
234
  * PreToolUse hook logic.
193
- * Checks tool usage against active role envelope.
235
+ * Records tool usage, flags write tools without a route card, and (wedge #1) attaches an ADVISORY
236
+ * Tool-Call Conformance verdict from the deterministic floor when the tool is catalogued. Advisory only
237
+ * — it never denies a call.
194
238
  *
195
239
  * @param {object} input - { tool_name, tool_input, session_id, cwd }
240
+ * @param {object} [opts] - { toolContracts } to inject a catalog (tests); defaults to loadToolContracts(cwd)
196
241
  * @returns {{ allow?: boolean, deny?: { reason: string }, addContext?: string }}
197
242
  */
198
- export function onPreToolUse(input) {
243
+ export function onPreToolUse(input, opts = {}) {
199
244
  const cwd = input.cwd || process.cwd();
200
245
  const state = getSessionState(cwd);
201
246
  const toolName = input.tool_name || "";
@@ -206,23 +251,33 @@ export function onPreToolUse(input) {
206
251
  saveSessionState(cwd, state);
207
252
  }
208
253
 
254
+ // Capability gate (opt-in via ROLEOS_CAPABILITY_GATE, fail-closed): an irreversible action without
255
+ // a granted capability is DENIED — bounds what a wrong verdict (honest or adversarial) can DO
256
+ // (POLA / CaMeL). Default OFF => pure no-op. Distinct from the advisory conformance floor below.
257
+ const cap = capabilityGate(cwd, toolName, input.tool_input, opts);
258
+ if (cap.denied) return { deny: { reason: cap.reason } };
259
+
260
+ const notes = [];
261
+
209
262
  // Advisory: flag write tools without route card after substantial prompts
210
263
  const writeTools = ["Bash", "Write", "Edit", "NotebookEdit"];
211
264
  if (writeTools.includes(toolName) && !state.routeCardPresent && (state.substantivePrompts || 0) >= 2) {
212
- return {
213
- addContext: `Write tool "${toolName}" used without a route card. If this is substantial work, consider routing first.`,
214
- };
265
+ notes.push(`Write tool "${toolName}" used without a route card. If this is substantial work, consider routing first.`);
215
266
  }
216
267
 
268
+ // Wedge #1: deterministic conformance floor (advisory, fail-open) on the proposed call
269
+ const conf = conformanceAdvisory(cwd, toolName, input.tool_input, opts);
270
+ if (conf) notes.push(conf);
271
+
217
272
  // If a role is active, read-only tools are always fine
218
273
  if (state.activeRole && state.activePack) {
219
274
  const readOnlyTools = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
220
275
  if (readOnlyTools.includes(toolName)) {
221
- return { allow: true };
276
+ return notes.length ? { allow: true, addContext: notes.join(" ") } : { allow: true };
222
277
  }
223
278
  }
224
279
 
225
- return {};
280
+ return notes.length ? { addContext: notes.join(" ") } : {};
226
281
  }
227
282
 
228
283
  /**
@@ -343,10 +398,15 @@ writeFileSync(join(stateDir, "session-state.json"), JSON.stringify(state, null,
343
398
 
344
399
  const hasRoleOs = existsSync(join(cwd, ".claude", "agents"));
345
400
  if (hasRoleOs) {
401
+ // SessionStart wire protocol (current Claude Code): hookSpecificOutput.additionalContext + exit 0.
346
402
  console.log(JSON.stringify({
347
- addContext: "Role OS is active. For non-trivial tasks, run /roleos-route first.",
403
+ hookSpecificOutput: {
404
+ hookEventName: "SessionStart",
405
+ additionalContext: "Role OS is active. For non-trivial tasks, run /roleos-route first.",
406
+ },
348
407
  }));
349
408
  }
409
+ process.exit(0);
350
410
  `;
351
411
  }
352
412
 
@@ -376,16 +436,21 @@ if (isSubstantial) state.substantivePrompts = (state.substantivePrompts || 0) +
376
436
  writeFileSync(statePath, JSON.stringify(state, null, 2));
377
437
 
378
438
  if (isSubstantial && (state.substantivePrompts || 0) >= 2 && !state.routeCardPresent) {
439
+ // UserPromptSubmit wire protocol (current Claude Code): hookSpecificOutput.additionalContext + exit 0.
379
440
  console.log(JSON.stringify({
380
- addContext: "No Role OS route card yet. Consider /roleos-route to classify this task.",
441
+ hookSpecificOutput: {
442
+ hookEventName: "UserPromptSubmit",
443
+ additionalContext: "No Role OS route card yet. Consider /roleos-route to classify this task.",
444
+ },
381
445
  }));
382
446
  }
447
+ process.exit(0);
383
448
  `;
384
449
  }
385
450
 
386
451
  function generatePreToolUseScript() {
387
452
  return `#!/usr/bin/env node
388
- // Role OS PreToolUse hook — enforce role-specific tool law
453
+ // Role OS PreToolUse hook — enforce role-specific tool law + Tool-Call Conformance floor (advisory)
389
454
  import { readFileSync, writeFileSync, existsSync } from "node:fs";
390
455
  import { join } from "node:path";
391
456
 
@@ -405,13 +470,48 @@ if (!state.toolsUsed.includes(toolName)) {
405
470
  writeFileSync(statePath, JSON.stringify(state, null, 2));
406
471
  }
407
472
 
408
- // Advisory: flag write tools without route card
473
+ const notes = [];
409
474
  const writeTools = ["Bash", "Write", "Edit", "NotebookEdit"];
410
475
  if (writeTools.includes(toolName) && !state.routeCardPresent && (state.substantivePrompts || 0) >= 2) {
476
+ notes.push(\`Write tool "\${toolName}" used without route card. Consider /roleos-route.\`);
477
+ }
478
+
479
+ // Tool-Call Conformance floor (advisory, deterministic). Best-effort: runs only when this tool has a
480
+ // .claude/role-os/tool-contracts.json catalog entry AND the role-os library is resolvable. ANY failure
481
+ // is a silent no-op — a hook must never break a tool call; the watcher is advisory + fail-open.
482
+ try {
483
+ const catPath = join(cwd, ".claude", "role-os", "tool-contracts.json");
484
+ if (existsSync(catPath)) {
485
+ const catalog = JSON.parse(readFileSync(catPath, "utf-8"));
486
+ const entry = catalog && catalog[toolName];
487
+ if (entry) {
488
+ const { schemaFloor, contractFloor } = await import("role-os/src/specialist/conformance-consult.mjs");
489
+ const tool = { name: toolName, contract: entry.contract, params: entry.params || [], constraints: entry.constraints || [] };
490
+ const call = (input.tool_input && typeof input.tool_input === "object") ? input.tool_input : {};
491
+ const v = [...schemaFloor(tool, call).violations, ...contractFloor(tool, call, entry.state_struct || null).violations];
492
+ if (v.length) notes.push(\`Tool-Call Conformance (advisory): "\${toolName}" appears NONCONFORMANT — \${v.join("; ")}.\`);
493
+ }
494
+ }
495
+ } catch { /* role-os not resolvable here, or internal error -> no-op (never block a tool call) */ }
496
+
497
+ // Capability gate (opt-in via ROLEOS_CAPABILITY_GATE, FAIL-CLOSED): an irreversible action without a
498
+ // granted capability is DENIED (exit 2 blocks). Default OFF => no-op. Bounds what a wrong verdict can
499
+ // DO (POLA / CaMeL). Best-effort: if role-os is not resolvable here a hook-resolution failure must not
500
+ // itself block a call; the in-process onPreToolUse path still enforces it where role-os is resolvable.
501
+ try {
502
+ const { capabilityGate } = await import("role-os/src/specialist/capability-gate.mjs");
503
+ const cap = capabilityGate(cwd, toolName, input.tool_input || {});
504
+ if (cap.denied) { process.stderr.write(cap.reason + "\\n"); process.exit(2); }
505
+ } catch { /* role-os not resolvable / internal error -> no-op (in-process path enforces) */ }
506
+
507
+ // PreToolUse wire protocol (current Claude Code): inject advisory context via
508
+ // hookSpecificOutput.additionalContext + exit 0. A bare { addContext } is IGNORED; exit 2 would BLOCK.
509
+ if (notes.length) {
411
510
  console.log(JSON.stringify({
412
- addContext: \`Write tool "\${toolName}" used without route card. Consider /roleos-route.\`,
511
+ hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: notes.join(" ") },
413
512
  }));
414
513
  }
514
+ process.exit(0);
415
515
  `;
416
516
  }
417
517
 
@@ -431,10 +531,15 @@ if (existsSync(statePath)) {
431
531
  }
432
532
 
433
533
  if (state.activeRole) {
534
+ // SubagentStart wire protocol (current Claude Code): hookSpecificOutput.additionalContext + exit 0.
434
535
  console.log(JSON.stringify({
435
- addContext: \`Role OS active. Role: \${state.activeRole}. Pack: \${state.activePack || "free routing"}. Follow role contract.\`,
536
+ hookSpecificOutput: {
537
+ hookEventName: "SubagentStart",
538
+ additionalContext: \`Role OS active. Role: \${state.activeRole}. Pack: \${state.activePack || "free routing"}. Follow role contract.\`,
539
+ },
436
540
  }));
437
541
  }
542
+ process.exit(0);
438
543
  `;
439
544
  }
440
545
 
@@ -461,9 +566,15 @@ if (!state.routeCardPresent) warnings.push("No route card produced.");
461
566
  if (!state.outcomeRecorded) warnings.push("No outcome artifact recorded.");
462
567
 
463
568
  if (warnings.length > 0) {
569
+ // Stop wire protocol (current Claude Code): advisory context via hookSpecificOutput.additionalContext
570
+ // + exit 0 lets the session end with a note. { decision: "block" } would PREVENT stopping (not wanted here).
464
571
  console.log(JSON.stringify({
465
- addContext: \`Role OS audit: \${warnings.join(" ")} Consider documenting the outcome.\`,
572
+ hookSpecificOutput: {
573
+ hookEventName: "Stop",
574
+ additionalContext: \`Role OS audit: \${warnings.join(" ")} Consider documenting the outcome.\`,
575
+ },
466
576
  }));
467
577
  }
578
+ process.exit(0);
468
579
  `;
469
580
  }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Capability gate — deterministic least-privilege (POLA) on IRREVERSIBLE tool calls. The one
3
+ * security primitive worth owning internally (memory: oversight-specialist-mint-strategy.md,
4
+ * 2026-06-08 posture): it bounds what any agent action can DO, so a WRONG verdict — an honest crew
5
+ * mistake OR (later) an adversarial flip — can never trigger an unauthorized irreversible action.
6
+ * The PREVENTIVE complement to NAMED_COMPENSATORS (which undoes an irreversible action; this stops
7
+ * the unauthorized one before it happens). Same action set, two halves.
8
+ *
9
+ * Grounded in the object-capability model / POLA and CaMeL (Debenedetti et al. 2025,
10
+ * arXiv:2503.18813: control/data-flow separation + capabilities, model UNMODIFIED). Deterministic,
11
+ * NO model — least-privilege, not an arms race.
12
+ *
13
+ * Safe by construction:
14
+ * - OPT-IN, default OFF (ROLEOS_CAPABILITY_GATE). Disabled => never denies (pure no-op), so it can
15
+ * never disrupt an existing flow until the director turns it on.
16
+ * - Scoped to a SMALL gated set (the NAMED_COMPENSATORS irreversible-action list). Every non-gated
17
+ * tool and every read-only tool is untouched.
18
+ * - FAIL-CLOSED for the gated set: a gated action with NO matching capability grant is denied, with
19
+ * a reason telling the director how to grant it. (Distinct from the conformance floor, which is
20
+ * advisory / fail-open — a missed nonconformance is cheap; an unauthorized irreversible action is
21
+ * not, so its asymmetry runs the other way.)
22
+ *
23
+ * The grant manifest (`.claude/role-os/capabilities.json`, director-authored) maps an action id to a
24
+ * grant, e.g.: { "npm:publish": { "granted": true, "scope": "@mcptoolshop/roll", "expires": "2026-07-01" } }
25
+ */
26
+
27
+ import { existsSync, readFileSync } from "node:fs";
28
+ import { join } from "node:path";
29
+
30
+ export const CAPABILITIES_FILE = ".claude/role-os/capabilities.json";
31
+
32
+ /** Opt-in flag, mirroring conformanceConsultEnabled(). Default OFF. */
33
+ export function capabilityGateEnabled() {
34
+ const v = process.env.ROLEOS_CAPABILITY_GATE;
35
+ return v === "1" || v === "true";
36
+ }
37
+
38
+ const _bash = (re) => (toolName, call) =>
39
+ toolName === "Bash" && typeof call?.command === "string" && re.test(call.command);
40
+
41
+ /**
42
+ * The GATED SET — the irreversible / world-touching actions from the NAMED_COMPENSATORS standard.
43
+ * Each entry: { id, label, test(toolName, call) -> boolean }. Detection is deterministic + pattern-
44
+ * based and errs toward FLAGGING (a benign match just needs a one-line grant), never toward missing
45
+ * an irreversible action.
46
+ */
47
+ export const GATED_ACTIONS = [
48
+ { id: "npm:publish", label: "npm/pnpm/yarn publish", test: _bash(/\b(?:npm|pnpm|yarn)\s+publish\b/) },
49
+ { id: "pypi:publish", label: "PyPI publish (twine/uv)", test: _bash(/\btwine\s+upload\b|\buv\s+publish\b/) },
50
+ { id: "gh:release", label: "gh release create", test: _bash(/\bgh\s+release\s+create\b/) },
51
+ { id: "gh:pr-create", label: "gh pr create", test: _bash(/\bgh\s+pr\s+create\b/) },
52
+ { id: "gh:repo-edit", label: "gh repo edit/delete", test: _bash(/\bgh\s+repo\s+(?:edit|delete)\b/) },
53
+ { id: "git:push", label: "git push", test: _bash(/\bgit\s+push\b/) },
54
+ { id: "pages:deploy", label: "GitHub Pages / gh-pages deploy", test: _bash(/\bgh-pages\b|\bpages\s+deploy\b/) },
55
+ ];
56
+
57
+ /** Read the director's capability manifest, or {} if absent/malformed (=> nothing granted). */
58
+ export function loadCapabilities(cwd) {
59
+ try {
60
+ const p = join(cwd, CAPABILITIES_FILE);
61
+ if (!existsSync(p)) return {};
62
+ const data = JSON.parse(readFileSync(p, "utf-8"));
63
+ return data && typeof data === "object" ? data : {};
64
+ } catch {
65
+ return {};
66
+ }
67
+ }
68
+
69
+ /** Is `actionId` granted (granted:true and not expired) in the manifest? */
70
+ function _granted(manifest, actionId, now) {
71
+ const g = manifest && manifest[actionId];
72
+ if (!g || typeof g !== "object" || g.granted !== true) return false;
73
+ if (typeof g.expires === "string") {
74
+ const t = Date.parse(g.expires);
75
+ if (!Number.isNaN(t) && t < now) return false; // grant expired
76
+ }
77
+ return true;
78
+ }
79
+
80
+ /**
81
+ * Capability gate for a proposed tool call.
82
+ *
83
+ * OPT-IN: when disabled it ALWAYS returns { denied:false } (pure no-op). When enabled, a gated
84
+ * irreversible action is allowed ONLY if the manifest grants its capability id; otherwise it is
85
+ * DENIED (fail-closed) with a reason telling the director how to grant it.
86
+ *
87
+ * @param {string} cwd
88
+ * @param {string} toolName
89
+ * @param {object} toolInput
90
+ * @param {object} [opts] - { force } run the gate regardless of the env flag (tests);
91
+ * { capabilities } inject a manifest (tests); { now } epoch ms for expiry (tests)
92
+ * @returns {{ denied: boolean, action?: string, reason?: string }}
93
+ */
94
+ export function capabilityGate(cwd, toolName, toolInput, opts = {}) {
95
+ if (!opts.force && !capabilityGateEnabled()) return { denied: false };
96
+ let action;
97
+ try {
98
+ const call = toolInput && typeof toolInput === "object" ? toolInput : {};
99
+ action = GATED_ACTIONS.find((a) => a.test(toolName, call));
100
+ if (!action) return { denied: false }; // not an irreversible action -> allow
101
+ const manifest = opts.capabilities || loadCapabilities(cwd);
102
+ const now = typeof opts.now === "number" ? opts.now : Date.now();
103
+ if (_granted(manifest, action.id, now)) return { denied: false };
104
+ return {
105
+ denied: true,
106
+ action: action.id,
107
+ reason:
108
+ `Capability gate: "${action.label}" is an irreversible action requiring an explicit grant. ` +
109
+ `No capability "${action.id}" is granted in ${CAPABILITIES_FILE}. To authorize it, the ` +
110
+ `director adds {"${action.id}": {"granted": true}} (optionally with "scope"/"expires").`,
111
+ };
112
+ } catch {
113
+ // A gate that errors must not silently allow an irreversible action: if a gated action matched
114
+ // but the grant cannot be evaluated, fail CLOSED (deny). If nothing matched, allow.
115
+ if (action) {
116
+ return {
117
+ denied: true,
118
+ action: action.id,
119
+ reason: `Capability gate errored evaluating the grant for "${action.id}"; failing closed on an irreversible action.`,
120
+ };
121
+ }
122
+ return { denied: false };
123
+ }
124
+ }