protect-mcp 0.6.3 → 0.7.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 ADDED
@@ -0,0 +1,47 @@
1
+ # Changelog
2
+
3
+ ## 0.7.0 (security release): the gate now fails closed and actually evaluates
4
+
5
+ This release fixes the way the Cedar policy gate behaves when anything goes
6
+ wrong, and a separate defect that meant Cedar policies were not being evaluated
7
+ at all against the pinned engine. If you rely on protect-mcp to enforce a Cedar
8
+ policy, upgrade.
9
+
10
+ ### Security
11
+
12
+ - **Fail closed, not open.** Before 0.7.0, if the Cedar engine was unavailable,
13
+ the result was malformed, evaluation threw, or a policy errored, the evaluator
14
+ returned ALLOW. A security gate must do the opposite. Every error and
15
+ uncertainty path now DENIES. The allow-on-error behavior is reachable only by
16
+ explicitly passing `{ failClosed: false }` (observe mode), and even then the
17
+ decision is flagged `would_deny: true` so a failure is never silent.
18
+ - **An errored policy can no longer permit-all.** Cedar silently discards a
19
+ policy that errors at evaluation (for example the `context.<string> in [list]`
20
+ type error in the 0.5.x and 0.6.x advisory), which could leave a residual
21
+ permit standing. The evaluator now treats any per-policy error as an
22
+ evaluation error and denies under enforcement.
23
+ - **Cedar policies are actually evaluated now.** The `isAuthorized` call passed
24
+ the policy text as a bare string, but `@cedar-policy/cedar-wasm@4.x` requires a
25
+ structured `PolicySet`. As a result every Cedar evaluation errored against the
26
+ pinned engine and (with the old fail-open default) allowed everything. The call
27
+ shape and the response parser are corrected, so a `forbid` rule actually denies
28
+ and a `permit` actually allows.
29
+
30
+ ### Added
31
+
32
+ - **`protect-mcp evaluate`** and **`protect-mcp sign`**: one-shot per-call verbs
33
+ for PreToolUse and PostToolUse hooks. `evaluate` loads a Cedar policy, evaluates
34
+ one tool call fail-closed, and exits 2 on deny (so the tool is blocked) and 0 on
35
+ allow; a missing or unloadable policy denies unless `--fail-on-missing-policy
36
+ false` is set. This makes hook configs that invoke these verbs work as written.
37
+ - **`runEvaluatorSelfTest()`** plus a startup gate: `serve --enforce` runs the
38
+ self-test before arming and refuses to start if the engine cannot prove it
39
+ denies a known-forbidden vector. `protect-mcp doctor` reports the same, so the
40
+ gate verifies its own restraint before it is trusted.
41
+ - A CI tripwire test that fails the build if the discarded `in`-on-String pattern
42
+ is ever reintroduced into a shipped policy.
43
+
44
+ ### Affected versions
45
+
46
+ The fail-open behavior and the unevaluated-Cedar defect are present in the
47
+ 0.5.x and 0.6.x lines. Upgrade to 0.7.0.
package/README.md CHANGED
@@ -401,6 +401,17 @@ npx @veritasacta/verify receipt.json --key <public-key-hex>
401
401
  - **Patent Status**: 4 Australian provisional patents pending (2025-2026)
402
402
  - **Cedar WASM**: [PR #64](https://github.com/cedar-policy/cedar-for-agents/pull/64) merged + [PR #73](https://github.com/cedar-policy/cedar-for-agents/pull/73) (RequestGenerator, pending review)
403
403
 
404
+ ## What's New in v0.7.0 (security release)
405
+
406
+ The gate now fails closed. On any policy-evaluation error, a missing engine, or a
407
+ policy that errored, the decision is DENY, not allow. Cedar is also evaluated
408
+ correctly against cedar-wasm 4.x (earlier versions passed the policy in a shape the
409
+ engine rejected, so evaluation errored and the old fail-open default allowed
410
+ everything). `serve --enforce` now runs a restraint self-test before arming and
411
+ refuses to start if it cannot prove it denies a forbidden vector, `doctor` reports
412
+ that self-test, and one-shot `evaluate` and `sign` verbs are available for hooks.
413
+ Upgrade from any 0.5.x or 0.6.x. See CHANGELOG.md.
414
+
404
415
  ## What's New in v0.5.3
405
416
 
406
417
  - `quickstart --connect`: Auto-create dashboard sandbox and configure receipt upload
@@ -289,14 +289,18 @@ function buildEntities(req) {
289
289
  }
290
290
  ];
