protect-mcp 0.9.5 → 0.9.6

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
@@ -187,6 +187,8 @@ reveals the shape, not the content.
187
187
 
188
188
  ## Try it in 60 seconds (no agent required)
189
189
 
190
+ [![Watch the two-minute demo film](https://legate.scopeblind.com/media/scopeblind-demo-poster.jpg)](https://legate.scopeblind.com/record)
191
+
190
192
  Watch the two-minute film at [legate.scopeblind.com/record](https://legate.scopeblind.com/record), then replay it against your own copy:
191
193
 
192
194
  ```bash
@@ -387,6 +389,7 @@ To report a vulnerability, see [SECURITY.md](./SECURITY.md).
387
389
  | `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
390
  | `init` | Generate an Ed25519 keypair (`keys/gateway.json`), a config template, and a sample policy. |
389
391
  | `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. |
392
+ | `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
393
  | `wrap` | Print a protected MCP command or patch Claude Desktop MCP servers. Dry-run by default; use `--write` to update Claude Desktop config. |
391
394
  | `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
395
  | `recommend` | Draft a reviewable JSON policy from observed local calls. Dry-run by default; use `--write` to create `protect-mcp.recommended.json`. |
@@ -20,7 +20,7 @@ import {
20
20
  // src/hook-server.ts
21
21
  import { createServer } from "http";
22
22
  import { createHash, randomUUID, randomBytes } from "crypto";
23
- import { appendFileSync, readFileSync, existsSync, readdirSync } from "fs";
23
+ import { appendFileSync, readFileSync, existsSync, readdirSync, statSync } from "fs";
24
24
  import { join } from "path";
25
25
 
26
26
  // src/scopeblind-bridge.ts
@@ -256,6 +256,7 @@ async function handlePreToolUse(input, state) {
256
256
  ...input.teamName && { team_name: input.teamName },
257
257
  ...input.agentType && { agent_type: input.agentType }
258
258
  };
259
+ maybeReloadCedar(state);
259
260
  if (state.cedarPolicies) {
260
261
  try {
261
262
  const cedarDecision = await evaluateCedar(state.cedarPolicies, {
@@ -293,10 +294,13 @@ async function handlePreToolUse(input, state) {
293
294
  sandbox_state: detectSandboxState(),
294
295
  plan_receipt_id: state.activePlanReceiptId || void 0
295
296
  });
297
+ const isDefaultDeny = !reason || reason === "cedar_deny" || /reason":\[\]/.test(reason);
298
+ const policyRef = state.cedarDir ? ` Policy: ${state.cedarDir}.` : "";
299
+ 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
300
  if (denyCount === 1) {
297
301
  process.stderr.write(
298
- `[PROTECT_MCP] No Cedar permit for "${toolName}" \u2014 suggest:
299
- ${suggestion}
302
+ `[PROTECT_MCP] Denied "${toolName}" (${isDefaultDeny ? "default-deny" : "forbid"}).
303
+ Allow with: npx protect-mcp policy allow ${toolName}
300
304
  `
301
305
  );
302
306
  }
@@ -304,7 +308,7 @@ async function handlePreToolUse(input, state) {
304
308
  hookSpecificOutput: {
305
309
  hookEventName: "PreToolUse",
306
310
  permissionDecision: "deny",
307
- permissionDecisionReason: `[ScopeBlind] Denied by Cedar policy. ${reason}. Forbidden: "${toolName}" is not permitted. Try a read-only alternative.` + (denyCount > 1 ? ` (attempt ${denyCount})` : "")
311
+ permissionDecisionReason: `[ScopeBlind] Denied "${toolName}".${howTo}` + (denyCount > 1 ? ` (attempt ${denyCount})` : "")
308
312
  }
309
313
  };
310
314
  }
@@ -803,6 +807,9 @@ async function startHookServer(options = {}) {
803
807
  }
