protect-mcp 0.3.0 → 0.3.2

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/README.md CHANGED
@@ -7,6 +7,9 @@ Security gateway for MCP servers. Shadow-mode logs by default, per-tool policies
7
7
  ## Quick Start
8
8
 
9
9
  ```bash
10
+ # Wrap an existing OpenClaw / MCP config into a usable pack
11
+ npx @scopeblind/passport wrap --runtime openclaw --config ./openclaw.json --policy email-safe
12
+
10
13
  # Shadow mode — log every tool call, enforce nothing
11
14
  npx protect-mcp -- node my-server.js
12
15
 
@@ -18,6 +21,9 @@ npx protect-mcp --policy protect-mcp.json -- node my-server.js
18
21
 
19
22
  # Enforce mode
20
23
  npx protect-mcp --policy protect-mcp.json --enforce -- node my-server.js
24
+
25
+ # Export an offline-verifiable audit bundle
26
+ npx protect-mcp bundle --output audit.json
21
27
  ```
22
28
 
23
29
  ## What It Does
@@ -39,7 +45,7 @@ All other MCP messages (`initialize`, `tools/list`, notifications) pass through
39
45
 
40
46
  - **Per-tool policies** — block destructive tools, rate-limit expensive ones, and attach minimum-tier requirements
41
47
  - **Structured decision logs** — every decision is emitted to `stderr` with `[PROTECT_MCP]`
42
- - **Optional local signed receipts** — generated when you run with a policy containing `signing.key_path`
48
+ - **Optional local signed receipts** — generated when you run with a policy containing `signing.key_path`, persisted to `.protect-mcp-receipts.jsonl`, and exposed at `http://127.0.0.1:9876/receipts`
43
49
  - **Offline verification** — verify receipts or bundles with `npx @veritasacta/verify`
44
50
  - **No account required** — local keys, local policy, local process
45
51
 
@@ -123,6 +129,10 @@ protect-mcp init
123
129
 
124
130
  Commands:
125
131
  init Generate Ed25519 keypair + config template
132
+ status Show decision stats and local passport identity
133
+ digest Generate a local human-readable summary
134
+ receipts Show recent persisted signed receipts
135
+ bundle Export an offline-verifiable audit bundle
126
136
 
127
137
  Options:
128
138
  --policy <path> Policy/config JSON file
@@ -0,0 +1,8 @@
1
+ import {
2
+ collectSignedReceipts,
3
+ createAuditBundle
4
+ } from "./chunk-5JXFV37Y.mjs";
5
+ export {
6
+ collectSignedReceipts,
7
+ createAuditBundle
8
+ };
@@ -0,0 +1,53 @@
1
+ // src/bundle.ts
2
+ function createAuditBundle(opts) {
3
+ const receipts = opts.receipts.filter(
4
+ (r) => r && typeof r === "object" && typeof r.signature === "string"
5
+ );
6
+ if (receipts.length === 0) {
7
+ throw new Error("Audit bundle requires at least one signed receipt");
8
+ }
9
+ const keyMap = /* @__PURE__ */ new Map();
10
+ for (const key of opts.signingKeys) {
11
+ if (!keyMap.has(key.kid)) {
12
+ keyMap.set(key.kid, key);
13
+ }
14
+ }
15
+ let timeRange = opts.timeRange || null;
16
+ if (!timeRange) {
17
+ const timestamps = receipts.map((r) => r.issued_at || r.timestamp).filter(Boolean).sort();
18
+ if (timestamps.length > 0) {
19
+ timeRange = {
20
+ from: timestamps[0],
21
+ to: timestamps[timestamps.length - 1]
22
+ };
23
+ }
24
+ }
25
+ return {
26
+ format: "scopeblind:audit-bundle",
27
+ version: 1,
28
+ exported_at: (/* @__PURE__ */ new Date()).toISOString(),
29
+ tenant: opts.tenant,
30
+ time_range: timeRange,
31
+ receipts,
32
+ anchors: opts.anchors || [],
33
+ verification: {
34
+ algorithm: "ed25519",
35
+ signing_keys: Array.from(keyMap.values()),
36
+ instructions: `Verify each receipt by: (1) remove the "signature" field, (2) canonicalize the remaining object with JCS (sorted keys at every level), (3) encode as UTF-8 bytes, (4) verify the Ed25519 signature using the signing key matching the receipt's "kid" field. CLI: npx @veritasacta/verify bundle.json --bundle`
37
+ }
38
+ };
39
+ }
40
+ function collectSignedReceipts(logs) {
41
+ return logs.filter((log) => log.v === 2).map((log) => {
42
+ const logRecord = log;
43
+ if (logRecord.receipt) {
44
+ return logRecord.receipt;
45
+ }
46
+ return logRecord;
47
+ }).filter((r) => typeof r.signature === "string");
48
+ }
49
+
50
+ export {
51
+ createAuditBundle,
52
+ collectSignedReceipts
53
+ };
@@ -317,7 +317,11 @@ async function initSigning(config) {
317
317
  return warnings;
318
318
  }
