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 +21 -0
- package/README.md +33 -0
- package/dist/{chunk-H332ZNJ6.mjs → chunk-AUUAVWQM.mjs} +51 -8
- package/dist/{chunk-XLJUZ4WO.mjs → chunk-G6X763MH.mjs} +7 -215
- package/dist/{chunk-7MHK5RF4.mjs → chunk-MOXINIMB.mjs} +2 -2
- package/dist/chunk-MWXDXYWH.mjs +210 -0
- package/dist/{chunk-PB3TC7E3.mjs → chunk-YM6SOJBR.mjs} +4 -2
- package/dist/cli.js +474 -7
- package/dist/cli.mjs +165 -8
- package/dist/hook-server.js +44 -3
- package/dist/hook-server.mjs +3 -2
- package/dist/{http-transport-HLSMVBI6.mjs → http-transport-VHD3YBT5.mjs} +3 -2
- package/dist/index.js +44 -3
- package/dist/index.mjs +11 -9
- package/dist/mcp-server.d.mts +24 -0
- package/dist/mcp-server.d.ts +24 -0
- package/dist/mcp-server.js +445 -0
- package/dist/mcp-server.mjs +269 -0
- package/package.json +4 -3
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
|
+
[](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-
|
|
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]
|
|
299
|
-
${
|
|
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
|
|
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
|
|
445
|
-
import { join
|
|
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 =
|
|
539
|
-
if (!
|
|
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 =
|
|
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
|
|
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:
|
|
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-
|
|
3
|
+
} from "./chunk-YM6SOJBR.mjs";
|
|
4
4
|
import {
|
|
5
5
|
checkRateLimit,
|
|
6
6
|
getToolPolicy,
|
|
7
7
|
parseRateLimit
|
|
8
|
-
} from "./chunk-
|
|
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
|
+
};
|