804
808
  const state = {
805
809
  cedarPolicies,
810
+ cedarDir: cedarDir || null,
811
+ cedarMtimeMs: cedarDir ? newestCedarMtime(cedarDir) : 0,
812
+ cedarCheckedMs: 0,
806
813
  jsonPolicy,
807
814
  rateLimitStore: /* @__PURE__ */ new Map(),
808
815
  receiptBuffer: new ReceiptBuffer(),
@@ -993,6 +1000,40 @@ async function startHookServer(options = {}) {
993
1000
  process.on("SIGTERM", shutdown);
994
1001
  return server;
995
1002
  }
1003
+ function newestCedarMtime(dir) {
1004
+ try {
1005
+ let newest = 0;
1006
+ for (const f of readdirSync(dir)) {
1007
+ if (!f.endsWith(".cedar")) continue;
1008
+ const m = statSync(join(dir, f)).mtimeMs;
1009
+ if (m > newest) newest = m;
1010
+ }
1011
+ return newest;
1012
+ } catch {
1013
+ return 0;
1014
+ }
1015
+ }
1016
+ var CEDAR_CHECK_THROTTLE_MS = 2e3;
1017
+ function maybeReloadCedar(state) {
1018
+ if (!state.cedarDir) return;
1019
+ const nowMs = Date.now();
1020
+ if (nowMs - state.cedarCheckedMs < CEDAR_CHECK_THROTTLE_MS) return;
1021
+ state.cedarCheckedMs = nowMs;
1022
+ const m = newestCedarMtime(state.cedarDir);
1023
+ if (m <= state.cedarMtimeMs) return;
1024
+ try {
1025
+ const reloaded = loadCedarPolicies(state.cedarDir);
1026
+ state.cedarPolicies = reloaded;
1027
+ state.policyDigest = reloaded.digest;
1028
+ state.cedarMtimeMs = m;
1029
+ process.stderr.write(`[PROTECT_MCP] Cedar policy reloaded (digest: ${reloaded.digest}) after on-disk change.
1030
+ `);
1031
+ } catch (err) {
1032
+ process.stderr.write(`[PROTECT_MCP] Cedar reload failed, keeping the previous policy: ${err instanceof Error ? err.message : err}
1033
+ `);
1034
+ state.cedarMtimeMs = m;
1035
+ }
1036
+ }
996
1037
  function findCedarDir() {
997
1038
  for (const candidate of ["cedar", "policies", "."]) {
998
1039
  try {
package/dist/cli.js CHANGED
@@ -6650,6 +6650,7 @@ async function handlePreToolUse(input, state) {
6650
6650
  ...input.teamName && { team_name: input.teamName },
6651
6651
  ...input.agentType && { agent_type: input.agentType }
6652
6652
  };
6653
+ maybeReloadCedar(state);
6653
6654
  if (state.cedarPolicies) {
6654
6655
  try {
6655
6656
  const cedarDecision = await evaluateCedar(state.cedarPolicies, {
@@ -6687,10 +6688,13 @@ async function handlePreToolUse(input, state) {
6687
6688
  sandbox_state: detectSandboxState(),
6688
6689
  plan_receipt_id: state.activePlanReceiptId || void 0
6689
6690
  });
6691
+ const isDefaultDeny = !reason || reason === "cedar_deny" || /reason":\[\]/.test(reason);
6692
+ const policyRef = state.cedarDir ? ` Policy: ${state.cedarDir}.` : "";
6693
+ 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`;
6690
6694
  if (denyCount === 1) {
6691
6695
  process.stderr.write(
6692
- `[PROTECT_MCP] No Cedar permit for "${toolName}" \u2014 suggest:
6693
- ${suggestion}
6696
+ `[PROTECT_MCP] Denied "${toolName}" (${isDefaultDeny ? "default-deny" : "forbid"}).
6697
+ Allow with: npx protect-mcp policy allow ${toolName}
6694
6698
  `
6695
6699
  );
6696
6700
  }
@@ -6698,7 +6702,7 @@ async function handlePreToolUse(input, state) {
6698
6702
  hookSpecificOutput: {
6699
6703
  hookEventName: "PreToolUse",
6700
6704
  permissionDecision: "deny",
6701
- permissionDecisionReason: `[ScopeBlind] Denied by Cedar policy. ${reason}. Forbidden: "${toolName}" is not permitted. Try a read-only alternative.` + (denyCount > 1 ? ` (attempt ${denyCount})` : "")
6705
+ permissionDecisionReason: `[ScopeBlind] Denied "${toolName}".${howTo}` + (denyCount > 1 ? ` (attempt ${denyCount})` : "")
6702
6706
  }
6703
6707
  };
6704
6708
  }
@@ -7197,6 +7201,9 @@ async function startHookServer(options = {}) {
7197
7201
  }
7198
7202
  const state = {
7199
7203
  cedarPolicies,
7204
+ cedarDir: cedarDir || null,
7205
+ cedarMtimeMs: cedarDir ? newestCedarMtime(cedarDir) : 0,
7206
+ cedarCheckedMs: 0,
7200
7207
  jsonPolicy,
7201
7208
  rateLimitStore: /* @__PURE__ */ new Map(),
7202
7209
  receiptBuffer: new ReceiptBuffer(),
@@ -7387,6 +7394,39 @@ async function startHookServer(options = {}) {
7387
7394
  process.on("SIGTERM", shutdown);
7388
7395
  return server;
7389
7396
  }
7397
+ function newestCedarMtime(dir) {
7398
+ try {
7399
+ let newest = 0;
7400
+ for (const f of (0, import_node_fs11.readdirSync)(dir)) {
7401
+ if (!f.endsWith(".cedar")) continue;
7402
+ const m = (0, import_node_fs11.statSync)((0, import_node_path8.join)(dir, f)).mtimeMs;
7403
+ if (m > newest) newest = m;
7404
+ }
7405
+ return newest;
7406
+ } catch {
7407
+ return 0;
7408
+ }
7409
+ }
7410
+ function maybeReloadCedar(state) {
7411
+ if (!state.cedarDir) return;
7412
+ const nowMs = Date.now();
7413
+ if (nowMs - state.cedarCheckedMs < CEDAR_CHECK_THROTTLE_MS) return;
7414
+ state.cedarCheckedMs = nowMs;
7415
+ const m = newestCedarMtime(state.cedarDir);
7416
+ if (m <= state.cedarMtimeMs) return;
7417
+ try {
7418
+ const reloaded = loadCedarPolicies(state.cedarDir);
7419
+ state.cedarPolicies = reloaded;
7420
+ state.policyDigest = reloaded.digest;
7421
+ state.cedarMtimeMs = m;
7422
+ process.stderr.write(`[PROTECT_MCP] Cedar policy reloaded (digest: ${reloaded.digest}) after on-disk change.
7423
+ `);
7424
+ } catch (err) {
7425
+ process.stderr.write(`[PROTECT_MCP] Cedar reload failed, keeping the previous policy: ${err instanceof Error ? err.message : err}
7426
+ `);
7427
+ state.cedarMtimeMs = m;
7428
+ }
7429
+ }
7390
7430
  function findCedarDir() {
7391
7431
  for (const candidate of ["cedar", "policies", "."]) {
7392
7432
  try {
@@ -7412,7 +7452,7 @@ function normalizeHookInput(raw) {
7412
7452
  }
7413
7453
  return result;
7414
7454
  }
7415
- var import_node_http2, import_node_crypto6, import_node_fs11, import_node_path8, DEFAULT_PORT, LOG_FILE3, RECEIPTS_FILE2, PAYLOAD_HASH_THRESHOLD, SNAKE_TO_CAMEL_MAP;
7455
+ var import_node_http2, import_node_crypto6, import_node_fs11, import_node_path8, DEFAULT_PORT, LOG_FILE3, RECEIPTS_FILE2, PAYLOAD_HASH_THRESHOLD, CEDAR_CHECK_THROTTLE_MS, SNAKE_TO_CAMEL_MAP;
7416
7456
  var init_hook_server = __esm({
7417
7457
  "src/hook-server.ts"() {
7418
7458
  "use strict";
@@ -7431,6 +7471,7 @@ var init_hook_server = __esm({
7431
7471
  LOG_FILE3 = ".protect-mcp-log.jsonl";
7432
7472
  RECEIPTS_FILE2 = ".protect-mcp-receipts.jsonl";
7433
7473
  PAYLOAD_HASH_THRESHOLD = 1024;
7474
+ CEDAR_CHECK_THROTTLE_MS = 2e3;
7434
7475
  SNAKE_TO_CAMEL_MAP = {
7435
7476
  hook_event_name: "hookEventName",
7436
7477
  session_id: "sessionId",
@@ -8871,6 +8912,7 @@ Usage:
8871
8912
  protect-mcp connect
8872
8913
  protect-mcp init [--dir <path>]
8873
8914
  protect-mcp sample [--dir <path>] [--force]
8915
+ protect-mcp policy list|show|allow <tool>|deny <tool>|path
8874
8916
  protect-mcp demo
8875
8917
  protect-mcp trace <receipt_id> [--endpoint <url>] [--depth <n>]
8876
8918
  protect-mcp status [--dir <path>]
@@ -12977,6 +13019,151 @@ ${bold("\u{1F6E1} Sample record seeded")} \xB7 8 decisions (1 blocked, 2 payment
12977
13019
  `);
12978
13020
  process.exit(0);
12979
13021
  }
13022
+ async function handlePolicy(argv) {
13023
+ const { readFileSync: rf, writeFileSync: wf, existsSync: ex, readdirSync: rd, appendFileSync: af } = await import("fs");
13024
+ const { join: pj } = await import("path");
13025
+ const { createHash: createHash6 } = await import("crypto");
13026
+ const sub = argv[0] || "list";
13027
+ const findDir = () => {
13028
+ for (const c of ["cedar", "policies", "."]) {
13029
+ try {
13030
+ if (ex(c) && rd(c).some((f) => f.endsWith(".cedar"))) return c;
13031
+ } catch {
13032
+ }
13033
+ }
13034
+ return null;
13035
+ };
13036
+ const dir = findDir();
13037
+ const cedarFiles = dir ? rd(dir).filter((f) => f.endsWith(".cedar")).sort() : [];
13038
+ const readAll = () => cedarFiles.map((f) => rf(pj(dir, f), "utf-8")).join("\n\n");
13039
+ const digestOf = (src) => createHash6("sha256").update(src).digest("hex").slice(0, 16);
13040
+ const stripComments = (src) => src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
13041
+ const targetFile = cedarFiles.includes("agent.cedar") ? "agent.cedar" : cedarFiles[0];
13042
+ if (sub === "path") {
13043
+ if (!dir) {
13044
+ process.stdout.write("No Cedar policy directory found (looked in ./cedar, ./policies, .).\n");
13045
+ process.exit(0);
13046
+ }
13047
+ process.stdout.write(`${pj(dir, targetFile)}
13048
+ `);
13049
+ process.exit(0);
13050
+ }
13051
+ if (sub === "show") {
13052
+ if (!dir) {
13053
+ process.stderr.write("No Cedar policy found. Run: npx protect-mcp init-hooks\n");
13054
+ process.exit(1);
13055
+ }
13056
+ const src = readAll();
13057
+ process.stdout.write(`${bold("Cedar policy")} ${dim(`(${cedarFiles.length} file${cedarFiles.length === 1 ? "" : "s"} in ${dir}, digest ${digestOf(src)})`)}
13058
+
13059
+ `);
13060
+ process.stdout.write(src.endsWith("\n") ? src : src + "\n");
13061
+ process.exit(0);
13062
+ }
13063
+ if (sub === "allow" || sub === "deny") {
13064
+ const tool = argv[1];
13065
+ if (!tool) {
13066
+ process.stderr.write(`Usage: npx protect-mcp policy ${sub} <ToolName>
13067
+ `);
13068
+ process.exit(1);
13069
+ }
13070
+ if (!/^[A-Za-z0-9_.:-]+$/.test(tool)) {
13071
+ process.stderr.write(`Refusing: "${tool}" is not a valid tool name.
13072
+ `);
13073
+ process.exit(1);
13074
+ }
13075
+ if (!dir) {
13076
+ process.stderr.write("No Cedar policy found. Run: npx protect-mcp init-hooks\n");
13077
+ process.exit(1);
13078
+ }
13079
+ const effect = sub === "allow" ? "permit" : "forbid";
13080
+ const before = readAll();
13081
+ const ruleRe = new RegExp(`${effect}\\s*\\([^)]*resource\\s*==\\s*Tool::"${tool.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}"`, "s");
13082
+ if (ruleRe.test(stripComments(before))) {
13083
+ process.stdout.write(`${dim("No change:")} a ${effect} rule for ${bold(tool)} already exists in ${targetFile}.
13084
+ `);
13085
+ process.exit(0);
13086
+ }
13087
+ const block = `
13088
+ // ${effect === "permit" ? "Allow" : "Block"} ${tool} (added by \`protect-mcp policy ${sub}\`)
13089
+ ${effect}(
13090
+ principal,
13091
+ action == Action::"MCP::Tool::call",
13092
+ resource == Tool::"${tool}"
13093
+ );
13094
+ `;
13095
+ af(pj(dir, targetFile), block);
13096
+ const after = readAll();
13097
+ process.stdout.write(`${bold(sub === "allow" ? "\u2713 Allowed" : "\u2713 Denied")} ${tool}
13098
+ `);
13099
+ process.stdout.write(` ${dim(`appended to ${pj(dir, targetFile)}`)}
13100
+ `);
13101
+ process.stdout.write(` ${dim(`policy digest ${digestOf(before)} \u2192 ${digestOf(after)}`)}
13102
+ `);
13103
+ process.stdout.write(`
13104
+ ${dim("A running gate (protect-mcp serve) hot-reloads on this change; no restart needed. The one-shot hook path re-reads per call.")}
13105
+ `);
13106
+ process.exit(0);
13107
+ }
13108
+ if (sub === "list") {
13109
+ if (!dir) {
13110
+ process.stderr.write("No Cedar policy found. Run: npx protect-mcp init-hooks\n");
13111
+ process.exit(1);
13112
+ }
13113
+ const src = readAll();
13114
+ const active = stripComments(src);
13115
+ const named = (effect) => {
13116
+ const set = /* @__PURE__ */ new Set();
13117
+ const re = new RegExp(`${effect}\\s*\\([\\s\\S]*?resource\\s*==\\s*Tool::"([^"]+)"[\\s\\S]*?\\);`, "g");
13118
+ let m;
13119
+ while (m = re.exec(active)) set.add(m[1]);
13120
+ return set;
13121
+ };
13122
+ const permitted = named("permit"), forbidden = named("forbid");
13123
+ const seen = /* @__PURE__ */ new Map();
13124
+ try {
13125
+ const logPath = pj(process.cwd(), ".protect-mcp-log.jsonl");
13126
+ if (ex(logPath)) {
13127
+ for (const line of rf(logPath, "utf-8").split("\n")) {
13128
+ if (!line.trim()) continue;
13129
+ try {
13130
+ const e = JSON.parse(line);
13131
+ if (!e.tool) continue;
13132
+ const rec = seen.get(e.tool) || { allow: 0, deny: 0 };
13133
+ if (e.decision === "deny") rec.deny++;
13134
+ else rec.allow++;
13135
+ seen.set(e.tool, rec);
13136
+ } catch {
13137
+ }
13138
+ }
13139
+ }
13140
+ } catch {
13141
+ }
13142
+ process.stdout.write(`${bold("Cedar policy")} ${dim(`(${dir}, digest ${digestOf(src)}) \xB7 default-deny, fail-closed`)}
13143
+
13144
+ `);
13145
+ const allTools = /* @__PURE__ */ new Set([...permitted, ...forbidden, ...seen.keys()]);
13146
+ if (allTools.size === 0) {
13147
+ process.stdout.write(dim(" No rules and no decisions logged yet.\n"));
13148
+ process.exit(0);
13149
+ }
13150
+ const rows = [...allTools].sort().map((t) => {
13151
+ const label = forbidden.has(t) ? "forbid" : permitted.has(t) ? "permit" : "default-deny";
13152
+ const colored = forbidden.has(t) ? red(label) : permitted.has(t) ? green(label) : yellow(label);
13153
+ const pad = " ".repeat(Math.max(1, 13 - label.length));
13154
+ const s = seen.get(t);
13155
+ const hits = s ? dim(` ${s.allow} allowed, ${s.deny} denied`) : "";
13156
+ return ` ${colored}${pad}${bold(t)}${hits}`;
13157
+ });
13158
+ process.stdout.write(rows.join("\n") + "\n");
13159
+ process.stdout.write(`
13160
+ ${dim("Allow a tool: npx protect-mcp policy allow <ToolName> \xB7 Block one: policy deny <ToolName>")}
13161
+ `);
13162
+ process.exit(0);
13163
+ }
13164
+ process.stderr.write("Usage: npx protect-mcp policy <list|show|allow <tool>|deny <tool>|path>\n");
13165
+ process.exit(1);
13166
+ }
12980
13167
  async function main() {
12981
13168
  sendInstallTelemetry().catch(() => {
12982
13169
  });
@@ -13046,6 +13233,10 @@ async function main() {
13046
13233
  await handleSample(args.slice(1));
13047
13234
  return;
13048
13235
  }
13236
+ if (args[0] === "policy") {
13237
+ await handlePolicy(args.slice(1));
13238
+ return;
13239
+ }
13049
13240
  if (args[0] === "init-hooks") {
13050
13241
  await handleInitHooks(args.slice(1));
13051
13242
  process.exit(0);
package/dist/cli.mjs CHANGED
@@ -55,6 +55,7 @@ Usage:
55
55
  protect-mcp connect
56
56
  protect-mcp init [--dir <path>]
57
57
  protect-mcp sample [--dir <path>] [--force]
58
+ protect-mcp policy list|show|allow <tool>|deny <tool>|path
58
59
  protect-mcp demo
59
60
  protect-mcp trace <receipt_id> [--endpoint <url>] [--depth <n>]
60
61
  protect-mcp status [--dir <path>]
@@ -4161,6 +4162,151 @@ ${bold("\u{1F6E1} Sample record seeded")} \xB7 8 decisions (1 blocked, 2 payment
4161
4162
  `);
4162
4163
  process.exit(0);
4163
4164
  }
4165
+ async function handlePolicy(argv) {
4166
+ const { readFileSync: rf, writeFileSync: wf, existsSync: ex, readdirSync: rd, appendFileSync: af } = await import("fs");
4167
+ const { join: pj } = await import("path");
4168
+ const { createHash } = await import("crypto");
4169
+ const sub = argv[0] || "list";
4170
+ const findDir = () => {
4171
+ for (const c of ["cedar", "policies", "."]) {
4172
+ try {
4173
+ if (ex(c) && rd(c).some((f) => f.endsWith(".cedar"))) return c;
4174
+ } catch {
4175
+ }
4176
+ }
4177
+ return null;
4178
+ };
4179
+ const dir = findDir();
4180
+ const cedarFiles = dir ? rd(dir).filter((f) => f.endsWith(".cedar")).sort() : [];
4181
+ const readAll = () => cedarFiles.map((f) => rf(pj(dir, f), "utf-8")).join("\n\n");
4182
+ const digestOf = (src) => createHash("sha256").update(src).digest("hex").slice(0, 16);
4183
+ const stripComments = (src) => src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
4184
+ const targetFile = cedarFiles.includes("agent.cedar") ? "agent.cedar" : cedarFiles[0];
4185
+ if (sub === "path") {
4186
+ if (!dir) {
4187
+ process.stdout.write("No Cedar policy directory found (looked in ./cedar, ./policies, .).\n");
4188
+ process.exit(0);
4189
+ }
4190
+ process.stdout.write(`${pj(dir, targetFile)}
4191
+ `);
4192
+ process.exit(0);
4193
+ }
4194
+ if (sub === "show") {
4195
+ if (!dir) {
4196
+ process.stderr.write("No Cedar policy found. Run: npx protect-mcp init-hooks\n");
4197
+ process.exit(1);
4198
+ }
4199
+ const src = readAll();
4200
+ process.stdout.write(`${bold("Cedar policy")} ${dim(`(${cedarFiles.length} file${cedarFiles.length === 1 ? "" : "s"} in ${dir}, digest ${digestOf(src)})`)}
4201
+
4202
+ `);
4203
+ process.stdout.write(src.endsWith("\n") ? src : src + "\n");
4204
+ process.exit(0);
4205
+ }
4206
+ if (sub === "allow" || sub === "deny") {
4207
+ const tool = argv[1];
4208
+ if (!tool) {
4209
+ process.stderr.write(`Usage: npx protect-mcp policy ${sub} <ToolName>
4210
+ `);
4211
+ process.exit(1);
4212
+ }
4213
+ if (!/^[A-Za-z0-9_.:-]+$/.test(tool)) {
4214
+ process.stderr.write(`Refusing: "${tool}" is not a valid tool name.
4215
+ `);
4216
+ process.exit(1);
4217
+ }
4218
+ if (!dir) {
4219
+ process.stderr.write("No Cedar policy found. Run: npx protect-mcp init-hooks\n");
4220
+ process.exit(1);
4221
+ }
4222
+ const effect = sub === "allow" ? "permit" : "forbid";
4223
+ const before = readAll();
4224
+ const ruleRe = new RegExp(`${effect}\\s*\\([^)]*resource\\s*==\\s*Tool::"${tool.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}"`, "s");
4225
+ if (ruleRe.test(stripComments(before))) {
4226
+ process.stdout.write(`${dim("No change:")} a ${effect} rule for ${bold(tool)} already exists in ${targetFile}.
4227
+ `);
4228
+ process.exit(0);
4229
+ }
4230
+ const block = `
4231
+ // ${effect === "permit" ? "Allow" : "Block"} ${tool} (added by \`protect-mcp policy ${sub}\`)
4232
+ ${effect}(
4233
+ principal,
4234
+ action == Action::"MCP::Tool::call",
4235
+ resource == Tool::"${tool}"
4236
+ );
4237
+ `;
4238
+ af(pj(dir, targetFile), block);
4239
+ const after = readAll();
4240
+ process.stdout.write(`${bold(sub === "allow" ? "\u2713 Allowed" : "\u2713 Denied")} ${tool}
4241
+ `);
4242
+ process.stdout.write(` ${dim(`appended to ${pj(dir, targetFile)}`)}
4243
+ `);
4244
+ process.stdout.write(` ${dim(`policy digest ${digestOf(before)} \u2192 ${digestOf(after)}`)}
4245
+ `);
4246
+ process.stdout.write(`
4247
+ ${dim("A running gate (protect-mcp serve) hot-reloads on this change; no restart needed. The one-shot hook path re-reads per call.")}
4248
+ `);
4249
+ process.exit(0);
4250
+ }
4251
+ if (sub === "list") {
4252
+ if (!dir) {
4253
+ process.stderr.write("No Cedar policy found. Run: npx protect-mcp init-hooks\n");
4254
+ process.exit(1);
4255
+ }
4256
+ const src = readAll();
4257
+ const active = stripComments(src);
4258
+ const named = (effect) => {
4259
+ const set = /* @__PURE__ */ new Set();
4260
+ const re = new RegExp(`${effect}\\s*\\([\\s\\S]*?resource\\s*==\\s*Tool::"([^"]+)"[\\s\\S]*?\\);`, "g");
4261
+ let m;
4262
+ while (m = re.exec(active)) set.add(m[1]);
4263
+ return set;
4264
+ };
4265
+ const permitted = named("permit"), forbidden = named("forbid");
4266
+ const seen = /* @__PURE__ */ new Map();
4267
+ try {
4268
+ const logPath = pj(process.cwd(), ".protect-mcp-log.jsonl");
4269
+ if (ex(logPath)) {
4270
+ for (const line of rf(logPath, "utf-8").split("\n")) {
4271
+ if (!line.trim()) continue;
4272
+ try {
4273
+ const e = JSON.parse(line);
4274
+ if (!e.tool) continue;
4275
+ const rec = seen.get(e.tool) || { allow: 0, deny: 0 };
4276
+ if (e.decision === "deny") rec.deny++;
4277
+ else rec.allow++;
4278
+ seen.set(e.tool, rec);
4279
+ } catch {
4280
+ }
4281
+ }
4282
+ }
4283
+ } catch {
4284
+ }
4285
+ process.stdout.write(`${bold("Cedar policy")} ${dim(`(${dir}, digest ${digestOf(src)}) \xB7 default-deny, fail-closed`)}
4286
+
4287
+ `);
4288
+ const allTools = /* @__PURE__ */ new Set([...permitted, ...forbidden, ...seen.keys()]);
4289
+ if (allTools.size === 0) {
4290
+ process.stdout.write(dim(" No rules and no decisions logged yet.\n"));
4291
+ process.exit(0);
4292
+ }
4293
+ const rows = [...allTools].sort().map((t) => {
4294
+ const label = forbidden.has(t) ? "forbid" : permitted.has(t) ? "permit" : "default-deny";
4295
+ const colored = forbidden.has(t) ? red(label) : permitted.has(t) ? green(label) : yellow(label);
4296
+ const pad = " ".repeat(Math.max(1, 13 - label.length));
4297
+ const s = seen.get(t);
4298
+ const hits = s ? dim(` ${s.allow} allowed, ${s.deny} denied`) : "";
4299
+ return ` ${colored}${pad}${bold(t)}${hits}`;
4300
+ });
4301
+ process.stdout.write(rows.join("\n") + "\n");
4302
+ process.stdout.write(`
4303
+ ${dim("Allow a tool: npx protect-mcp policy allow <ToolName> \xB7 Block one: policy deny <ToolName>")}
4304
+ `);
4305
+ process.exit(0);
4306
+ }
4307
+ process.stderr.write("Usage: npx protect-mcp policy <list|show|allow <tool>|deny <tool>|path>\n");
4308
+ process.exit(1);
4309
+ }
4164
4310
  async function main() {
4165
4311
  sendInstallTelemetry().catch(() => {
4166
4312
  });
@@ -4230,6 +4376,10 @@ async function main() {
4230
4376
  await handleSample(args.slice(1));
4231
4377
  return;
4232
4378
  }
4379
+ if (args[0] === "policy") {
4380
+ await handlePolicy(args.slice(1));
4381
+ return;
4382
+ }
4233
4383
  if (args[0] === "init-hooks") {
4234
4384
  await handleInitHooks(args.slice(1));
4235
4385
  process.exit(0);
@@ -1242,6 +1242,7 @@ async function handlePreToolUse(input, state) {
1242
1242
  ...input.teamName && { team_name: input.teamName },
1243
1243
  ...input.agentType && { agent_type: input.agentType }
1244
1244
  };
1245
+ maybeReloadCedar(state);
1245
1246
  if (state.cedarPolicies) {
1246
1247
  try {
1247
1248
  const cedarDecision = await evaluateCedar(state.cedarPolicies, {
@@ -1279,10 +1280,13 @@ async function handlePreToolUse(input, state) {
1279
1280
  sandbox_state: detectSandboxState(),
1280
1281
  plan_receipt_id: state.activePlanReceiptId || void 0
1281
1282
  });
1283
+ const isDefaultDeny = !reason || reason === "cedar_deny" || /reason":\[\]/.test(reason);
1284
+ const policyRef = state.cedarDir ? ` Policy: ${state.cedarDir}.` : "";
1285
+ 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`;
1282
1286
  if (denyCount === 1) {
1283
1287
  process.stderr.write(
1284
- `[PROTECT_MCP] No Cedar permit for "${toolName}" \u2014 suggest:
1285
- ${suggestion}
1288
+ `[PROTECT_MCP] Denied "${toolName}" (${isDefaultDeny ? "default-deny" : "forbid"}).
1289
+ Allow with: npx protect-mcp policy allow ${toolName}
1286
1290
  `
1287
1291
  );
1288
1292
  }
@@ -1290,7 +1294,7 @@ async function handlePreToolUse(input, state) {
1290
1294
  hookSpecificOutput: {
1291
1295
  hookEventName: "PreToolUse",
1292
1296
  permissionDecision: "deny",
1293
- permissionDecisionReason: `[ScopeBlind] Denied by Cedar policy. ${reason}. Forbidden: "${toolName}" is not permitted. Try a read-only alternative.` + (denyCount > 1 ? ` (attempt ${denyCount})` : "")
1297
+ permissionDecisionReason: `[ScopeBlind] Denied "${toolName}".${howTo}` + (denyCount > 1 ? ` (attempt ${denyCount})` : "")
1294
1298
  }
1295
1299
  };
1296
1300
  }
@@ -1789,6 +1793,9 @@ async function startHookServer(options = {}) {
1789
1793
  }
1790
1794
  const state = {
1791
1795
  cedarPolicies,
1796
+ cedarDir: cedarDir || null,
1797
+ cedarMtimeMs: cedarDir ? newestCedarMtime(cedarDir) : 0,
1798
+ cedarCheckedMs: 0,
1792
1799
  jsonPolicy,
1793
1800
  rateLimitStore: /* @__PURE__ */ new Map(),
1794
1801
  receiptBuffer: new ReceiptBuffer(),
@@ -1979,6 +1986,40 @@ async function startHookServer(options = {}) {
1979
1986
  process.on("SIGTERM", shutdown);
1980
1987
  return server;
1981
1988
  }
1989
+ function newestCedarMtime(dir) {
1990
+ try {
1991
+ let newest = 0;
1992
+ for (const f of (0, import_node_fs5.readdirSync)(dir)) {
1993
+ if (!f.endsWith(".cedar")) continue;
1994
+ const m = (0, import_node_fs5.statSync)((0, import_node_path3.join)(dir, f)).mtimeMs;
1995
+ if (m > newest) newest = m;
1996
+ }
1997
+ return newest;
1998
+ } catch {
1999
+ return 0;
2000
+ }
2001
+ }
2002
+ var CEDAR_CHECK_THROTTLE_MS = 2e3;
2003
+ function maybeReloadCedar(state) {
2004
+ if (!state.cedarDir) return;
2005
+ const nowMs = Date.now();
2006
+ if (nowMs - state.cedarCheckedMs < CEDAR_CHECK_THROTTLE_MS) return;
2007
+ state.cedarCheckedMs = nowMs;
2008
+ const m = newestCedarMtime(state.cedarDir);
2009
+ if (m <= state.cedarMtimeMs) return;
2010
+ try {
2011
+ const reloaded = loadCedarPolicies(state.cedarDir);
2012
+ state.cedarPolicies = reloaded;
2013
+ state.policyDigest = reloaded.digest;
2014
+ state.cedarMtimeMs = m;
2015
+ process.stderr.write(`[PROTECT_MCP] Cedar policy reloaded (digest: ${reloaded.digest}) after on-disk change.
2016
+ `);
2017
+ } catch (err) {
2018
+ process.stderr.write(`[PROTECT_MCP] Cedar reload failed, keeping the previous policy: ${err instanceof Error ? err.message : err}
2019
+ `);
2020
+ state.cedarMtimeMs = m;
2021
+ }
2022
+ }
1982
2023
  function findCedarDir() {
1983
2024
  for (const candidate of ["cedar", "policies", "."]) {
1984
2025
  try {
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  startHookServer
3
- } from "./chunk-H332ZNJ6.mjs";
3
+ } from "./chunk-WCZJGEBO.mjs";
4
4
  import "./chunk-WWPQNIVF.mjs";
5
5
  import "./chunk-AYNQIEN7.mjs";
6
6
  import "./chunk-XLJUZ4WO.mjs";
package/dist/index.js CHANGED
@@ -40565,6 +40565,7 @@ async function handlePreToolUse(input, state) {
40565
40565
  ...input.teamName && { team_name: input.teamName },
40566
40566
  ...input.agentType && { agent_type: input.agentType }
40567
40567
  };
40568
+ maybeReloadCedar(state);
40568
40569
  if (state.cedarPolicies) {
40569
40570
  try {
40570
40571
  const cedarDecision = await evaluateCedar(state.cedarPolicies, {
@@ -40602,10 +40603,13 @@ async function handlePreToolUse(input, state) {
40602
40603
  sandbox_state: detectSandboxState(),
40603
40604
  plan_receipt_id: state.activePlanReceiptId || void 0
40604
40605
  });
40606
+ const isDefaultDeny = !reason || reason === "cedar_deny" || /reason":\[\]/.test(reason);
40607
+ const policyRef = state.cedarDir ? ` Policy: ${state.cedarDir}.` : "";
40608
+ 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`;
40605
40609
  if (denyCount === 1) {
40606
40610
  process.stderr.write(
40607
- `[PROTECT_MCP] No Cedar permit for "${toolName}" \u2014 suggest:
40608
- ${suggestion}
40611
+ `[PROTECT_MCP] Denied "${toolName}" (${isDefaultDeny ? "default-deny" : "forbid"}).
40612
+ Allow with: npx protect-mcp policy allow ${toolName}
40609
40613
  `
40610
40614
  );
40611
40615
  }
@@ -40613,7 +40617,7 @@ async function handlePreToolUse(input, state) {
40613
40617
  hookSpecificOutput: {
40614
40618
  hookEventName: "PreToolUse",
40615
40619
  permissionDecision: "deny",
40616
- permissionDecisionReason: `[ScopeBlind] Denied by Cedar policy. ${reason}. Forbidden: "${toolName}" is not permitted. Try a read-only alternative.` + (denyCount > 1 ? ` (attempt ${denyCount})` : "")
40620
+ permissionDecisionReason: `[ScopeBlind] Denied "${toolName}".${howTo}` + (denyCount > 1 ? ` (attempt ${denyCount})` : "")
40617
40621
  }
40618
40622
  };
40619
40623
  }
@@ -41112,6 +41116,9 @@ async function startHookServer(options = {}) {
41112
41116
  }
41113
41117
  const state = {
41114
41118
  cedarPolicies,
41119
+ cedarDir: cedarDir || null,
41120
+ cedarMtimeMs: cedarDir ? newestCedarMtime(cedarDir) : 0,
41121
+ cedarCheckedMs: 0,
41115
41122
  jsonPolicy,
41116
41123
  rateLimitStore: /* @__PURE__ */ new Map(),
41117
41124
  receiptBuffer: new ReceiptBuffer(),
@@ -41302,6 +41309,40 @@ async function startHookServer(options = {}) {
41302
41309
  process.on("SIGTERM", shutdown);
41303
41310
  return server;
41304
41311
  }
41312
+ function newestCedarMtime(dir) {
41313
+ try {
41314
+ let newest = 0;
41315
+ for (const f of (0, import_node_fs9.readdirSync)(dir)) {
41316
+ if (!f.endsWith(".cedar")) continue;
41317
+ const m = (0, import_node_fs9.statSync)((0, import_node_path5.join)(dir, f)).mtimeMs;
41318
+ if (m > newest) newest = m;
41319
+ }
41320
+ return newest;
41321
+ } catch {
41322
+ return 0;
41323
+ }
41324
+ }
41325
+ var CEDAR_CHECK_THROTTLE_MS = 2e3;
41326
+ function maybeReloadCedar(state) {
41327
+ if (!state.cedarDir) return;
41328
+ const nowMs = Date.now();
41329
+ if (nowMs - state.cedarCheckedMs < CEDAR_CHECK_THROTTLE_MS) return;
41330
+ state.cedarCheckedMs = nowMs;
41331
+ const m = newestCedarMtime(state.cedarDir);
41332
+ if (m <= state.cedarMtimeMs) return;
41333
+ try {
41334
+ const reloaded = loadCedarPolicies(state.cedarDir);
41335
+ state.cedarPolicies = reloaded;
41336
+ state.policyDigest = reloaded.digest;
41337
+ state.cedarMtimeMs = m;
41338
+ process.stderr.write(`[PROTECT_MCP] Cedar policy reloaded (digest: ${reloaded.digest}) after on-disk change.
41339
+ `);
41340
+ } catch (err) {
41341
+ process.stderr.write(`[PROTECT_MCP] Cedar reload failed, keeping the previous policy: ${err instanceof Error ? err.message : err}
41342
+ `);
41343
+ state.cedarMtimeMs = m;
41344
+ }
41345
+ }
41305
41346
  function findCedarDir() {
41306
41347
  for (const candidate of ["cedar", "policies", "."]) {
41307
41348
  try {
package/dist/index.mjs CHANGED
@@ -54,7 +54,7 @@ import {
54
54
  forwardReceipt,
55
55
  getScopeBlindBridge,
56
56
  startHookServer
57
- } from "./chunk-H332ZNJ6.mjs";
57
+ } from "./chunk-WCZJGEBO.mjs";
58
58
  import "./chunk-WWPQNIVF.mjs";
59
59
  import {
60
60
  sha256 as sha2562
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "protect-mcp",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "mcpName": "com.scopeblind/protect-mcp",
5
5
  "description": "Fail-closed Cedar policy gate + Ed25519 signed receipts for AI agent tool calls. Denies on any policy error, proves the gate is live with a startup self-test, and turns every decision into a local searchable record you own. The open gate behind Legate by ScopeBlind. scopeblind.com",
6
6
  "main": "dist/index.js",