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/CHANGELOG.md +33 -0
- package/README.es.md +192 -129
- package/README.fr.md +200 -137
- package/README.hi.md +197 -134
- package/README.it.md +193 -130
- package/README.ja.md +198 -135
- package/README.md +13 -18
- package/README.pt-BR.md +195 -132
- package/README.zh.md +201 -141
- package/package.json +1 -1
- package/src/hooks.mjs +125 -14
- package/src/specialist/capability-gate.mjs +124 -0
- package/src/specialist/conformance-consult.mjs +322 -0
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|