291
291
  }
292
- async function evaluateCedar(policySet, req, schema) {
292
+ function onEvalError(reason, failClosed, extra) {
293
+ return {
294
+ allowed: !failClosed,
295
+ reason: failClosed ? reason : `${reason} (observe mode; would DENY under enforcement)`,
296
+ metadata: { error: true, fail_closed: failClosed, would_deny: true, ...extra || {} }
297
+ };
298
+ }
299
+ async function evaluateCedar(policySet, req, schema, options) {
300
+ const failClosed = options?.failClosed ?? true;
293
301
  const available = await ensureCedarWasm();
294
302
  if (!available) {
295
- return {
296
- allowed: true,
297
- reason: "cedar_wasm_not_available",
298
- metadata: { fallback: true }
299
- };
303
+ return onEvalError("cedar_wasm_not_available", failClosed, { fallback: true });
300
304
  }
301
305
  try {
302
306
  const agentId = req.agentId || req.tier;
@@ -318,7 +322,7 @@ async function evaluateCedar(policySet, req, schema) {
318
322
  let result;
319
323
  if (typeof cedarWasm.isAuthorized === "function") {
320
324
  result = cedarWasm.isAuthorized({
321
- policies: policySet.source,
325
+ policies: { staticPolicies: policySet.source },
322
326
  entities,
323
327
  principal: authRequest.principal,
324
328
  action: authRequest.action,
@@ -336,7 +340,7 @@ async function evaluateCedar(policySet, req, schema) {
336
340
  const cedarEngine = cedarWasm.default || cedarWasm;
337
341
  if (typeof cedarEngine.isAuthorized === "function") {
338
342
  result = cedarEngine.isAuthorized({
339
- policies: policySet.source,
343
+ policies: { staticPolicies: policySet.source },
340
344
  entities,
341
345
  principal: authRequest.principal,
342
346
  action: authRequest.action,
@@ -345,61 +349,87 @@ async function evaluateCedar(policySet, req, schema) {
345
349
  schema: cedarSchema
346
350
  });
347
351
  } else {
348
- return {
349
- allowed: true,
350
- reason: "cedar_wasm_api_unsupported",
351
- metadata: { fallback: true, exports: Object.keys(cedarWasm) }
352
- };
352
+ return onEvalError("cedar_wasm_api_unsupported", failClosed, { exports: Object.keys(cedarWasm) });
353
353
  }
354
354
  }
355
- const decision = parseWasmResult(result);
355
+ const parsed = parseWasmResult(result);
356
+ const policyErrors = extractPolicyErrors(result);
357
+ if (parsed.kind === "error") {
358
+ return onEvalError(`cedar_unparseable_result: ${parsed.diagnostics}`, failClosed);
359
+ }
360
+ if (policyErrors.length > 0) {
361
+ return onEvalError(
362
+ `cedar_policy_errored: ${policyErrors.length} policy error(s); decision is unsound`,
363
+ failClosed,
364
+ { policy_errors: policyErrors.slice(0, 5), policy_digest: policySet.digest }
365
+ );
366
+ }
356
367
  return {
357
- allowed: decision.allowed,
358
- reason: decision.allowed ? void 0 : `cedar_deny${decision.diagnostics ? ": " + decision.diagnostics : ""}`,
368
+ allowed: parsed.kind === "allow",
369
+ reason: parsed.kind === "allow" ? void 0 : `cedar_deny${parsed.diagnostics ? ": " + parsed.diagnostics : ""}`,
359
370
  metadata: {
360
371
  policy_digest: policySet.digest,
361
- ...decision.matchedPolicies ? { matched_policies: decision.matchedPolicies } : {}
372
+ ...parsed.matchedPolicies ? { matched_policies: parsed.matchedPolicies } : {}
362
373
  }
363
374
  };
364
375
  } catch (err) {
365
- return {
366
- allowed: true,
367
- reason: `cedar_eval_error: ${err instanceof Error ? err.message : "unknown"}`,
368
- metadata: { fallback: true, error: true }
369
- };
376
+ return onEvalError(`cedar_eval_error: ${err instanceof Error ? err.message : "unknown"}`, failClosed);
370
377
  }
371
378
  }
372
379
  function parseWasmResult(result) {
373
- if (!result) {
374
- return { allowed: true, diagnostics: "null result from Cedar WASM" };
375
- }
376
- if (result.type === "allow" || result.type === "Allow") {
377
- return { allowed: true };
378
- }
379
- if (result.type === "deny" || result.type === "Deny") {
380
- return {
381
- allowed: false,
382
- diagnostics: result.diagnostics ? JSON.stringify(result.diagnostics) : void 0,
383
- matchedPolicies: result.diagnostics?.reasons
384
- };
385
- }
386
- if (result.decision === "Allow") {
387
- return { allowed: true };
388
- }
389
- if (result.decision === "Deny") {
390
- return {
391
- allowed: false,
392
- diagnostics: result.diagnostics ? JSON.stringify(result.diagnostics) : void 0
393
- };
394
- }
395
- if (typeof result === "boolean") {
396
- return { allowed: result };
380
+ if (!result) return { kind: "error", diagnostics: "null result from Cedar WASM" };
381
+ if (result.type === "failure") {
382
+ return { kind: "error", diagnostics: `cedar failure: ${JSON.stringify(result.errors ?? [])}` };
383
+ }
384
+ if (result.type === "success" && result.response) {
385
+ const dec = result.response.decision;
386
+ const reasons = result.response.diagnostics?.reason;
387
+ if (dec === "allow" || dec === "Allow") return { kind: "allow", matchedPolicies: reasons };
388
+ if (dec === "deny" || dec === "Deny") {
389
+ return { kind: "deny", diagnostics: result.response.diagnostics ? JSON.stringify(result.response.diagnostics) : void 0, matchedPolicies: reasons };
390
+ }
397
391
  }
398
- return { allowed: true, diagnostics: `unknown result format: ${JSON.stringify(result)}` };
392
+ if (result.type === "allow" || result.decision === "Allow") return { kind: "allow" };
393
+ if (result.type === "deny" || result.decision === "Deny") return { kind: "deny" };
394
+ if (typeof result === "boolean") return result ? { kind: "allow" } : { kind: "deny" };
395
+ return { kind: "error", diagnostics: `unknown result format: ${JSON.stringify(result)}` };
396
+ }
397
+ function extractPolicyErrors(result) {
398
+ if (!result || typeof result !== "object") return [];
399
+ const raw = result.errors ?? result.response?.diagnostics?.errors ?? result.diagnostics?.errors ?? [];
400
+ if (!Array.isArray(raw)) return [];
401
+ return raw.map((e) => typeof e === "string" ? e : e?.message ?? e?.error ?? JSON.stringify(e)).filter(Boolean);
399
402
  }
400
403
  async function isCedarAvailable() {
401
404
  return ensureCedarWasm();
402
405
  }
406
+ function policySetFromSource(source, name = "inline") {
407
+ const digest = createHash2("sha256").update(source).digest("hex").slice(0, 16);
408
+ return { source, digest, fileCount: 1, files: [name] };
409
+ }
410
+ async function runEvaluatorSelfTest() {
411
+ const wasmAvailable = await isCedarAvailable();
412
+ const cases = [];
413
+ const run = async (name, expected, policy, context) => {
414
+ const d = await evaluateCedar(policy, { tool: "Bash", tier: "unknown", context }, void 0, { failClosed: true });
415
+ const actual = d.allowed ? "ALLOW" : "DENY";
416
+ cases.push({ name, expected, actual, pass: actual === expected, reason: d.reason });
417
+ };
418
+ if (!wasmAvailable) {
419
+ await run("engine unavailable denies", "DENY", policySetFromSource("permit(principal, action, resource);"), {});
420
+ return { wasmAvailable, passed: cases.every((c) => c.pass), cases };
421
+ }
422
+ const correct = policySetFromSource(
423
+ 'forbid(principal, action, resource) when { ["rm", "dd", "mkfs"].contains(context.command) };\npermit(principal, action, resource);'
424
+ );
425
+ await run("forbid denies rm", "DENY", correct, { command: "rm" });
426
+ await run("permit allows ls", "ALLOW", correct, { command: "ls" });
427
+ const broken = policySetFromSource(
428
+ 'forbid(principal, action, resource) when { context.command in ["rm", "dd"] };\npermit(principal, action, resource);'
429
+ );
430
+ await run("in-on-String forbid does not permit-all", "DENY", broken, { command: "rm" });
431
+ return { wasmAvailable, passed: cases.every((c) => c.pass), cases };
432
+ }
403
433
 
404
434
  // src/http-server.ts
405
435
  import { createServer } from "http";
@@ -638,6 +668,8 @@ export {
638
668
  loadCedarPolicies,
639
669
  evaluateCedar,
640
670
  isCedarAvailable,
671
+ policySetFromSource,
672
+ runEvaluatorSelfTest,
641
673
  ReceiptBuffer,
642
674
  startStatusServer
643
675
  };
@@ -0,0 +1,252 @@
1
+ // node_modules/@noble/hashes/esm/cryptoNode.js
2
+ import * as nc from "crypto";
3
+ var crypto = nc && typeof nc === "object" && "webcrypto" in nc ? nc.webcrypto : nc && typeof nc === "object" && "randomBytes" in nc ? nc : void 0;
4
+
5
+ // node_modules/@noble/hashes/esm/utils.js
6
+ function isBytes(a) {
7
+ return a instanceof Uint8Array || ArrayBuffer.isView(a) && a.constructor.name === "Uint8Array";
8
+ }
9
+ function anumber(n) {
10
+ if (!Number.isSafeInteger(n) || n < 0)
11
+ throw new Error("positive integer expected, got " + n);
12
+ }
13
+ function abytes(b, ...lengths) {
14
+ if (!isBytes(b))
15
+ throw new Error("Uint8Array expected");
16
+ if (lengths.length > 0 && !lengths.includes(b.length))
17
+ throw new Error("Uint8Array expected of length " + lengths + ", got length=" + b.length);
18
+ }
19
+ function ahash(h) {
20
+ if (typeof h !== "function" || typeof h.create !== "function")
21
+ throw new Error("Hash should be wrapped by utils.createHasher");
22
+ anumber(h.outputLen);
23
+ anumber(h.blockLen);
24
+ }
25
+ function aexists(instance, checkFinished = true) {
26
+ if (instance.destroyed)
27
+ throw new Error("Hash instance has been destroyed");
28
+ if (checkFinished && instance.finished)
29
+ throw new Error("Hash#digest() has already been called");
30
+ }
31
+ function aoutput(out, instance) {
32
+ abytes(out);
33
+ const min = instance.outputLen;
34
+ if (out.length < min) {
35
+ throw new Error("digestInto() expects output buffer of length at least " + min);
36
+ }
37
+ }
38
+ function u8(arr) {
39
+ return new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength);
40
+ }
41
+ function u32(arr) {
42
+ return new Uint32Array(arr.buffer, arr.byteOffset, Math.floor(arr.byteLength / 4));
43
+ }
44
+ function clean(...arrays) {
45
+ for (let i = 0; i < arrays.length; i++) {
46
+ arrays[i].fill(0);
47
+ }
48
+ }
49
+ function createView(arr) {
50
+ return new DataView(arr.buffer, arr.byteOffset, arr.byteLength);
51
+ }
52
+ function rotr(word, shift) {
53
+ return word << 32 - shift | word >>> shift;
54
+ }
55
+ function rotl(word, shift) {
56
+ return word << shift | word >>> 32 - shift >>> 0;
57
+ }
58
+ var isLE = /* @__PURE__ */ (() => new Uint8Array(new Uint32Array([287454020]).buffer)[0] === 68)();
59
+ function byteSwap(word) {
60
+ return word << 24 & 4278190080 | word << 8 & 16711680 | word >>> 8 & 65280 | word >>> 24 & 255;
61
+ }
62
+ var swap8IfBE = isLE ? (n) => n : (n) => byteSwap(n);
63
+ var byteSwapIfBE = swap8IfBE;
64
+ function byteSwap32(arr) {
65
+ for (let i = 0; i < arr.length; i++) {
66
+ arr[i] = byteSwap(arr[i]);
67
+ }
68
+ return arr;
69
+ }
70
+ var swap32IfBE = isLE ? (u) => u : byteSwap32;
71
+ var hasHexBuiltin = /* @__PURE__ */ (() => (
72
+ // @ts-ignore
73
+ typeof Uint8Array.from([]).toHex === "function" && typeof Uint8Array.fromHex === "function"
74
+ ))();
75
+ var hexes = /* @__PURE__ */ Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
76
+ function bytesToHex(bytes) {
77
+ abytes(bytes);
78
+ if (hasHexBuiltin)
79
+ return bytes.toHex();
80
+ let hex = "";
81
+ for (let i = 0; i < bytes.length; i++) {
82
+ hex += hexes[bytes[i]];
83
+ }
84
+ return hex;
85
+ }
86
+ var asciis = { _0: 48, _9: 57, A: 65, F: 70, a: 97, f: 102 };
87
+ function asciiToBase16(ch) {
88
+ if (ch >= asciis._0 && ch <= asciis._9)
89
+ return ch - asciis._0;
90
+ if (ch >= asciis.A && ch <= asciis.F)
91
+ return ch - (asciis.A - 10);
92
+ if (ch >= asciis.a && ch <= asciis.f)
93
+ return ch - (asciis.a - 10);
94
+ return;
95
+ }
96
+ function hexToBytes(hex) {
97
+ if (typeof hex !== "string")
98
+ throw new Error("hex string expected, got " + typeof hex);
99
+ if (hasHexBuiltin)
100
+ return Uint8Array.fromHex(hex);
101
+ const hl = hex.length;
102
+ const al = hl / 2;
103
+ if (hl % 2)
104
+ throw new Error("hex string expected, got unpadded hex of length " + hl);
105
+ const array = new Uint8Array(al);
106
+ for (let ai = 0, hi = 0; ai < al; ai++, hi += 2) {
107
+ const n1 = asciiToBase16(hex.charCodeAt(hi));
108
+ const n2 = asciiToBase16(hex.charCodeAt(hi + 1));
109
+ if (n1 === void 0 || n2 === void 0) {
110
+ const char = hex[hi] + hex[hi + 1];
111
+ throw new Error('hex string expected, got non-hex character "' + char + '" at index ' + hi);
112
+ }
113
+ array[ai] = n1 * 16 + n2;
114
+ }
115
+ return array;
116
+ }
117
+ var nextTick = async () => {
118
+ };
119
+ async function asyncLoop(iters, tick, cb) {
120
+ let ts = Date.now();
121
+ for (let i = 0; i < iters; i++) {
122
+ cb(i);
123
+ const diff = Date.now() - ts;
124
+ if (diff >= 0 && diff < tick)
125
+ continue;
126
+ await nextTick();
127
+ ts += diff;
128
+ }
129
+ }
130
+ function utf8ToBytes(str) {
131
+ if (typeof str !== "string")
132
+ throw new Error("string expected");
133
+ return new Uint8Array(new TextEncoder().encode(str));
134
+ }
135
+ function bytesToUtf8(bytes) {
136
+ return new TextDecoder().decode(bytes);
137
+ }
138
+ function toBytes(data) {
139
+ if (typeof data === "string")
140
+ data = utf8ToBytes(data);
141
+ abytes(data);
142
+ return data;
143
+ }
144
+ function kdfInputToBytes(data) {
145
+ if (typeof data === "string")
146
+ data = utf8ToBytes(data);
147
+ abytes(data);
148
+ return data;
149
+ }
150
+ function concatBytes(...arrays) {
151
+ let sum = 0;
152
+ for (let i = 0; i < arrays.length; i++) {
153
+ const a = arrays[i];
154
+ abytes(a);
155
+ sum += a.length;
156
+ }
157
+ const res = new Uint8Array(sum);
158
+ for (let i = 0, pad = 0; i < arrays.length; i++) {
159
+ const a = arrays[i];
160
+ res.set(a, pad);
161
+ pad += a.length;
162
+ }
163
+ return res;
164
+ }
165
+ function checkOpts(defaults, opts) {
166
+ if (opts !== void 0 && {}.toString.call(opts) !== "[object Object]")
167
+ throw new Error("options should be object or undefined");
168
+ const merged = Object.assign(defaults, opts);
169
+ return merged;
170
+ }
171
+ var Hash = class {
172
+ };
173
+ function createHasher(hashCons) {
174
+ const hashC = (msg) => hashCons().update(toBytes(msg)).digest();
175
+ const tmp = hashCons();
176
+ hashC.outputLen = tmp.outputLen;
177
+ hashC.blockLen = tmp.blockLen;
178
+ hashC.create = () => hashCons();
179
+ return hashC;
180
+ }
181
+ function createOptHasher(hashCons) {
182
+ const hashC = (msg, opts) => hashCons(opts).update(toBytes(msg)).digest();
183
+ const tmp = hashCons({});
184
+ hashC.outputLen = tmp.outputLen;
185
+ hashC.blockLen = tmp.blockLen;
186
+ hashC.create = (opts) => hashCons(opts);
187
+ return hashC;
188
+ }
189
+ function createXOFer(hashCons) {
190
+ const hashC = (msg, opts) => hashCons(opts).update(toBytes(msg)).digest();
191
+ const tmp = hashCons({});
192
+ hashC.outputLen = tmp.outputLen;
193
+ hashC.blockLen = tmp.blockLen;
194
+ hashC.create = (opts) => hashCons(opts);
195
+ return hashC;
196
+ }
197
+ var wrapConstructor = createHasher;
198
+ var wrapConstructorWithOpts = createOptHasher;
199
+ var wrapXOFConstructorWithOpts = createXOFer;
200
+ function randomBytes(bytesLength = 32) {
201
+ if (crypto && typeof crypto.getRandomValues === "function") {
202
+ return crypto.getRandomValues(new Uint8Array(bytesLength));
203
+ }
204
+ if (crypto && typeof crypto.randomBytes === "function") {
205
+ return Uint8Array.from(crypto.randomBytes(bytesLength));
206
+ }
207
+ throw new Error("crypto.getRandomValues must be defined");
208
+ }
209
+
210
+ export {
211
+ isBytes,
212
+ anumber,
213
+ abytes,
214
+ ahash,
215
+ aexists,
216
+ aoutput,
217
+ u8,
218
+ u32,
219
+ clean,
220
+ createView,
221
+ rotr,
222
+ rotl,
223
+ isLE,
224
+ byteSwap,
225
+ swap8IfBE,
226
+ byteSwapIfBE,
227
+ byteSwap32,
228
+ swap32IfBE,
229
+ bytesToHex,
230
+ hexToBytes,
231
+ nextTick,
232
+ asyncLoop,
233
+ utf8ToBytes,
234
+ bytesToUtf8,
235
+ toBytes,
236
+ kdfInputToBytes,
237
+ concatBytes,
238
+ checkOpts,
239
+ Hash,
240
+ createHasher,
241
+ createOptHasher,
242
+ createXOFer,
243
+ wrapConstructor,
244
+ wrapConstructorWithOpts,
245
+ wrapXOFConstructorWithOpts,
246
+ randomBytes
247
+ };
248
+ /*! Bundled license information:
249
+
250
+ @noble/hashes/esm/utils.js:
251
+ (*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) *)
252
+ */