protect-mcp 0.9.5 → 0.9.7

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 CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.6: policy you can see and change
4
+
5
+ The gate is default-deny and fail-closed, but a deny used to be a dead end: an
6
+ empty reason and no pointer to the file that decides. This release makes the
7
+ policy legible and editable from the terminal, and lets a running gate pick up
8
+ a policy change without a restart.
9
+
10
+ - **`policy` command.** `policy list` shows every tool named in the policy
11
+ (permit / forbid / default-deny) cross-referenced with how often the gate
12
+ actually allowed or denied it; `policy show` prints the active policy and its
13
+ digest; `policy allow <tool>` / `policy deny <tool>` append a Cedar rule and
14
+ print the digest change; `policy path` prints the file. Idempotent and
15
+ tool-name validated.
16
+ - **Denies that teach.** A blocked call now says whether it was default-deny
17
+ (no permit matched) or an explicit forbid, names the policy directory, and
18
+ gives the exact fix: `npx protect-mcp policy allow <tool>`.
19
+ - **Hot reload.** `protect-mcp serve` watches the .cedar files and reloads the
20
+ policy on an on-disk change, so `policy allow/deny` (or a hand edit) takes
21
+ effect without restarting the gate. Fail-closed: an unparseable edit keeps the
22
+ previous policy rather than opening the gate, and the reload is logged.
23
+
3
24
  ## 0.9.5: replayable from scratch
4
25
 
5
26
  The public demo film (legate.scopeblind.com/record) is now reproducible by
package/README.md CHANGED
@@ -57,6 +57,36 @@ The dashboard binds to `127.0.0.1`, reads only local log/receipt files, and does
57
57
  not upload anything. Use `npx protect-mcp connect` only if you explicitly want a
58
58
  hosted ScopeBlind dashboard.
59
59
 