319
319
  try {
320
- artifactsModule = await import("@veritasacta/artifacts");
320
+ const moduleName = "@veritasacta/artifacts";
321
+ artifactsModule = await import(
322
+ /* @vite-ignore */
323
+ moduleName
324
+ );
321
325
  } catch {
322
326
  warnings.push("signing: @veritasacta/artifacts not available \u2014 receipts will be unsigned");
323
327
  return warnings;
@@ -336,8 +340,8 @@ async function initSigning(config) {
336
340
  signerState = {
337
341
  privateKey: keyData.privateKey,
338
342
  publicKey: keyData.publicKey,
339
- kid: artifactsModule.computeKid(keyData.publicKey),
340
- issuer: config.issuer || "protect-mcp"
343
+ kid: keyData.kid || artifactsModule.computeKid(keyData.publicKey),
344
+ issuer: config.issuer || keyData.issuer || "protect-mcp"
341
345
  };
342
346
  } catch (err) {
343
347
  warnings.push(`signing: failed to load key file: ${err instanceof Error ? err.message : err}`);
@@ -524,7 +528,7 @@ function buildDecisionContext(toolName, tier, opts) {
524
528
 
525
529
  // src/gateway.ts
526
530
  import { spawn } from "child_process";
527
- import { randomUUID } from "crypto";
531
+ import { randomUUID, randomBytes } from "crypto";
528
532
  import { createInterface } from "readline";
529
533
  import { appendFileSync } from "fs";
530
534
  import { join as join3 } from "path";
@@ -556,13 +560,16 @@ var ReceiptBuffer = class {
556
560
  count() {
557
561
  return this.receipts.length;
558
562
  }
563
+ getLatest() {
564
+ return this.receipts.length > 0 ? this.receipts[this.receipts.length - 1] : void 0;
565
+ }
559
566
  };
560
- function startStatusServer(config, receiptBuffer) {
567
+ function startStatusServer(config, receiptBuffer, approvalStore, approvalNonce) {
561
568
  const startTime = Date.now();
562
569
  const logDir = process.cwd();
563
570
  const server = createServer((req, res) => {
564
571
  res.setHeader("Access-Control-Allow-Origin", "*");
565
- res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
572
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
566
573
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
567
574
  res.setHeader("Content-Type", "application/json");
568
575
  if (req.method === "OPTIONS") {
@@ -579,18 +586,30 @@ function startStatusServer(config, receiptBuffer) {
579
586
  handleStatus(res, logDir);
580
587
  } else if (path === "/receipts") {
581
588
  handleReceipts(res, receiptBuffer, url);
589
+ } else if (path === "/receipts/latest") {
590
+ handleReceiptLatest(res, receiptBuffer);
582
591
  } else if (path.startsWith("/receipts/")) {
583
592
  const id = path.slice("/receipts/".length);
584
593
  handleReceiptById(res, receiptBuffer, id);
594
+ } else if (path === "/approve" && req.method === "POST") {
595
+ handleApprove(req, res, approvalStore, approvalNonce);
596
+ } else if (path === "/approvals" && req.method === "GET") {
597
+ handleListApprovals(res, approvalStore);
585
598
  } else {
586
599
  res.writeHead(404);
587
- res.end(JSON.stringify({ error: "not_found", endpoints: ["/health", "/status", "/receipts", "/receipts/:id"] }));
600
+ res.end(JSON.stringify({ error: "not_found", endpoints: ["/health", "/status", "/receipts", "/receipts/latest", "/receipts/:id", "/approve", "/approvals"] }));
588
601
  }
589
602
  } catch (err) {
590
603
  res.writeHead(500);
591
604
  res.end(JSON.stringify({ error: "internal_error" }));
592
605
  }
593
606
  });
607
+ server.on("error", (err) => {
608
+ if (config.verbose) {
609
+ process.stderr.write(`[PROTECT_MCP] HTTP status server error: ${err.message}
610
+ `);
611
+ }
612
+ });
594
613
  server.listen(config.port, "127.0.0.1", () => {
595
614
  if (config.verbose) {
596
615
  process.stderr.write(`[PROTECT_MCP] HTTP status server listening on http://127.0.0.1:${config.port}
@@ -606,7 +625,7 @@ function handleHealth(res, startTime, config) {
606
625
  status: "ok",
607
626
  uptime_ms: Date.now() - startTime,
608
627
  mode: config.mode,
609
- version: "0.3.0"
628
+ version: "0.3.1"
610
629
  }));
611
630
  }
612
631
  function handleStatus(res, logDir) {
@@ -655,6 +674,16 @@ function handleReceipts(res, buffer, url) {
655
674
  receipts
656
675
  }));
657
676
  }
677
+ function handleReceiptLatest(res, buffer) {
678
+ const latest = buffer.getLatest();
679
+ if (!latest) {
680
+ res.writeHead(404);
681
+ res.end(JSON.stringify({ error: "no_receipts", message: "No receipts yet. Make a tool call through protect-mcp first." }));
682
+ return;
683
+ }
684
+ res.writeHead(200);
685
+ res.end(JSON.stringify(latest));
686
+ }
658
687
  function handleReceiptById(res, buffer, id) {
659
688
  const receipt = buffer.getById(id);
660
689
  if (!receipt) {
@@ -665,22 +694,92 @@ function handleReceiptById(res, buffer, id) {
665
694
  res.writeHead(200);
666
695
  res.end(JSON.stringify(receipt));
667
696
  }
697
+ function handleApprove(req, res, approvalStore, expectedNonce) {
698
+ if (!approvalStore) {
699
+ res.writeHead(503);
700
+ res.end(JSON.stringify({ error: "approval_store_not_available" }));
701
+ return;
702
+ }
703
+ let body = "";
704
+ req.on("data", (chunk) => {
705
+ body += chunk.toString();
706
+ });
707
+ req.on("end", () => {
708
+ try {
709
+ const { request_id, tool, mode, nonce } = JSON.parse(body);
710
+ if (expectedNonce && nonce !== expectedNonce) {
711
+ res.writeHead(403);
712
+ res.end(JSON.stringify({ error: "invalid_nonce", message: "Approval nonce does not match. Check stderr output for the correct nonce." }));
713
+ return;
714
+ }
715
+ if (!tool || typeof tool !== "string") {
716
+ res.writeHead(400);
717
+ res.end(JSON.stringify({ error: "missing_tool", usage: '{"request_id":"abc123","tool":"send_email","mode":"once|always","nonce":"..."}' }));
718
+ return;
719
+ }
720
+ const grantMode = mode === "always" ? "always" : "once";
721
+ const ttlMs = grantMode === "once" ? 5 * 60 * 1e3 : 24 * 60 * 60 * 1e3;
722
+ const grantEntry = { tool, mode: grantMode, expires_at: Date.now() + ttlMs };
723
+ if (grantMode === "always") {
724
+ approvalStore.set(`always:${tool}`, grantEntry);
725
+ } else if (request_id) {
726
+ approvalStore.set(request_id, grantEntry);
727
+ } else {
728
+ approvalStore.set(tool, grantEntry);
729
+ }
730
+ res.writeHead(200);
731
+ res.end(JSON.stringify({
732
+ approved: true,
733
+ request_id: request_id || null,
734
+ tool,
735
+ mode: grantMode,
736
+ expires_in_seconds: ttlMs / 1e3
737
+ }));
738
+ } catch {
739
+ res.writeHead(400);
740
+ res.end(JSON.stringify({ error: "invalid_json", usage: '{"request_id":"abc123","tool":"send_email","mode":"once","nonce":"..."}' }));
741
+ }
742
+ });
743
+ }
744
+ function handleListApprovals(res, approvalStore) {
745
+ if (!approvalStore) {
746
+ res.writeHead(200);
747
+ res.end(JSON.stringify({ grants: [] }));
748
+ return;
749
+ }
750
+ const now = Date.now();
751
+ const grants = [];
752
+ for (const [key, grant] of approvalStore) {
753
+ if (now < grant.expires_at) {
754
+ grants.push({ key, tool: grant.tool, mode: grant.mode, expires_in_seconds: Math.round((grant.expires_at - now) / 1e3) });
755
+ }
756
+ }
757
+ res.writeHead(200);
758
+ res.end(JSON.stringify({ grants }));
759
+ }
668
760
 
669
761
  // src/gateway.ts
670
762
  var LOG_FILE2 = ".protect-mcp-log.jsonl";
763
+ var RECEIPTS_FILE = ".protect-mcp-receipts.jsonl";
671
764
  var ProtectGateway = class {
672
765
  child = null;
673
766
  config;
674
767
  rateLimitStore = /* @__PURE__ */ new Map();
675
768
  clientReader = null;
676
769
  logFilePath;
770
+ receiptFilePath;
677
771
  evidenceStore;
678
772
  receiptBuffer;
773
+ /** Approval grants keyed by request_id (scoped to the specific action that was requested) */
774
+ approvalStore = /* @__PURE__ */ new Map();
775
+ /** Random nonce generated at startup — required for approval endpoint authentication */
776
+ approvalNonce = randomBytes(16).toString("hex");
679
777
  currentTier = "unknown";
680
778
  admissionResult = null;
681
779
  constructor(config) {
682
780
  this.config = config;
683
781
  this.logFilePath = join3(process.cwd(), LOG_FILE2);
782
+ this.receiptFilePath = join3(process.cwd(), RECEIPTS_FILE);
684
783
  this.evidenceStore = new EvidenceStore();
685
784
  this.receiptBuffer = new ReceiptBuffer();
686
785
  }
@@ -704,12 +803,15 @@ var ProtectGateway = class {
704
803
  this.log(`External PDP: ${this.config.policy.external?.endpoint || "not configured"}`);
705
804
  }
706
805
  }
806
+ this.log(`Approval nonce: ${this.approvalNonce}`);
707
807
  const httpPort = parseInt(process.env.PROTECT_MCP_HTTP_PORT || "9876", 10);
708
808
  if (httpPort > 0) {
709
809
  try {
710
810
  startStatusServer(
711
811
  { port: httpPort, mode, verbose },
712
- this.receiptBuffer
812
+ this.receiptBuffer,
813
+ this.approvalStore,
814
+ this.approvalNonce
713
815
  );
714
816
  } catch {
715
817
  if (verbose) this.log(`HTTP status server could not start on port ${httpPort}`);
@@ -811,8 +913,31 @@ var ProtectGateway = class {
811
913
  async interceptToolCall(request) {
812
914
  const toolName = request.params?.name || "unknown";
813
915
  const requestId = randomUUID().slice(0, 12);
814
- const toolPolicy = getToolPolicy(toolName, this.config.policy);
815
916
  const mode = this.config.enforce ? "enforce" : "shadow";
917
+ let resolvedAgentKid = this.admissionResult?.agent_id;
918
+ let effectiveToolPolicy;
919
+ if (this.config.multiAgent?.enabled) {
920
+ const paramKid = request.params?._passport_kid;
921
+ if (paramKid) resolvedAgentKid = paramKid;
922
+ const agentOverrides = resolvedAgentKid ? this.config.multiAgent.agentPolicies?.[resolvedAgentKid] : void 0;
923
+ if (agentOverrides && agentOverrides[toolName]) {
924
+ effectiveToolPolicy = { ...getToolPolicy(toolName, this.config.policy), ...agentOverrides[toolName] };
925
+ } else if (!resolvedAgentKid && this.config.multiAgent.unknownAgentPolicy === "deny") {
926
+ this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: "unknown_agent_denied", request_id: requestId, tier: this.currentTier });
927
+ if (this.config.enforce) {
928
+ return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" denied: unidentified agent`);
929
+ }
930
+ return null;
931
+ } else {
932
+ effectiveToolPolicy = getToolPolicy(toolName, this.config.policy);
933
+ }
934
+ if (this.config.verbose && resolvedAgentKid) {
935
+ this.log(`Multi-agent: resolved kid=${resolvedAgentKid} for tool=${toolName}`);
936
+ }
937
+ } else {
938
+ effectiveToolPolicy = getToolPolicy(toolName, this.config.policy);
939
+ }
940
+ const toolPolicy = effectiveToolPolicy;
816
941
  let credentialRef;
817
942
  if (this.config.credentials) {
818
943
  const cred = resolveCredential(toolName, this.config.credentials);
@@ -863,6 +988,32 @@ var ProtectGateway = class {
863
988
  }
864
989
  return null;
865
990
  }
991
+ if (toolPolicy.require_approval) {
992
+ const grant = this.approvalStore.get(requestId);
993
+ const alwaysGrant = this.approvalStore.get(`always:${toolName}`);
994
+ if (grant && Date.now() < grant.expires_at || alwaysGrant && Date.now() < alwaysGrant.expires_at) {
995
+ if (grant && grant.mode === "once") this.approvalStore.delete(requestId);
996
+ this.emitDecisionLog({ tool: toolName, decision: "allow", reason_code: "approval_granted", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
997
+ return null;
998
+ }
999
+ this.emitDecisionLog({ tool: toolName, decision: "require_approval", reason_code: "requires_human_approval", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
1000
+ if (this.config.enforce) {
1001
+ return {
1002
+ jsonrpc: "2.0",
1003
+ id: request.id,
1004
+ result: {
1005
+ content: [
1006
+ {
1007
+ type: "text",
1008
+ text: `REQUIRES_APPROVAL: The tool "${toolName}" requires human approval before execution. Request ID: ${requestId}. Approval nonce: ${this.approvalNonce}. Tell the user you need their approval to use "${toolName}" and will retry when granted. Do NOT retry this tool call until the user explicitly approves it.`
1009
+ }
1010
+ ],
1011
+ isError: true
1012
+ }
1013
+ };
1014
+ }
1015
+ return null;
1016
+ }
866
1017
  const rateSpec = this.getTierRateLimit(toolPolicy, this.currentTier);
867
1018
  if (rateSpec) {
868
1019
  try {
@@ -920,6 +1071,10 @@ var ProtectGateway = class {
920
1071
  if (signed.signed) {
921
1072
  process.stderr.write(`[PROTECT_MCP_RECEIPT] ${signed.signed}
922
1073
  `);
1074
+ try {
1075
+ appendFileSync(this.receiptFilePath, signed.signed + "\n");
1076
+ } catch {
1077
+ }
923
1078
  this.receiptBuffer.add(log.request_id, signed.signed);
924
1079
  if (this.admissionResult?.agent_id) {
925
1080
  this.evidenceStore.record(this.admissionResult.agent_id, this.config.signing?.issuer || "protect-mcp");
@@ -957,6 +1112,137 @@ var ProtectGateway = class {
957
1112
  }
958
1113
  };
959
1114
 
1115
+ // src/simulate.ts
1116
+ import { readFileSync as readFileSync5 } from "fs";
1117
+ function parseLogFile(path) {
1118
+ const raw = readFileSync5(path, "utf-8");
1119
+ const entries = [];
1120
+ for (const line of raw.split("\n")) {
1121
+ const trimmed = line.trim();
1122
+ if (!trimmed) continue;
1123
+ const jsonStr = trimmed.replace(/^\[PROTECT_MCP\]\s*/, "");
1124
+ try {
1125
+ const parsed = JSON.parse(jsonStr);
1126
+ if (parsed.tool && parsed.decision) {
1127
+ entries.push(parsed);
1128
+ }
1129
+ } catch {
1130
+ }
1131
+ }
1132
+ return entries;
1133
+ }
1134
+ function simulate(entries, policy, tier = "unknown") {
1135
+ const rateLimitStore = /* @__PURE__ */ new Map();
1136
+ const toolResults = /* @__PURE__ */ new Map();
1137
+ const totals = {
1138
+ allow: 0,
1139
+ block: 0,
1140
+ rate_limited: 0,
1141
+ require_approval: 0,
1142
+ tier_insufficient: 0
1143
+ };
1144
+ const originalTotals = { allow: 0, deny: 0 };
1145
+ const changes = [];
1146
+ for (const entry of entries) {
1147
+ const toolName = entry.tool;
1148
+ const toolPolicy = getToolPolicy(toolName, policy);
1149
+ if (entry.decision === "allow") {
1150
+ originalTotals.allow++;
1151
+ } else {
1152
+ originalTotals.deny++;
1153
+ }
1154
+ let newDecision;
1155
+ if (toolPolicy.block) {
1156
+ newDecision = "block";
1157
+ } else if (toolPolicy.min_tier && !meetsMinTier(tier, toolPolicy.min_tier)) {
1158
+ newDecision = "tier_insufficient";
1159
+ } else if (toolPolicy.require_approval) {
1160
+ newDecision = "require_approval";
1161
+ } else if (toolPolicy.rate_limit) {
1162
+ const limit = parseRateLimit(toolPolicy.rate_limit);
1163
+ const result = checkRateLimit(toolName, limit, rateLimitStore);
1164
+ newDecision = result.allowed ? "allow" : "rate_limited";
1165
+ } else {
1166
+ newDecision = "allow";
1167
+ }
1168
+ totals[newDecision]++;
1169
+ if (!toolResults.has(toolName)) {
1170
+ toolResults.set(toolName, {
1171
+ tool: toolName,
1172
+ calls: 0,
1173
+ results: { allow: 0, block: 0, rate_limited: 0, require_approval: 0, tier_insufficient: 0 },
1174
+ original: { allow: 0, deny: 0 }
1175
+ });
1176
+ }
1177
+ const tr = toolResults.get(toolName);
1178
+ tr.calls++;
1179
+ tr.results[newDecision]++;
1180
+ if (entry.decision === "allow") {
1181
+ tr.original.allow++;
1182
+ } else {
1183
+ tr.original.deny++;
1184
+ }
1185
+ }
1186
+ for (const [tool, result] of toolResults) {
1187
+ const wasAllBlocked = result.original.allow === 0;
1188
+ const nowAllBlocked = result.results.allow === 0;
1189
+ const wasAllAllowed = result.original.deny === 0;
1190
+ if (wasAllAllowed && result.results.block > 0) {
1191
+ changes.push(`${tool}: ${result.results.block} calls would be blocked (was: all allowed)`);
1192
+ }
1193
+ if (wasAllAllowed && result.results.rate_limited > 0) {
1194
+ changes.push(`${tool}: ${result.results.rate_limited} calls would be rate-limited (was: all allowed)`);
1195
+ }
1196
+ if (wasAllAllowed && result.results.require_approval > 0) {
1197
+ changes.push(`${tool}: ${result.results.require_approval} calls would require approval (was: all allowed)`);
1198
+ }
1199
+ if (wasAllAllowed && result.results.tier_insufficient > 0) {
1200
+ changes.push(`${tool}: ${result.results.tier_insufficient} calls would fail tier check (was: all allowed)`);
1201
+ }
1202
+ if (wasAllBlocked && result.results.allow > 0 && !nowAllBlocked) {
1203
+ changes.push(`${tool}: ${result.results.allow} calls would now be allowed (was: all blocked)`);
1204
+ }
1205
+ }
1206
+ return {
1207
+ policy_file: "",
1208
+ log_file: "",
1209
+ total_calls: entries.length,
1210
+ results: totals,
1211
+ original: originalTotals,
1212
+ tool_breakdown: Array.from(toolResults.values()).sort((a, b) => b.calls - a.calls),
1213
+ changes
1214
+ };
1215
+ }
1216
+ function formatSimulation(summary) {
1217
+ const lines = [];
1218
+ lines.push(`Simulating ${summary.policy_file} against ${summary.total_calls} recorded tool calls:
1219
+ `);
1220
+ const maxToolLen = Math.max(...summary.tool_breakdown.map((t) => t.tool.length), 4);
1221
+ for (const tr of summary.tool_breakdown) {
1222
+ const parts = [];
1223
+ if (tr.results.allow > 0) parts.push(`${tr.results.allow} allow`);
1224
+ if (tr.results.block > 0) parts.push(`\x1B[31m${tr.results.block} blocked\x1B[0m`);
1225
+ if (tr.results.rate_limited > 0) parts.push(`\x1B[33m${tr.results.rate_limited} rate_limited\x1B[0m`);
1226
+ if (tr.results.require_approval > 0) parts.push(`\x1B[36m${tr.results.require_approval} require_approval\x1B[0m`);
1227
+ if (tr.results.tier_insufficient > 0) parts.push(`\x1B[35m${tr.results.tier_insufficient} tier_insufficient\x1B[0m`);
1228
+ const originalParts = [];
1229
+ if (tr.original.allow > 0) originalParts.push(`${tr.original.allow} allow`);
1230
+ if (tr.original.deny > 0) originalParts.push(`${tr.original.deny} deny`);
1231
+ lines.push(` ${tr.tool.padEnd(maxToolLen)} \xD7 ${String(tr.calls).padStart(3)} \u2192 ${parts.join(", ")} (was: ${originalParts.join(", ")})`);
1232
+ }
1233
+ lines.push("");
1234
+ lines.push(`Summary: ${summary.results.allow} allow, ${summary.results.block} blocked, ${summary.results.rate_limited} rate_limited, ${summary.results.require_approval} require_approval, ${summary.results.tier_insufficient} tier_insufficient`);
1235
+ lines.push(` vs original: ${summary.original.allow} allow, ${summary.original.deny} deny`);
1236
+ if (summary.changes.length > 0) {
1237
+ lines.push("");
1238
+ lines.push("Changes:");
1239
+ for (const change of summary.changes) {
1240
+ lines.push(` \u2022 ${change}`);
1241
+ }
1242
+ }
1243
+ return lines.join("\n");
1244
+ }
1245
+
960
1246
  export {
961
1247
  loadPolicy,
962
1248
  getToolPolicy,
@@ -973,5 +1259,8 @@ export {
973
1259
  isSigningEnabled,
974
1260
  queryExternalPDP,
975
1261
  buildDecisionContext,
976
- ProtectGateway
1262
+ ProtectGateway,
1263
+ parseLogFile,
1264
+ simulate,
1265
+ formatSimulation
977
1266
  };