60
+ ## The gate as an MCP server
61
+
62
+ If you would rather call the gate as tools than wire the Claude Code hooks, run
63
+ it as an MCP server:
64
+
65
+ ```bash
66
+ npx protect-mcp mcp
67
+ ```
68
+
69
+ It speaks MCP over stdio and exposes four read-only tools, the whole loop:
70
+
71
+ - **`evaluate_action`**: decide a proposed tool call against an inline Cedar policy, fail-closed (any policy error is DENY). Returns `{ allowed, decision, reason, policy_digest }`.
72
+ - **`sign_decision`**: turn a decision into an Ed25519 signed receipt (a denial signs a `gateway_restraint`, an allow a `decision_receipt`). Returns the receipt and its public key; generates an ephemeral key if you do not supply one.
73
+ - **`verify_receipt`**: verify a signed receipt offline against a public key. Returns `{ valid, error, type, kid, issuer }`.
74
+ - **`self_test`**: prove it, no inputs. A known-forbidden action is denied, then a signed receipt round-trips and a tampered copy fails.
75
+
76
+ Point any MCP host at it, for example Claude Desktop:
77
+
78
+ ```json
79
+ {
80
+ "mcpServers": {
81
+ "protect-mcp": { "command": "npx", "args": ["-y", "protect-mcp", "mcp"] }
82
+ }
83
+ }
84
+ ```
85
+
86
+ Receipts are byte-compatible with the ones the gate signs at runtime, so a
87
+ receipt minted here verifies with [`@veritasacta/verify`](https://www.npmjs.com/package/@veritasacta/verify)
88
+ and the browser verifier just the same.
89
+
60
90
  ### Local Action Dashboard
61
91
 
62
92
  `protect-mcp dashboard` is the operator view for moving from visibility to
@@ -187,6 +217,8 @@ reveals the shape, not the content.
187
217
 
188
218
  ## Try it in 60 seconds (no agent required)
189
219
 
220
+ [![Watch the two-minute demo film](https://legate.scopeblind.com/media/scopeblind-demo-poster.jpg)](https://legate.scopeblind.com/record)
221
+
190
222
  Watch the two-minute film at [legate.scopeblind.com/record](https://legate.scopeblind.com/record), then replay it against your own copy:
191
223
 
192
224
  ```bash
@@ -387,6 +419,7 @@ To report a vulnerability, see [SECURITY.md](./SECURITY.md).
387
419
  | `serve` | Start the HTTP hook server for Claude Code (port 9377). `--enforce` runs the restraint self-test first; `--cedar <dir>` and `--policy <path>` select the policy. |
388
420
  | `init` | Generate an Ed25519 keypair (`keys/gateway.json`), a config template, and a sample policy. |
389
421
  | `sample` | Seed a clearly-labeled sample record (8 decisions: one blocked call, two payments; kid `sample-demo`) plus a tampered copy, so `record`, `claim`, `verify-claim`, and `anchor-record` are replayable from scratch before wiring an agent. Refuses to touch an existing record; `--force` overrides. |
422
+ | `policy` | See and change the Cedar policy from the terminal: `policy list` (permit / forbid / default-deny per tool, with how often the gate allowed or denied it), `policy show`, `policy allow <tool>`, `policy deny <tool>`, `policy path`. A running `serve` hot-reloads on the change. |
390
423
  | `wrap` | Print a protected MCP command or patch Claude Desktop MCP servers. Dry-run by default; use `--write` to update Claude Desktop config. |
391
424
  | `dashboard` | Start a local-only dashboard on `127.0.0.1` showing tool inventory, risk, policy coverage, exact-action approvals, receipt chains, and audit export. |
392
425
  | `recommend` | Draft a reviewable JSON policy from observed local calls. Dry-run by default; use `--write` to create `protect-mcp.recommended.json`. |
@@ -5,22 +5,24 @@ import {
5
5
  ReceiptBuffer,
6
6
  buildActionReadback,
7
7
  checkRateLimit,
8
- evaluateCedar,
9
8
  getSignerInfo,
10
9
  getToolPolicy,
11
10
  initSigning,
12
- isCedarAvailable,
13
11
  isSigningEnabled,
14
- loadCedarPolicies,
15
12
  loadPolicy,
16
13
  parseRateLimit,
17
14
  signDecision
18
- } from "./chunk-XLJUZ4WO.mjs";
15
+ } from "./chunk-G6X763MH.mjs";
16
+ import {
17
+ evaluateCedar,
18
+ isCedarAvailable,
19
+ loadCedarPolicies
20
+ } from "./chunk-MWXDXYWH.mjs";
19
21
 
20
22
  // src/hook-server.ts
21
23
  import { createServer } from "http";
22
24
  import { createHash, randomUUID, randomBytes } from "crypto";
23
- import { appendFileSync, readFileSync, existsSync, readdirSync } from "fs";
25
+ import { appendFileSync, readFileSync, existsSync, readdirSync, statSync } from "fs";
24
26
  import { join } from "path";
25
27
 
26
28
  // src/scopeblind-bridge.ts
@@ -256,6 +258,7 @@ async function handlePreToolUse(input, state) {
256
258
  ...input.teamName && { team_name: input.teamName },
257
259
  ...input.agentType && { agent_type: input.agentType }
258
260
  };
261
+ maybeReloadCedar(state);
259
262
  if (state.cedarPolicies) {
260
263
  try {
261
264
  const cedarDecision = await evaluateCedar(state.cedarPolicies, {
@@ -293,10 +296,13 @@ async function handlePreToolUse(input, state) {
293
296
  sandbox_state: detectSandboxState(),
294
297
  plan_receipt_id: state.activePlanReceiptId || void 0
295
298
  });
299
+ const isDefaultDeny = !reason || reason === "cedar_deny" || /reason":\[\]/.test(reason);
300
+ const policyRef = state.cedarDir ? ` Policy: ${state.cedarDir}.` : "";
301
+ const howTo = isDefaultDeny ? ` No permit matched (default-deny, fail-closed).${policyRef} Allow it: npx protect-mcp policy allow ${toolName}` : ` Blocked by an explicit forbid rule.${policyRef} Review: npx protect-mcp policy show`;
296
302
  if (denyCount === 1) {
297
303
  process.stderr.write(
298
- `[PROTECT_MCP] No Cedar permit for "${toolName}" \u2014 suggest:
299
- ${suggestion}
304
+ `[PROTECT_MCP] Denied "${toolName}" (${isDefaultDeny ? "default-deny" : "forbid"}).
305
+ Allow with: npx protect-mcp policy allow ${toolName}
300
306
  `
301
307
  );
302
308
  }
@@ -304,7 +310,7 @@ async function handlePreToolUse(input, state) {
304
310
  hookSpecificOutput: {
305
311
  hookEventName: "PreToolUse",
306
312
  permissionDecision: "deny",
307
- permissionDecisionReason: `[ScopeBlind] Denied by Cedar policy. ${reason}. Forbidden: "${toolName}" is not permitted. Try a read-only alternative.` + (denyCount > 1 ? ` (attempt ${denyCount})` : "")
313
+ permissionDecisionReason: `[ScopeBlind] Denied "${toolName}".${howTo}` + (denyCount > 1 ? ` (attempt ${denyCount})` : "")
308
314
  }
309
315
  };
310
316
  }
@@ -803,6 +809,9 @@ async function startHookServer(options = {}) {
803
809
  }
804
810
  const state = {
805
811
  cedarPolicies,
812
+ cedarDir: cedarDir || null,
813
+ cedarMtimeMs: cedarDir ? newestCedarMtime(cedarDir) : 0,
814
+ cedarCheckedMs: 0,
806
815
  jsonPolicy,
807
816
  rateLimitStore: /* @__PURE__ */ new Map(),
808
817
  receiptBuffer: new ReceiptBuffer(),
@@ -993,6 +1002,40 @@ async function startHookServer(options = {}) {
993
1002
  process.on("SIGTERM", shutdown);
994
1003
  return server;
995
1004
  }
1005
+ function newestCedarMtime(dir) {
1006
+ try {
1007
+ let newest = 0;
1008
+ for (const f of readdirSync(dir)) {
1009
+ if (!f.endsWith(".cedar")) continue;
1010
+ const m = statSync(join(dir, f)).mtimeMs;
1011
+ if (m > newest) newest = m;
1012
+ }
1013
+ return newest;
1014
+ } catch {
1015
+ return 0;
1016
+ }
1017
+ }
1018
+ var CEDAR_CHECK_THROTTLE_MS = 2e3;
1019
+ function maybeReloadCedar(state) {
1020
+ if (!state.cedarDir) return;
1021
+ const nowMs = Date.now();
1022
+ if (nowMs - state.cedarCheckedMs < CEDAR_CHECK_THROTTLE_MS) return;
1023
+ state.cedarCheckedMs = nowMs;
1024
+ const m = newestCedarMtime(state.cedarDir);
1025
+ if (m <= state.cedarMtimeMs) return;
1026
+ try {
1027
+ const reloaded = loadCedarPolicies(state.cedarDir);
1028
+ state.cedarPolicies = reloaded;
1029
+ state.policyDigest = reloaded.digest;
1030
+ state.cedarMtimeMs = m;
1031
+ process.stderr.write(`[PROTECT_MCP] Cedar policy reloaded (digest: ${reloaded.digest}) after on-disk change.
1032
+ `);
1033
+ } catch (err) {
1034
+ process.stderr.write(`[PROTECT_MCP] Cedar reload failed, keeping the previous policy: ${err instanceof Error ? err.message : err}
1035
+ `);
1036
+ state.cedarMtimeMs = m;
1037
+ }
1038
+ }
996
1039
  function findCedarDir() {
997
1040
  for (const candidate of ["cedar", "policies", "."]) {
998
1041
  try {
@@ -236,213 +236,10 @@ function isSigningEnabled() {
236
236
  return signingConfigured && signingInitError === null && signerState !== null && artifactsModule !== null;
237
237
  }
238
238
 
239
- // src/cedar-evaluator.ts
240
- import { createHash as createHash2 } from "crypto";
241
- import { readFileSync as readFileSync3, readdirSync, existsSync as existsSync2 } from "fs";
242
- import { join, extname } from "path";
243
- var cedarWasm = null;
244
- var loadAttempted = false;
245
- async function ensureCedarWasm() {
246
- if (cedarWasm) return true;
247
- if (loadAttempted) return false;
248
- loadAttempted = true;
249
- try {
250
- const moduleName = "@cedar-policy/cedar-wasm";
251
- cedarWasm = await import(
252
- /* @vite-ignore */
253
- moduleName
254
- );
255
- return true;
256
- } catch {
257
- return false;
258
- }
259
- }
260
- function loadCedarPolicies(dirPath) {
261
- if (!existsSync2(dirPath)) {
262
- throw new Error(`Cedar policy directory not found: ${dirPath}`);
263
- }
264
- const entries = readdirSync(dirPath).filter((f) => extname(f) === ".cedar").sort();
265
- if (entries.length === 0) {
266
- throw new Error(`No .cedar files found in: ${dirPath}`);
267
- }
268
- const sources = [];
269
- for (const file of entries) {
270
- const content = readFileSync3(join(dirPath, file), "utf-8");
271
- sources.push(content);
272
- }
273
- const concatenated = sources.join("\n\n");
274
- const digest = createHash2("sha256").update(concatenated).digest("hex").slice(0, 16);
275
- return {
276
- source: concatenated,
277
- digest,
278
- fileCount: entries.length,
279
- files: entries
280
- };
281
- }
282
- function buildEntities(req) {
283
- const agentId = req.agentId || req.tier;
284
- return [
285
- {
286
- uid: { type: "Agent", id: agentId },
287
- attrs: {
288
- tier: req.tier,
289
- ...req.agentId ? { agent_id: req.agentId } : {}
290
- },
291
- parents: []
292
- },
293
- {
294
- uid: { type: "Tool", id: req.tool },
295
- attrs: {},
296
- parents: []
297
- }
298
- ];
299
- }
300
- function onEvalError(reason, failClosed, extra) {
301
- return {
302
- allowed: !failClosed,
303
- reason: failClosed ? reason : `${reason} (observe mode; would DENY under enforcement)`,
304
- metadata: { error: true, fail_closed: failClosed, would_deny: true, ...extra || {} }
305
- };
306
- }
307
- async function evaluateCedar(policySet, req, schema, options) {
308
- const failClosed = options?.failClosed ?? true;
309
- const available = await ensureCedarWasm();
310
- if (!available) {
311
- return onEvalError("cedar_wasm_not_available", failClosed, { fallback: true });
312
- }
313
- try {
314
- const agentId = req.agentId || req.tier;
315
- const context = {
316
- tier: req.tier,
317
- ...req.context || {}
318
- };
319
- if (req.toolInput && Object.keys(req.toolInput).length > 0) {
320
- context.input = req.toolInput;
321
- }
322
- const authRequest = {
323
- principal: { type: "Agent", id: agentId },
324
- action: { type: "Action", id: "MCP::Tool::call" },
325
- resource: { type: "Tool", id: req.tool },
326
- context
327
- };
328
- const entities = buildEntities(req);
329
- const cedarSchema = schema?.schemaJson ?? null;
330
- let result;
331
- if (typeof cedarWasm.isAuthorized === "function") {
332
- result = cedarWasm.isAuthorized({
333
- policies: { staticPolicies: policySet.source },
334
- entities,
335
- principal: authRequest.principal,
336
- action: authRequest.action,
337
- resource: authRequest.resource,
338
- context: authRequest.context,
339
- schema: cedarSchema
340
- });
341
- } else if (typeof cedarWasm.checkAuthorization === "function") {
342
- result = cedarWasm.checkAuthorization(
343
- policySet.source,
344
- JSON.stringify(entities),
345
- JSON.stringify(authRequest)
346
- );
347
- } else {
348
- const cedarEngine = cedarWasm.default || cedarWasm;
349
- if (typeof cedarEngine.isAuthorized === "function") {
350
- result = cedarEngine.isAuthorized({
351
- policies: { staticPolicies: policySet.source },
352
- entities,
353
- principal: authRequest.principal,
354
- action: authRequest.action,
355
- resource: authRequest.resource,
356
- context: authRequest.context,
357
- schema: cedarSchema
358
- });
359
- } else {
360
- return onEvalError("cedar_wasm_api_unsupported", failClosed, { exports: Object.keys(cedarWasm) });
361
- }
362
- }
363
- const parsed = parseWasmResult(result);
364
- const policyErrors = extractPolicyErrors(result);
365
- if (parsed.kind === "error") {
366
- return onEvalError(`cedar_unparseable_result: ${parsed.diagnostics}`, failClosed);
367
- }
368
- if (policyErrors.length > 0) {
369
- return onEvalError(
370
- `cedar_policy_errored: ${policyErrors.length} policy error(s); decision is unsound`,
371
- failClosed,
372
- { policy_errors: policyErrors.slice(0, 5), policy_digest: policySet.digest }
373
- );
374
- }
375
- return {
376
- allowed: parsed.kind === "allow",
377
- reason: parsed.kind === "allow" ? void 0 : `cedar_deny${parsed.diagnostics ? ": " + parsed.diagnostics : ""}`,
378
- metadata: {
379
- policy_digest: policySet.digest,
380
- ...parsed.matchedPolicies ? { matched_policies: parsed.matchedPolicies } : {}
381
- }
382
- };
383
- } catch (err) {
384
- return onEvalError(`cedar_eval_error: ${err instanceof Error ? err.message : "unknown"}`, failClosed);
385
- }
386
- }
387
- function parseWasmResult(result) {
388
- if (!result) return { kind: "error", diagnostics: "null result from Cedar WASM" };
389
- if (result.type === "failure") {
390
- return { kind: "error", diagnostics: `cedar failure: ${JSON.stringify(result.errors ?? [])}` };
391
- }
392
- if (result.type === "success" && result.response) {
393
- const dec = result.response.decision;
394
- const reasons = result.response.diagnostics?.reason;
395
- if (dec === "allow" || dec === "Allow") return { kind: "allow", matchedPolicies: reasons };
396
- if (dec === "deny" || dec === "Deny") {
397
- return { kind: "deny", diagnostics: result.response.diagnostics ? JSON.stringify(result.response.diagnostics) : void 0, matchedPolicies: reasons };
398
- }
399
- }
400
- if (result.type === "allow" || result.decision === "Allow") return { kind: "allow" };
401
- if (result.type === "deny" || result.decision === "Deny") return { kind: "deny" };
402
- if (typeof result === "boolean") return result ? { kind: "allow" } : { kind: "deny" };
403
- return { kind: "error", diagnostics: `unknown result format: ${JSON.stringify(result)}` };
404
- }
405
- function extractPolicyErrors(result) {
406
- if (!result || typeof result !== "object") return [];
407
- const raw = result.errors ?? result.response?.diagnostics?.errors ?? result.diagnostics?.errors ?? [];
408
- if (!Array.isArray(raw)) return [];
409
- return raw.map((e) => typeof e === "string" ? e : e?.message ?? e?.error ?? JSON.stringify(e)).filter(Boolean);
410
- }
411
- async function isCedarAvailable() {
412
- return ensureCedarWasm();
413
- }
414
- function policySetFromSource(source, name = "inline") {
415
- const digest = createHash2("sha256").update(source).digest("hex").slice(0, 16);
416
- return { source, digest, fileCount: 1, files: [name] };
417
- }
418
- async function runEvaluatorSelfTest() {
419
- const wasmAvailable = await isCedarAvailable();
420
- const cases = [];
421
- const run = async (name, expected, policy, context) => {
422
- const d = await evaluateCedar(policy, { tool: "Bash", tier: "unknown", context }, void 0, { failClosed: true });
423
- const actual = d.allowed ? "ALLOW" : "DENY";
424
- cases.push({ name, expected, actual, pass: actual === expected, reason: d.reason });
425
- };
426
- if (!wasmAvailable) {
427
- await run("engine unavailable denies", "DENY", policySetFromSource("permit(principal, action, resource);"), {});
428
- return { wasmAvailable, passed: cases.every((c) => c.pass), cases };
429
- }
430
- const correct = policySetFromSource(
431
- 'forbid(principal, action, resource) when { ["rm", "dd", "mkfs"].contains(context.command) };\npermit(principal, action, resource);'
432
- );
433
- await run("forbid denies rm", "DENY", correct, { command: "rm" });
434
- await run("permit allows ls", "ALLOW", correct, { command: "ls" });
435
- const broken = policySetFromSource(
436
- 'forbid(principal, action, resource) when { context.command in ["rm", "dd"] };\npermit(principal, action, resource);'
437
- );
438
- await run("in-on-String forbid does not permit-all", "DENY", broken, { command: "rm" });
439
- return { wasmAvailable, passed: cases.every((c) => c.pass), cases };
440
- }
441
-
442
239
  // src/http-server.ts
443
240
  import { createServer } from "http";
444
- import { readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
445
- import { join as join2 } from "path";
241
+ import { readFileSync as readFileSync3, existsSync as existsSync2 } from "fs";
242
+ import { join } from "path";
446
243
  var LOG_FILE = ".protect-mcp-log.jsonl";
447
244
  var MAX_RECEIPTS = 100;
448
245
  var ReceiptBuffer = class {
@@ -535,13 +332,13 @@ function handleHealth(res, startTime, config) {
535
332
  }));
536
333
  }
537
334
  function handleStatus(res, logDir) {
538
- const logPath = join2(logDir, LOG_FILE);
539
- if (!existsSync3(logPath)) {
335
+ const logPath = join(logDir, LOG_FILE);
336
+ if (!existsSync2(logPath)) {
540
337
  res.writeHead(200);
541
338
  res.end(JSON.stringify({ entries: 0, message: "no log file yet" }));
542
339
  return;
543
340
  }
544
- const raw = readFileSync4(logPath, "utf-8");
341
+ const raw = readFileSync3(logPath, "utf-8");
545
342
  const lines = raw.trim().split("\n").filter(Boolean);
546
343
  const entries = [];
547
344
  for (const line of lines) {
@@ -665,7 +462,7 @@ function handleListApprovals(res, approvalStore) {
665
462
  }
666
463
 
667
464
  // src/action-readback.ts
668
- import { createHash as createHash3 } from "crypto";
465
+ import { createHash as createHash2 } from "crypto";
669
466
  var SECRET_KEY_RE = /(api[_-]?key|authorization|bearer|credential|password|secret|session|token|private[_-]?key)/i;
670
467
  var DESTINATION_KEYS = [
671
468
  "path",
@@ -745,7 +542,7 @@ function buildActionReadback(tool, input) {
745
542
  action,
746
543
  destination,
747
544
  payload_preview: payloadPreview,
748
- payload_hash: createHash3("sha256").update(canonical).digest("hex"),
545
+ payload_hash: createHash2("sha256").update(canonical).digest("hex"),
749
546
  payload_bytes: Buffer.byteLength(canonical, "utf-8"),
750
547
  disclosed_fields: [...new Set(disclosedFields)].slice(0, 80),
751
548
  redacted_fields: [...new Set(redactedFields)].slice(0, 80),
@@ -762,11 +559,6 @@ export {
762
559
  signDecision,
763
560
  getSignerInfo,
764
561
  isSigningEnabled,
765
- loadCedarPolicies,
766
- evaluateCedar,
767
- isCedarAvailable,
768
- policySetFromSource,
769
- runEvaluatorSelfTest,
770
562
  ReceiptBuffer,
771
563
  startStatusServer,
772
564
  buildActionReadback
@@ -1,11 +1,11 @@
1
1
  import {
2
2
  meetsMinTier
3
- } from "./chunk-PB3TC7E3.mjs";
3
+ } from "./chunk-YM6SOJBR.mjs";
4
4
  import {
5
5
  checkRateLimit,
6
6
  getToolPolicy,
7
7
  parseRateLimit
8
- } from "./chunk-XLJUZ4WO.mjs";
8
+ } from "./chunk-G6X763MH.mjs";
9
9
 
10
10
  // src/simulate.ts
11
11
  import { readFileSync } from "fs";
@@ -0,0 +1,210 @@
1
+ // src/cedar-evaluator.ts
2
+ import { createHash } from "crypto";
3
+ import { readFileSync, readdirSync, existsSync } from "fs";
4
+ import { join, extname } from "path";
5
+ var cedarWasm = null;
6
+ var loadAttempted = false;
7
+ async function ensureCedarWasm() {
8
+ if (cedarWasm) return true;
9
+ if (loadAttempted) return false;
10
+ loadAttempted = true;
11
+ try {
12
+ const moduleName = "@cedar-policy/cedar-wasm";
13
+ cedarWasm = await import(
14
+ /* @vite-ignore */
15
+ moduleName
16
+ );
17
+ return true;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+ function loadCedarPolicies(dirPath) {
23
+ if (!existsSync(dirPath)) {
24
+ throw new Error(`Cedar policy directory not found: ${dirPath}`);
25
+ }
26
+ const entries = readdirSync(dirPath).filter((f) => extname(f) === ".cedar").sort();
27
+ if (entries.length === 0) {
28
+ throw new Error(`No .cedar files found in: ${dirPath}`);
29
+ }
30
+ const sources = [];
31
+ for (const file of entries) {
32
+ const content = readFileSync(join(dirPath, file), "utf-8");
33
+ sources.push(content);
34
+ }
35
+ const concatenated = sources.join("\n\n");
36
+ const digest = createHash("sha256").update(concatenated).digest("hex").slice(0, 16);
37
+ return {
38
+ source: concatenated,
39
+ digest,
40
+ fileCount: entries.length,
41
+ files: entries
42
+ };
43
+ }
44
+ function buildEntities(req) {
45
+ const agentId = req.agentId || req.tier;
46
+ return [
47
+ {
48
+ uid: { type: "Agent", id: agentId },
49
+ attrs: {
50
+ tier: req.tier,
51
+ ...req.agentId ? { agent_id: req.agentId } : {}
52
+ },
53
+ parents: []
54
+ },
55
+ {
56
+ uid: { type: "Tool", id: req.tool },
57
+ attrs: {},
58
+ parents: []
59
+ }
60
+ ];
61
+ }
62
+ function onEvalError(reason, failClosed, extra) {
63
+ return {
64
+ allowed: !failClosed,
65
+ reason: failClosed ? reason : `${reason} (observe mode; would DENY under enforcement)`,
66
+ metadata: { error: true, fail_closed: failClosed, would_deny: true, ...extra || {} }
67
+ };
68
+ }
69
+ async function evaluateCedar(policySet, req, schema, options) {
70
+ const failClosed = options?.failClosed ?? true;
71
+ const available = await ensureCedarWasm();
72
+ if (!available) {
73
+ return onEvalError("cedar_wasm_not_available", failClosed, { fallback: true });
74
+ }
75
+ try {
76
+ const agentId = req.agentId || req.tier;
77
+ const context = {
78
+ tier: req.tier,
79
+ ...req.context || {}
80
+ };
81
+ if (req.toolInput && Object.keys(req.toolInput).length > 0) {
82
+ context.input = req.toolInput;
83
+ }
84
+ const authRequest = {
85
+ principal: { type: "Agent", id: agentId },
86
+ action: { type: "Action", id: "MCP::Tool::call" },
87
+ resource: { type: "Tool", id: req.tool },
88
+ context
89
+ };
90
+ const entities = buildEntities(req);
91
+ const cedarSchema = schema?.schemaJson ?? null;
92
+ let result;
93
+ if (typeof cedarWasm.isAuthorized === "function") {
94
+ result = cedarWasm.isAuthorized({
95
+ policies: { staticPolicies: policySet.source },
96
+ entities,
97
+ principal: authRequest.principal,
98
+ action: authRequest.action,
99
+ resource: authRequest.resource,
100
+ context: authRequest.context,
101
+ schema: cedarSchema
102
+ });
103
+ } else if (typeof cedarWasm.checkAuthorization === "function") {
104
+ result = cedarWasm.checkAuthorization(
105
+ policySet.source,
106
+ JSON.stringify(entities),
107
+ JSON.stringify(authRequest)
108
+ );
109
+ } else {
110
+ const cedarEngine = cedarWasm.default || cedarWasm;
111
+ if (typeof cedarEngine.isAuthorized === "function") {
112
+ result = cedarEngine.isAuthorized({
113
+ policies: { staticPolicies: policySet.source },
114
+ entities,
115
+ principal: authRequest.principal,
116
+ action: authRequest.action,
117
+ resource: authRequest.resource,
118
+ context: authRequest.context,
119
+ schema: cedarSchema
120
+ });
121
+ } else {
122
+ return onEvalError("cedar_wasm_api_unsupported", failClosed, { exports: Object.keys(cedarWasm) });
123
+ }
124
+ }
125
+ const parsed = parseWasmResult(result);
126
+ const policyErrors = extractPolicyErrors(result);
127
+ if (parsed.kind === "error") {
128
+ return onEvalError(`cedar_unparseable_result: ${parsed.diagnostics}`, failClosed);
129
+ }
130
+ if (policyErrors.length > 0) {
131
+ return onEvalError(
132
+ `cedar_policy_errored: ${policyErrors.length} policy error(s); decision is unsound`,
133
+ failClosed,
134
+ { policy_errors: policyErrors.slice(0, 5), policy_digest: policySet.digest }
135
+ );
136
+ }
137
+ return {
138
+ allowed: parsed.kind === "allow",
139
+ reason: parsed.kind === "allow" ? void 0 : `cedar_deny${parsed.diagnostics ? ": " + parsed.diagnostics : ""}`,
140
+ metadata: {
141
+ policy_digest: policySet.digest,
142
+ ...parsed.matchedPolicies ? { matched_policies: parsed.matchedPolicies } : {}
143
+ }
144
+ };
145
+ } catch (err) {
146
+ return onEvalError(`cedar_eval_error: ${err instanceof Error ? err.message : "unknown"}`, failClosed);
147
+ }
148
+ }
149
+ function parseWasmResult(result) {
150
+ if (!result) return { kind: "error", diagnostics: "null result from Cedar WASM" };
151
+ if (result.type === "failure") {
152
+ return { kind: "error", diagnostics: `cedar failure: ${JSON.stringify(result.errors ?? [])}` };
153
+ }
154
+ if (result.type === "success" && result.response) {
155
+ const dec = result.response.decision;
156
+ const reasons = result.response.diagnostics?.reason;
157
+ if (dec === "allow" || dec === "Allow") return { kind: "allow", matchedPolicies: reasons };
158
+ if (dec === "deny" || dec === "Deny") {
159
+ return { kind: "deny", diagnostics: result.response.diagnostics ? JSON.stringify(result.response.diagnostics) : void 0, matchedPolicies: reasons };
160
+ }
161
+ }
162
+ if (result.type === "allow" || result.decision === "Allow") return { kind: "allow" };
163
+ if (result.type === "deny" || result.decision === "Deny") return { kind: "deny" };
164
+ if (typeof result === "boolean") return result ? { kind: "allow" } : { kind: "deny" };
165
+ return { kind: "error", diagnostics: `unknown result format: ${JSON.stringify(result)}` };
166
+ }
167
+ function extractPolicyErrors(result) {
168
+ if (!result || typeof result !== "object") return [];
169
+ const raw = result.errors ?? result.response?.diagnostics?.errors ?? result.diagnostics?.errors ?? [];
170
+ if (!Array.isArray(raw)) return [];
171
+ return raw.map((e) => typeof e === "string" ? e : e?.message ?? e?.error ?? JSON.stringify(e)).filter(Boolean);
172
+ }
173
+ async function isCedarAvailable() {
174
+ return ensureCedarWasm();
175
+ }
176
+ function policySetFromSource(source, name = "inline") {
177
+ const digest = createHash("sha256").update(source).digest("hex").slice(0, 16);
178
+ return { source, digest, fileCount: 1, files: [name] };
179
+ }
180
+ async function runEvaluatorSelfTest() {
181
+ const wasmAvailable = await isCedarAvailable();
182
+ const cases = [];
183
+ const run = async (name, expected, policy, context) => {
184
+ const d = await evaluateCedar(policy, { tool: "Bash", tier: "unknown", context }, void 0, { failClosed: true });
185
+ const actual = d.allowed ? "ALLOW" : "DENY";
186
+ cases.push({ name, expected, actual, pass: actual === expected, reason: d.reason });
187
+ };
188
+ if (!wasmAvailable) {
189
+ await run("engine unavailable denies", "DENY", policySetFromSource("permit(principal, action, resource);"), {});
190
+ return { wasmAvailable, passed: cases.every((c) => c.pass), cases };
191
+ }
192
+ const correct = policySetFromSource(
193
+ 'forbid(principal, action, resource) when { ["rm", "dd", "mkfs"].contains(context.command) };\npermit(principal, action, resource);'
194
+ );
195
+ await run("forbid denies rm", "DENY", correct, { command: "rm" });
196
+ await run("permit allows ls", "ALLOW", correct, { command: "ls" });
197
+ const broken = policySetFromSource(
198
+ 'forbid(principal, action, resource) when { context.command in ["rm", "dd"] };\npermit(principal, action, resource);'
199
+ );
200
+ await run("in-on-String forbid does not permit-all", "DENY", broken, { command: "rm" });
201
+ return { wasmAvailable, passed: cases.every((c) => c.pass), cases };
202
+ }
203
+
204
+ export {
205
+ loadCedarPolicies,
206
+ evaluateCedar,
207
+ isCedarAvailable,
208
+ policySetFromSource,
209
+ runEvaluatorSelfTest
210
+ };