protect-mcp 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -2642,6 +2642,8 @@ var init_ed25519 = __esm({
2642
2642
  var import_node_child_process = require("child_process");
2643
2643
  var import_node_crypto2 = require("crypto");
2644
2644
  var import_node_readline = require("readline");
2645
+ var import_node_fs5 = require("fs");
2646
+ var import_node_path3 = require("path");
2645
2647
 
2646
2648
  // src/policy.ts
2647
2649
  var import_node_crypto = require("crypto");
@@ -2719,8 +2721,126 @@ function checkRateLimit(key, limit, store) {
2719
2721
  return { allowed: true, remaining: limit.count - timestamps.length };
2720
2722
  }
2721
2723
 
2724
+ // src/evidence-store.ts
2725
+ var import_node_fs2 = require("fs");
2726
+ var import_node_path = require("path");
2727
+ var DEFAULT_THRESHOLDS = {
2728
+ min_receipts: 10,
2729
+ min_epoch_span: 3,
2730
+ min_issuers: 2
2731
+ };
2732
+ var EvidenceStore = class {
2733
+ agents = /* @__PURE__ */ new Map();
2734
+ filePath;
2735
+ dirty = false;
2736
+ constructor(dir) {
2737
+ this.filePath = (0, import_node_path.join)(dir || process.cwd(), ".protect-mcp-evidence.json");
2738
+ this.load();
2739
+ }
2740
+ /**
2741
+ * Record a receipt observation for an agent.
2742
+ */
2743
+ record(agentId, issuer, timestamp) {
2744
+ const ts = timestamp || (/* @__PURE__ */ new Date()).toISOString();
2745
+ const epochHour = Math.floor(new Date(ts).getTime() / (3600 * 1e3));
2746
+ const existing = this.agents.get(agentId);
2747
+ const observation = {
2748
+ issuer,
2749
+ timestamp: ts,
2750
+ epoch_hour: epochHour
2751
+ };
2752
+ if (existing) {
2753
+ existing.receipts.push(observation);
2754
+ existing.last_seen = ts;
2755
+ if (existing.receipts.length > 200) {
2756
+ existing.receipts = existing.receipts.slice(-200);
2757
+ }
2758
+ } else {
2759
+ this.agents.set(agentId, {
2760
+ agent_id: agentId,
2761
+ receipts: [observation],
2762
+ first_seen: ts,
2763
+ last_seen: ts
2764
+ });
2765
+ }
2766
+ this.dirty = true;
2767
+ }
2768
+ /**
2769
+ * Get the evidence summary for an agent.
2770
+ */
2771
+ getSummary(agentId) {
2772
+ const record = this.agents.get(agentId);
2773
+ if (!record || record.receipts.length === 0) {
2774
+ return { receipt_count: 0, epoch_span: 0, issuer_count: 0 };
2775
+ }
2776
+ const uniqueIssuers = new Set(record.receipts.map((r) => r.issuer));
2777
+ const uniqueEpochs = new Set(record.receipts.map((r) => r.epoch_hour));
2778
+ return {
2779
+ receipt_count: record.receipts.length,
2780
+ epoch_span: uniqueEpochs.size,
2781
+ issuer_count: uniqueIssuers.size
2782
+ };
2783
+ }
2784
+ /**
2785
+ * Check if an agent meets the evidenced tier thresholds.
2786
+ */
2787
+ meetsEvidencedThreshold(agentId, thresholds = DEFAULT_THRESHOLDS) {
2788
+ const summary = this.getSummary(agentId);
2789
+ return summary.receipt_count >= thresholds.min_receipts && summary.epoch_span >= thresholds.min_epoch_span && summary.issuer_count >= thresholds.min_issuers;
2790
+ }
2791
+ /**
2792
+ * Persist to disk (call periodically or on shutdown).
2793
+ */
2794
+ save() {
2795
+ if (!this.dirty) return;
2796
+ const data = {};
2797
+ for (const [id, record] of this.agents) {
2798
+ data[id] = record;
2799
+ }
2800
+ try {
2801
+ (0, import_node_fs2.writeFileSync)(this.filePath, JSON.stringify({ v: 1, agents: data }, null, 2) + "\n");
2802
+ this.dirty = false;
2803
+ } catch {
2804
+ }
2805
+ }
2806
+ /**
2807
+ * Load from disk.
2808
+ */
2809
+ load() {
2810
+ if (!(0, import_node_fs2.existsSync)(this.filePath)) return;
2811
+ try {
2812
+ const raw = (0, import_node_fs2.readFileSync)(this.filePath, "utf-8");
2813
+ const parsed = JSON.parse(raw);
2814
+ if (parsed.agents && typeof parsed.agents === "object") {
2815
+ for (const [id, record] of Object.entries(parsed.agents)) {
2816
+ this.agents.set(id, record);
2817
+ }
2818
+ }
2819
+ } catch {
2820
+ }
2821
+ }
2822
+ /**
2823
+ * Get total agent count (for status display).
2824
+ */
2825
+ agentCount() {
2826
+ return this.agents.size;
2827
+ }
2828
+ /**
2829
+ * Get all agent summaries (for status display).
2830
+ */
2831
+ allSummaries() {
2832
+ const result = [];
2833
+ for (const [id] of this.agents) {
2834
+ result.push({ agent_id: id, summary: this.getSummary(id) });
2835
+ }
2836
+ return result;
2837
+ }
2838
+ };
2839
+
2722
2840
  // src/admission.ts
2723
- function evaluateTier(manifest, overrides) {
2841
+ function evaluateTier(manifest, opts) {
2842
+ const options = opts && ("evidenceStore" in opts || "overrides" in opts || "thresholds" in opts) ? opts : { overrides: opts };
2843
+ const { overrides, evidenceStore, thresholds } = options;
2724
2844
  if (!manifest) {
2725
2845
  return {
2726
2846
  tier: "unknown",
@@ -2746,7 +2866,8 @@ function evaluateTier(manifest, overrides) {
2746
2866
  if (manifest.signature_valid === true) {
2747
2867
  if (manifest.evidence_summary) {
2748
2868
  const es = manifest.evidence_summary;
2749
- if (es.receipt_count >= 10 && es.epoch_span >= 3 && es.issuer_count >= 2) {
2869
+ const t = thresholds || DEFAULT_THRESHOLDS;
2870
+ if (es.receipt_count >= t.min_receipts && es.epoch_span >= t.min_epoch_span && es.issuer_count >= t.min_issuers) {
2750
2871
  return {
2751
2872
  tier: "evidenced",
2752
2873
  agent_id: manifest.agent_id,
@@ -2755,6 +2876,16 @@ function evaluateTier(manifest, overrides) {
2755
2876
  };
2756
2877
  }
2757
2878
  }
2879
+ if (evidenceStore && manifest.agent_id) {
2880
+ if (evidenceStore.meetsEvidencedThreshold(manifest.agent_id, thresholds)) {
2881
+ return {
2882
+ tier: "evidenced",
2883
+ agent_id: manifest.agent_id,
2884
+ manifest_hash: manifest.manifest_hash,
2885
+ reason: "evidence_store_threshold_met"
2886
+ };
2887
+ }
2888
+ }
2758
2889
  return {
2759
2890
  tier: "signed-known",
2760
2891
  agent_id: manifest.agent_id,
@@ -2820,7 +2951,7 @@ function validateCredentials(credentials) {
2820
2951
  }
2821
2952
 
2822
2953
  // src/signing.ts
2823
- var import_node_fs2 = require("fs");
2954
+ var import_node_fs3 = require("fs");
2824
2955
  var signerState = null;
2825
2956
  var artifactsModule = null;
2826
2957
  async function initSigning(config) {
@@ -2835,12 +2966,12 @@ async function initSigning(config) {
2835
2966
  return warnings;
2836
2967
  }
2837
2968
  if (config.key_path) {
2838
- if (!(0, import_node_fs2.existsSync)(config.key_path)) {
2969
+ if (!(0, import_node_fs3.existsSync)(config.key_path)) {
2839
2970
  warnings.push(`signing: key file not found at ${config.key_path} \u2014 run "protect-mcp init" to generate`);
2840
2971
  return warnings;
2841
2972
  }
2842
2973
  try {
2843
- const keyData = JSON.parse((0, import_node_fs2.readFileSync)(config.key_path, "utf-8"));
2974
+ const keyData = JSON.parse((0, import_node_fs3.readFileSync)(config.key_path, "utf-8"));
2844
2975
  if (!keyData.privateKey || !keyData.publicKey) {
2845
2976
  warnings.push("signing: key file missing privateKey or publicKey fields");
2846
2977
  return warnings;
@@ -2904,21 +3035,283 @@ function isSigningEnabled() {
2904
3035
  return signerState !== null && artifactsModule !== null;
2905
3036
  }
2906
3037
 
3038
+ // src/external-pdp.ts
3039
+ async function queryExternalPDP(context, config) {
3040
+ const timeout = config.timeout_ms || 500;
3041
+ const controller = new AbortController();
3042
+ const timer = setTimeout(() => controller.abort(), timeout);
3043
+ try {
3044
+ const body = formatRequest(context, config.format || "generic");
3045
+ const response = await fetch(config.endpoint, {
3046
+ method: "POST",
3047
+ headers: { "Content-Type": "application/json" },
3048
+ body: JSON.stringify(body),
3049
+ signal: controller.signal
3050
+ });
3051
+ clearTimeout(timer);
3052
+ if (!response.ok) {
3053
+ return fallbackDecision(config, `PDP returned HTTP ${response.status}`);
3054
+ }
3055
+ const result = await response.json();
3056
+ return parseResponse(result, config.format || "generic");
3057
+ } catch (err) {
3058
+ clearTimeout(timer);
3059
+ if (err instanceof Error && err.name === "AbortError") {
3060
+ return fallbackDecision(config, `PDP timeout after ${timeout}ms`);
3061
+ }
3062
+ return fallbackDecision(config, `PDP error: ${err instanceof Error ? err.message : "unknown"}`);
3063
+ }
3064
+ }
3065
+ function formatRequest(context, format) {
3066
+ switch (format) {
3067
+ case "opa":
3068
+ return {
3069
+ input: {
3070
+ actor: context.actor,
3071
+ action: context.action,
3072
+ target: context.target,
3073
+ credential_ref: context.credential_ref,
3074
+ mode: context.mode,
3075
+ metadata: context.request_metadata
3076
+ }
3077
+ };
3078
+ case "cerbos":
3079
+ return {
3080
+ principal: {
3081
+ id: context.actor.id || "unknown",
3082
+ roles: [context.actor.tier],
3083
+ attr: {
3084
+ manifest_hash: context.actor.manifest_hash
3085
+ }
3086
+ },
3087
+ resource: {
3088
+ kind: "tool",
3089
+ id: context.action.tool,
3090
+ attr: context.target
3091
+ },
3092
+ actions: [context.action.operation || "call"]
3093
+ };
3094
+ case "generic":
3095
+ default:
3096
+ return context;
3097
+ }
3098
+ }
3099
+ function parseResponse(result, format) {
3100
+ switch (format) {
3101
+ case "opa":
3102
+ if (typeof result.result === "boolean") {
3103
+ return { allowed: result.result };
3104
+ }
3105
+ if (result.result && typeof result.result === "object") {
3106
+ const r = result.result;
3107
+ return {
3108
+ allowed: Boolean(r.allow),
3109
+ reason: r.reason,
3110
+ metadata: r
3111
+ };
3112
+ }
3113
+ return { allowed: false, reason: "unrecognized OPA response" };
3114
+ case "cerbos":
3115
+ if (Array.isArray(result.results) && result.results.length > 0) {
3116
+ const actions = result.results[0].actions;
3117
+ if (actions) {
3118
+ const effect = Object.values(actions)[0];
3119
+ return { allowed: effect === "EFFECT_ALLOW" };
3120
+ }
3121
+ }
3122
+ return { allowed: false, reason: "unrecognized Cerbos response" };
3123
+ case "generic":
3124
+ default:
3125
+ return {
3126
+ allowed: Boolean(result.allowed),
3127
+ reason: result.reason,
3128
+ metadata: result.metadata
3129
+ };
3130
+ }
3131
+ }
3132
+ function fallbackDecision(config, reason) {
3133
+ const fallback = config.fallback || "deny";
3134
+ return {
3135
+ allowed: fallback === "allow",
3136
+ reason: `fallback_${fallback}: ${reason}`
3137
+ };
3138
+ }
3139
+ function buildDecisionContext(toolName, tier, opts) {
3140
+ return {
3141
+ v: 1,
3142
+ actor: {
3143
+ id: opts.agentId,
3144
+ tier,
3145
+ manifest_hash: opts.manifestHash
3146
+ },
3147
+ action: {
3148
+ tool: toolName,
3149
+ operation: "call"
3150
+ },
3151
+ target: {
3152
+ service: opts.slug || "default"
3153
+ },
3154
+ credential_ref: opts.credentialRef,
3155
+ mode: opts.mode,
3156
+ request_metadata: opts.requestMetadata || {}
3157
+ };
3158
+ }
3159
+
3160
+ // src/http-server.ts
3161
+ var import_node_http = require("http");
3162
+ var import_node_fs4 = require("fs");
3163
+ var import_node_path2 = require("path");
3164
+ var LOG_FILE = ".protect-mcp-log.jsonl";
3165
+ var MAX_RECEIPTS = 100;
3166
+ var ReceiptBuffer = class {
3167
+ receipts = [];
3168
+ add(requestId, receipt) {
3169
+ this.receipts.push({
3170
+ request_id: requestId,
3171
+ receipt,
3172
+ timestamp: Date.now()
3173
+ });
3174
+ if (this.receipts.length > MAX_RECEIPTS) {
3175
+ this.receipts = this.receipts.slice(-MAX_RECEIPTS);
3176
+ }
3177
+ }
3178
+ getAll() {
3179
+ return [...this.receipts].reverse();
3180
+ }
3181
+ getById(requestId) {
3182
+ return this.receipts.find((r) => r.request_id === requestId);
3183
+ }
3184
+ count() {
3185
+ return this.receipts.length;
3186
+ }
3187
+ };
3188
+ function startStatusServer(config, receiptBuffer) {
3189
+ const startTime = Date.now();
3190
+ const logDir = process.cwd();
3191
+ const server = (0, import_node_http.createServer)((req, res) => {
3192
+ res.setHeader("Access-Control-Allow-Origin", "*");
3193
+ res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
3194
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
3195
+ res.setHeader("Content-Type", "application/json");
3196
+ if (req.method === "OPTIONS") {
3197
+ res.writeHead(204);
3198
+ res.end();
3199
+ return;
3200
+ }
3201
+ const url = new URL(req.url || "/", `http://localhost:${config.port}`);
3202
+ const path = url.pathname;
3203
+ try {
3204
+ if (path === "/health") {
3205
+ handleHealth(res, startTime, config);
3206
+ } else if (path === "/status") {
3207
+ handleStatus(res, logDir);
3208
+ } else if (path === "/receipts") {
3209
+ handleReceipts(res, receiptBuffer, url);
3210
+ } else if (path.startsWith("/receipts/")) {
3211
+ const id = path.slice("/receipts/".length);
3212
+ handleReceiptById(res, receiptBuffer, id);
3213
+ } else {
3214
+ res.writeHead(404);
3215
+ res.end(JSON.stringify({ error: "not_found", endpoints: ["/health", "/status", "/receipts", "/receipts/:id"] }));
3216
+ }
3217
+ } catch (err) {
3218
+ res.writeHead(500);
3219
+ res.end(JSON.stringify({ error: "internal_error" }));
3220
+ }
3221
+ });
3222
+ server.listen(config.port, "127.0.0.1", () => {
3223
+ if (config.verbose) {
3224
+ process.stderr.write(`[PROTECT_MCP] HTTP status server listening on http://127.0.0.1:${config.port}
3225
+ `);
3226
+ }
3227
+ });
3228
+ server.unref();
3229
+ return server;
3230
+ }
3231
+ function handleHealth(res, startTime, config) {
3232
+ res.writeHead(200);
3233
+ res.end(JSON.stringify({
3234
+ status: "ok",
3235
+ uptime_ms: Date.now() - startTime,
3236
+ mode: config.mode,
3237
+ version: "0.3.0"
3238
+ }));
3239
+ }
3240
+ function handleStatus(res, logDir) {
3241
+ const logPath = (0, import_node_path2.join)(logDir, LOG_FILE);
3242
+ if (!(0, import_node_fs4.existsSync)(logPath)) {
3243
+ res.writeHead(200);
3244
+ res.end(JSON.stringify({ entries: 0, message: "no log file yet" }));
3245
+ return;
3246
+ }
3247
+ const raw = (0, import_node_fs4.readFileSync)(logPath, "utf-8");
3248
+ const lines = raw.trim().split("\n").filter(Boolean);
3249
+ const entries = [];
3250
+ for (const line of lines) {
3251
+ try {
3252
+ entries.push(JSON.parse(line));
3253
+ } catch {
3254
+ }
3255
+ }
3256
+ const toolCounts = {};
3257
+ let allowCount = 0, denyCount = 0;
3258
+ const tierCounts = {};
3259
+ for (const e of entries) {
3260
+ toolCounts[e.tool] = (toolCounts[e.tool] || 0) + 1;
3261
+ if (e.decision === "allow") allowCount++;
3262
+ else denyCount++;
3263
+ if (e.tier) tierCounts[e.tier] = (tierCounts[e.tier] || 0) + 1;
3264
+ }
3265
+ res.writeHead(200);
3266
+ res.end(JSON.stringify({
3267
+ entries: entries.length,
3268
+ allow: allowCount,
3269
+ deny: denyCount,
3270
+ tools: toolCounts,
3271
+ tiers: tierCounts,
3272
+ first_timestamp: entries.length > 0 ? entries[0].timestamp : null,
3273
+ last_timestamp: entries.length > 0 ? entries[entries.length - 1].timestamp : null
3274
+ }));
3275
+ }
3276
+ function handleReceipts(res, buffer, url) {
3277
+ const limit = parseInt(url.searchParams.get("limit") || "20", 10);
3278
+ const receipts = buffer.getAll().slice(0, Math.min(limit, MAX_RECEIPTS));
3279
+ res.writeHead(200);
3280
+ res.end(JSON.stringify({
3281
+ count: receipts.length,
3282
+ total: buffer.count(),
3283
+ receipts
3284
+ }));
3285
+ }
3286
+ function handleReceiptById(res, buffer, id) {
3287
+ const receipt = buffer.getById(id);
3288
+ if (!receipt) {
3289
+ res.writeHead(404);
3290
+ res.end(JSON.stringify({ error: "receipt_not_found", request_id: id }));
3291
+ return;
3292
+ }
3293
+ res.writeHead(200);
3294
+ res.end(JSON.stringify(receipt));
3295
+ }
3296
+
2907
3297
  // src/gateway.ts
3298
+ var LOG_FILE2 = ".protect-mcp-log.jsonl";
2908
3299
  var ProtectGateway = class {
2909
3300
  child = null;
2910
3301
  config;
2911
3302
  rateLimitStore = /* @__PURE__ */ new Map();
2912
3303
  clientReader = null;
2913
- // Trust-tier state for the current session
3304
+ logFilePath;
3305
+ evidenceStore;
3306
+ receiptBuffer;
2914
3307
  currentTier = "unknown";
2915
3308
  admissionResult = null;
2916
3309
  constructor(config) {
2917
3310
  this.config = config;
3311
+ this.logFilePath = (0, import_node_path3.join)(process.cwd(), LOG_FILE2);
3312
+ this.evidenceStore = new EvidenceStore();
3313
+ this.receiptBuffer = new ReceiptBuffer();
2918
3314
  }
2919
- /**
2920
- * Start the gateway: spawn child process and wire up message relay.
2921
- */
2922
3315
  async start() {
2923
3316
  const { command, args, verbose } = this.config;
2924
3317
  const mode = this.config.enforce ? "enforce" : "shadow";
@@ -2935,11 +3328,34 @@ var ProtectGateway = class {
2935
3328
  const labels = Object.keys(this.config.credentials);
2936
3329
  this.log(`Credential vault: ${labels.length} credential(s) configured [${labels.join(", ")}]`);
2937
3330
  }
3331
+ if (this.config.policy?.policy_engine === "external" || this.config.policy?.policy_engine === "hybrid") {
3332
+ this.log(`External PDP: ${this.config.policy.external?.endpoint || "not configured"}`);
3333
+ }
2938
3334
  }
2939
- this.child = (0, import_node_child_process.spawn)(command, args, {
2940
- stdio: ["pipe", "pipe", "pipe"],
2941
- env: { ...process.env }
2942
- });
3335
+ const httpPort = parseInt(process.env.PROTECT_MCP_HTTP_PORT || "9876", 10);
3336
+ if (httpPort > 0) {
3337
+ try {
3338
+ startStatusServer(
3339
+ { port: httpPort, mode, verbose },
3340
+ this.receiptBuffer
3341
+ );
3342
+ } catch {
3343
+ if (verbose) this.log(`HTTP status server could not start on port ${httpPort}`);
3344
+ }
3345
+ }
3346
+ const childEnv = { ...process.env };
3347
+ if (this.config.credentials) {
3348
+ for (const [label, credConfig] of Object.entries(this.config.credentials)) {
3349
+ if (credConfig.inject === "env" && credConfig.name && credConfig.value_env) {
3350
+ const envValue = process.env[credConfig.value_env];
3351
+ if (envValue) {
3352
+ childEnv[credConfig.name] = envValue;
3353
+ if (verbose) this.log(`Credential "${label}": injected as env var "${credConfig.name}"`);
3354
+ }
3355
+ }
3356
+ }
3357
+ }
3358
+ this.child = (0, import_node_child_process.spawn)(command, args, { stdio: ["pipe", "pipe", "pipe"], env: childEnv });
2943
3359
  if (!this.child.stdin || !this.child.stdout || !this.child.stderr) {
2944
3360
  throw new Error("Failed to create pipes to child process");
2945
3361
  }
@@ -2955,9 +3371,8 @@ var ProtectGateway = class {
2955
3371
  this.handleClientMessage(line);
2956
3372
  });
2957
3373
  this.child.on("exit", (code, signal) => {
2958
- if (this.config.verbose) {
2959
- this.log(`Child process exited (code=${code}, signal=${signal})`);
2960
- }
3374
+ if (verbose) this.log(`Child process exited (code=${code}, signal=${signal})`);
3375
+ this.evidenceStore.save();
2961
3376
  process.exit(code ?? 1);
2962
3377
  });
2963
3378
  this.child.on("error", (err) => {
@@ -2967,30 +3382,18 @@ var ProtectGateway = class {
2967
3382
  process.on("SIGINT", () => this.stop());
2968
3383
  process.on("SIGTERM", () => this.stop());
2969
3384
  process.stdin.on("end", () => {
2970
- if (this.config.verbose) {
2971
- this.log("Client stdin closed, closing child stdin");
2972
- }
2973
- if (this.child?.stdin?.writable) {
2974
- this.child.stdin.end();
2975
- }
3385
+ if (verbose) this.log("Client stdin closed, closing child stdin");
3386
+ if (this.child?.stdin?.writable) this.child.stdin.end();
2976
3387
  });
2977
3388
  }
2978
- /**
2979
- * Set the trust tier for this session.
2980
- * Called at admission (first interaction) or by explicit manifest presentation.
2981
- */
2982
3389
  setManifest(manifest) {
2983
- this.admissionResult = evaluateTier(manifest);
3390
+ this.admissionResult = evaluateTier(manifest, { evidenceStore: this.evidenceStore });
2984
3391
  this.currentTier = this.admissionResult.tier;
2985
3392
  if (this.config.verbose) {
2986
- this.log(`Admission: tier=${this.currentTier} agent=${this.admissionResult.agent_id || "none"} reason=${this.admissionResult.reason}`);
3393
+ this.log(`Admission: tier=${this.currentTier} agent=${this.admissionResult.agent_id || "none"}`);
2987
3394
  }
2988
3395
  return this.admissionResult;
2989
3396
  }
2990
- /**
2991
- * Handle a message from the MCP client (stdin).
2992
- * Intercept tools/call requests; pass through everything else.
2993
- */
2994
3397
  handleClientMessage(raw) {
2995
3398
  const trimmed = raw.trim();
2996
3399
  if (!trimmed) return;
@@ -3002,25 +3405,38 @@ var ProtectGateway = class {
3002
3405
  return;
3003
3406
  }
3004
3407
  if (message.method === "tools/call" && message.id !== void 0) {
3005
- const result = this.interceptToolCall(message);
3006
- if (result) {
3007
- this.sendToClient(JSON.stringify(result));
3008
- return;
3009
- }
3408
+ this.interceptToolCallAsync(message, trimmed);
3409
+ return;
3010
3410
  }
3011
3411
  this.sendToChild(trimmed);
3012
3412
  }
3013
- /**
3014
- * Handle a message from the wrapped MCP server (child stdout).
3015
- * Forward to client (stdout) transparently.
3016
- */
3413
+ async interceptToolCallAsync(request, raw) {
3414
+ const result = await this.interceptToolCall(request);
3415
+ if (result) {
3416
+ this.sendToClient(JSON.stringify(result));
3417
+ } else {
3418
+ const modified = this.injectParamsCredentials(request);
3419
+ this.sendToChild(JSON.stringify(modified));
3420
+ }
3421
+ }
3017
3422
  handleServerMessage(raw) {
3018
3423
  this.sendToClient(raw);
3019
3424
  }
3020
- /**
3021
- * Intercept a tools/call request. Returns a JSON-RPC error response if denied, null if allowed.
3022
- */
3023
- interceptToolCall(request) {
3425
+ injectParamsCredentials(request) {
3426
+ if (!this.config.credentials) return request;
3427
+ const injections = {};
3428
+ for (const [label, credConfig] of Object.entries(this.config.credentials)) {
3429
+ if (credConfig.inject === "header" || credConfig.inject === "query") {
3430
+ const cred = resolveCredential(label, this.config.credentials);
3431
+ if (cred.resolved && cred.value && cred.name) {
3432
+ injections[cred.name] = cred.value;
3433
+ }
3434
+ }
3435
+ }
3436
+ if (Object.keys(injections).length === 0) return request;
3437
+ return { ...request, params: { ...request.params, _credentials: injections } };
3438
+ }
3439
+ async interceptToolCall(request) {
3024
3440
  const toolName = request.params?.name || "unknown";
3025
3441
  const requestId = (0, import_node_crypto2.randomUUID)().slice(0, 12);
3026
3442
  const toolPolicy = getToolPolicy(toolName, this.config.policy);
@@ -3031,49 +3447,45 @@ var ProtectGateway = class {
3031
3447
  if (cred.resolved) {
3032
3448
  credentialRef = cred.label;
3033
3449
  } else if (cred.error && !cred.error.includes("not configured")) {
3034
- this.emitDecisionLog({
3035
- tool: toolName,
3036
- decision: "deny",
3037
- reason_code: "policy_block",
3038
- request_id: requestId,
3039
- credential_ref: toolName,
3040
- tier: this.currentTier
3041
- });
3450
+ this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: "credential_error", request_id: requestId, tier: this.currentTier, credential_ref: toolName });
3042
3451
  if (this.config.enforce) {
3043
- this.log(`Credential error for "${toolName}": ${cred.error}`);
3044
3452
  return this.makeErrorResponse(request.id, -32600, `Credential error for tool "${toolName}"`);
3045
3453
  }
3046
3454
  }
3047
3455
  }
3456
+ if (this.config.policy?.external && (this.config.policy.policy_engine === "external" || this.config.policy.policy_engine === "hybrid")) {
3457
+ try {
3458
+ const ctx = buildDecisionContext(toolName, this.currentTier, {
3459
+ agentId: this.admissionResult?.agent_id,
3460
+ manifestHash: this.admissionResult?.manifest_hash,
3461
+ credentialRef,
3462
+ mode,
3463
+ slug: this.config.slug
3464
+ });
3465
+ const externalDecision = await queryExternalPDP(ctx, this.config.policy.external);
3466
+ if (!externalDecision.allowed) {
3467
+ const reason = `external_pdp_deny${externalDecision.reason ? ": " + externalDecision.reason : ""}`;
3468
+ this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: reason, request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
3469
+ if (this.config.enforce) {
3470
+ return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" denied by external policy engine`);
3471
+ }
3472
+ if (this.config.policy.policy_engine === "external") return null;
3473
+ }
3474
+ } catch (err) {
3475
+ if (this.config.verbose) this.log(`External PDP error: ${err instanceof Error ? err.message : err}`);
3476
+ }
3477
+ }
3048
3478
  if (toolPolicy.min_tier) {
3049
3479
  if (!meetsMinTier(this.currentTier, toolPolicy.min_tier)) {
3050
- this.emitDecisionLog({
3051
- tool: toolName,
3052
- decision: "deny",
3053
- reason_code: "tier_insufficient",
3054
- request_id: requestId,
3055
- tier: this.currentTier,
3056
- credential_ref: credentialRef
3057
- });
3480
+ this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: "tier_insufficient", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
3058
3481
  if (this.config.enforce) {
3059
- return this.makeErrorResponse(
3060
- request.id,
3061
- -32600,
3062
- `Tool "${toolName}" requires tier "${toolPolicy.min_tier}", agent has "${this.currentTier}"`
3063
- );
3482
+ return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" requires tier "${toolPolicy.min_tier}"`);
3064
3483
  }
3065
3484
  return null;
3066
3485
  }
3067
3486
  }
3068
3487
  if (toolPolicy.block) {
3069
- this.emitDecisionLog({
3070
- tool: toolName,
3071
- decision: "deny",
3072
- reason_code: "policy_block",
3073
- request_id: requestId,
3074
- tier: this.currentTier,
3075
- credential_ref: credentialRef
3076
- });
3488
+ this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: "policy_block", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
3077
3489
  if (this.config.enforce) {
3078
3490
  return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" is blocked by policy`);
3079
3491
  }
@@ -3086,59 +3498,22 @@ var ProtectGateway = class {
3086
3498
  const key = `tool:${toolName}:${this.currentTier}`;
3087
3499
  const { allowed, remaining } = checkRateLimit(key, limit, this.rateLimitStore);
3088
3500
  if (!allowed) {
3089
- this.emitDecisionLog({
3090
- tool: toolName,
3091
- decision: "deny",
3092
- reason_code: "rate_limit_exceeded",
3093
- request_id: requestId,
3094
- rate_limit_remaining: 0,
3095
- tier: this.currentTier,
3096
- credential_ref: credentialRef
3097
- });
3501
+ this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: "rate_limit_exceeded", request_id: requestId, rate_limit_remaining: 0, tier: this.currentTier, credential_ref: credentialRef });
3098
3502
  if (this.config.enforce) {
3099
- return this.makeErrorResponse(
3100
- request.id,
3101
- -32600,
3102
- `Tool "${toolName}" rate limit exceeded (${rateSpec})`
3103
- );
3503
+ return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" rate limit exceeded (${rateSpec})`);
3104
3504
  }
3105
3505
  return null;
3106
3506
  }
3107
- this.emitDecisionLog({
3108
- tool: toolName,
3109
- decision: "allow",
3110
- reason_code: "policy_allow",
3111
- request_id: requestId,
3112
- rate_limit_remaining: remaining,
3113
- tier: this.currentTier,
3114
- credential_ref: credentialRef
3115
- });
3507
+ this.emitDecisionLog({ tool: toolName, decision: "allow", reason_code: "policy_allow", request_id: requestId, rate_limit_remaining: remaining, tier: this.currentTier, credential_ref: credentialRef });
3116
3508
  } catch {
3117
- this.emitDecisionLog({
3118
- tool: toolName,
3119
- decision: "allow",
3120
- reason_code: "default_allow",
3121
- request_id: requestId,
3122
- tier: this.currentTier,
3123
- credential_ref: credentialRef
3124
- });
3509
+ this.emitDecisionLog({ tool: toolName, decision: "allow", reason_code: "default_allow", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
3125
3510
  }
3126
3511
  } else {
3127
3512
  const reasonCode = this.config.enforce ? "policy_allow" : "observe_mode";
3128
- this.emitDecisionLog({
3129
- tool: toolName,
3130
- decision: "allow",
3131
- reason_code: reasonCode,
3132
- request_id: requestId,
3133
- tier: this.currentTier,
3134
- credential_ref: credentialRef
3135
- });
3513
+ this.emitDecisionLog({ tool: toolName, decision: "allow", reason_code: reasonCode, request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
3136
3514
  }
3137
3515
  return null;
3138
3516
  }
3139
- /**
3140
- * Get the applicable rate limit spec based on the agent's tier.
3141
- */
3142
3517
  getTierRateLimit(policy, tier) {
3143
3518
  if (policy.rate_limits && policy.rate_limits[tier]) {
3144
3519
  const tierLimit = policy.rate_limits[tier];
@@ -3146,10 +3521,6 @@ var ProtectGateway = class {
3146
3521
  }
3147
3522
  return policy.rate_limit;
3148
3523
  }
3149
- /**
3150
- * Emit a structured decision log to stderr.
3151
- * If signing is enabled, also emits a signed artifact.
3152
- */
3153
3524
  emitDecisionLog(entry) {
3154
3525
  const mode = this.config.enforce ? "enforce" : "shadow";
3155
3526
  const log = {
@@ -3168,55 +3539,44 @@ var ProtectGateway = class {
3168
3539
  };
3169
3540
  process.stderr.write(`[PROTECT_MCP] ${JSON.stringify(log)}
3170
3541
  `);
3542
+ try {
3543
+ (0, import_node_fs5.appendFileSync)(this.logFilePath, JSON.stringify(log) + "\n");
3544
+ } catch {
3545
+ }
3171
3546
  if (isSigningEnabled()) {
3172
3547
  const signed = signDecision(log);
3173
3548
  if (signed.signed) {
3174
3549
  process.stderr.write(`[PROTECT_MCP_RECEIPT] ${signed.signed}
3175
3550
  `);
3551
+ this.receiptBuffer.add(log.request_id, signed.signed);
3552
+ if (this.admissionResult?.agent_id) {
3553
+ this.evidenceStore.record(this.admissionResult.agent_id, this.config.signing?.issuer || "protect-mcp");
3554
+ if (this.evidenceStore.getSummary(this.admissionResult.agent_id).receipt_count % 10 === 0) {
3555
+ this.evidenceStore.save();
3556
+ }
3557
+ }
3176
3558
  } else if (signed.warning) {
3177
3559
  process.stderr.write(`[PROTECT_MCP] Warning: ${signed.warning}
3178
3560
  `);
3179
3561
  }
3180
3562
  }
3181
3563
  }
3182
- /**
3183
- * Create a JSON-RPC error response.
3184
- */
3185
3564
  makeErrorResponse(id, code, message) {
3186
- return {
3187
- jsonrpc: "2.0",
3188
- id,
3189
- error: { code, message }
3190
- };
3565
+ return { jsonrpc: "2.0", id, error: { code, message } };
3191
3566
  }
3192
- /**
3193
- * Send a message to the child process (wrapped MCP server).
3194
- */
3195
3567
  sendToChild(message) {
3196
- if (this.child?.stdin?.writable) {
3197
- this.child.stdin.write(message + "\n");
3198
- }
3568
+ if (this.child?.stdin?.writable) this.child.stdin.write(message + "\n");
3199
3569
  }
3200
- /**
3201
- * Send a message to the MCP client (stdout).
3202
- */
3203
3570
  sendToClient(message) {
3204
3571
  process.stdout.write(message + "\n");
3205
3572
  }
3206
- /**
3207
- * Log a message to stderr (debug output).
3208
- */
3209
3573
  log(message) {
3210
3574
  process.stderr.write(`[PROTECT_MCP] ${message}
3211
3575
  `);
3212
3576
  }
3213
- /**
3214
- * Stop the gateway: kill child process and exit.
3215
- */
3216
3577
  stop() {
3217
- if (this.clientReader) {
3218
- this.clientReader.close();
3219
- }
3578
+ this.evidenceStore.save();
3579
+ if (this.clientReader) this.clientReader.close();
3220
3580
  if (this.child) {
3221
3581
  this.child.kill("SIGTERM");
3222
3582
  this.child = null;
@@ -3226,6 +3586,7 @@ var ProtectGateway = class {
3226
3586
  };
3227
3587
 
3228
3588
  // src/cli.ts
3589
+ var import_meta = {};
3229
3590
  function printHelp() {
3230
3591
  process.stderr.write(`
3231
3592
  protect-mcp \u2014 Shadow-mode security gateway for MCP servers
@@ -3233,6 +3594,8 @@ protect-mcp \u2014 Shadow-mode security gateway for MCP servers
3233
3594
  Usage:
3234
3595
  protect-mcp [options] -- <command> [args...]
3235
3596
  protect-mcp init [--dir <path>]
3597
+ protect-mcp demo
3598
+ protect-mcp status [--dir <path>]
3236
3599
 
3237
3600
  Options:
3238
3601
  --policy <path> Policy/config JSON file (default: allow-all)
@@ -3243,12 +3606,16 @@ Options:
3243
3606
 
3244
3607
  Commands:
3245
3608
  init Generate config template, Ed25519 keypair, and sample policy
3609
+ demo Start a demo server wrapped with protect-mcp (see receipts instantly)
3610
+ status Show tool call statistics from the local decision log
3246
3611
 
3247
3612
  Examples:
3248
3613
  protect-mcp -- node my-server.js
3249
3614
  protect-mcp --policy protect-mcp.json -- node my-server.js
3250
3615
  protect-mcp --policy protect-mcp.json --enforce -- node my-server.js
3251
3616
  protect-mcp init
3617
+ protect-mcp demo
3618
+ protect-mcp status
3252
3619
 
3253
3620
  `);
3254
3621
  }
@@ -3292,17 +3659,17 @@ function parseArgs(argv) {
3292
3659
  return { policyPath, slug, enforce, verbose, childCommand };
3293
3660
  }
3294
3661
  async function handleInit(argv) {
3295
- const { writeFileSync, existsSync: existsSync2, mkdirSync } = await import("fs");
3296
- const { join } = await import("path");
3662
+ const { writeFileSync: writeFileSync2, existsSync: existsSync4, mkdirSync } = await import("fs");
3663
+ const { join: join4 } = await import("path");
3297
3664
  let dir = process.cwd();
3298
3665
  const dirIdx = argv.indexOf("--dir");
3299
3666
  if (dirIdx !== -1 && argv[dirIdx + 1]) {
3300
3667
  dir = argv[dirIdx + 1];
3301
3668
  }
3302
- const configPath = join(dir, "protect-mcp.json");
3303
- const keysDir = join(dir, "keys");
3304
- const keyPath = join(keysDir, "gateway.json");
3305
- if (existsSync2(configPath)) {
3669
+ const configPath = join4(dir, "protect-mcp.json");
3670
+ const keysDir = join4(dir, "keys");
3671
+ const keyPath = join4(keysDir, "gateway.json");
3672
+ if (existsSync4(configPath)) {
3306
3673
  process.stderr.write(`[PROTECT_MCP] Config already exists at ${configPath}
3307
3674
  `);
3308
3675
  process.stderr.write("[PROTECT_MCP] Delete it first if you want to regenerate.\n");
@@ -3324,19 +3691,19 @@ async function handleInit(argv) {
3324
3691
  kid: "generated"
3325
3692
  };
3326
3693
  }
3327
- if (!existsSync2(keysDir)) {
3694
+ if (!existsSync4(keysDir)) {
3328
3695
  mkdirSync(keysDir, { recursive: true });
3329
3696
  }
3330
- writeFileSync(keyPath, JSON.stringify({
3697
+ writeFileSync2(keyPath, JSON.stringify({
3331
3698
  privateKey: keypair.privateKey,
3332
3699
  publicKey: keypair.publicKey,
3333
3700
  kid: keypair.kid,
3334
3701
  generated_at: (/* @__PURE__ */ new Date()).toISOString(),
3335
3702
  warning: "KEEP THIS FILE SECRET. Never commit to version control."
3336
3703
  }, null, 2) + "\n");
3337
- const gitignorePath = join(keysDir, ".gitignore");
3338
- if (!existsSync2(gitignorePath)) {
3339
- writeFileSync(gitignorePath, "# Never commit signing keys\n*.json\n");
3704
+ const gitignorePath = join4(keysDir, ".gitignore");
3705
+ if (!existsSync4(gitignorePath)) {
3706
+ writeFileSync2(gitignorePath, "# Never commit signing keys\n*.json\n");
3340
3707
  }
3341
3708
  const config = {
3342
3709
  tools: {
@@ -3363,19 +3730,27 @@ async function handleInit(argv) {
3363
3730
  },
3364
3731
  credentials: {
3365
3732
  _example_api: {
3366
- inject: "header",
3367
- name: "Authorization",
3733
+ inject: "env",
3734
+ name: "EXAMPLE_API_KEY",
3368
3735
  value_env: "EXAMPLE_API_KEY",
3369
3736
  _comment: "Remove the underscore prefix and set EXAMPLE_API_KEY in your environment"
3370
3737
  }
3371
3738
  }
3372
3739
  };
3373
- writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
3740
+ writeFileSync2(configPath, JSON.stringify(config, null, 2) + "\n");
3741
+ const claudeConfig = {
3742
+ "mcpServers": {
3743
+ "my-server": {
3744
+ "command": "npx",
3745
+ "args": ["protect-mcp", "--policy", configPath, "--", "node", "my-server.js"]
3746
+ }
3747
+ }
3748
+ };
3374
3749
  process.stderr.write(`
3375
3750
  ${bold("protect-mcp initialized!")}
3376
3751
 
3377
3752
  Created:
3378
- ${configPath} Config with shadow mode + optional local signing
3753
+ ${configPath} Config with shadow mode + local signing
3379
3754
  ${keyPath} Ed25519 signing keypair
3380
3755
 
3381
3756
  ${bold("Next steps:")}
@@ -3389,13 +3764,227 @@ ${bold("Your gateway public key:")}
3389
3764
  ${bold("Key ID (kid):")}
3390
3765
  ${keypair.kid}
3391
3766
 
3767
+ ${bold("Claude Desktop config snippet")} (add to claude_desktop_config.json):
3768
+ ${dim(JSON.stringify(claudeConfig, null, 2))}
3769
+
3770
+ ${bold("Quick demo:")}
3771
+ protect-mcp demo
3772
+
3392
3773
  Shadow mode is the default \u2014 all tool calls are logged and nothing is blocked.
3393
- Run with the generated policy file if you also want local signed receipts. Add --enforce when ready.
3774
+ Add --enforce when ready to block policy violations.
3775
+ `);
3776
+ }
3777
+ async function handleDemo() {
3778
+ const { existsSync: existsSync4 } = await import("fs");
3779
+ const { join: join4, dirname } = await import("path");
3780
+ const { fileURLToPath } = await import("url");
3781
+ const __filename = fileURLToPath(import_meta.url);
3782
+ const __dirname = dirname(__filename);
3783
+ const demoServerPath = join4(__dirname, "demo-server.js");
3784
+ const configPath = join4(process.cwd(), "protect-mcp.json");
3785
+ const hasConfig = existsSync4(configPath);
3786
+ if (!hasConfig) {
3787
+ process.stderr.write(`
3788
+ ${bold("protect-mcp demo")}
3789
+
3790
+ Starting demo with default shadow mode (no signing).
3791
+ For signed receipts, run ${dim("npx protect-mcp init")} first.
3792
+
3793
+ `);
3794
+ } else {
3795
+ process.stderr.write(`
3796
+ ${bold("protect-mcp demo")}
3797
+
3798
+ Using config from ${configPath}
3799
+ Starting demo server with 5 tools...
3800
+
3801
+ `);
3802
+ }
3803
+ let policy = null;
3804
+ let policyDigest = "none";
3805
+ let credentials;
3806
+ let signing;
3807
+ if (hasConfig) {
3808
+ try {
3809
+ const loaded = loadPolicy(configPath);
3810
+ policy = loaded.policy;
3811
+ policyDigest = loaded.digest;
3812
+ credentials = loaded.credentials;
3813
+ signing = loaded.signing;
3814
+ } catch (err) {
3815
+ process.stderr.write(`[PROTECT_MCP] Warning: Could not load config: ${err instanceof Error ? err.message : err}
3816
+ `);
3817
+ }
3818
+ }
3819
+ if (signing) {
3820
+ const warnings = await initSigning(signing);
3821
+ for (const w of warnings) {
3822
+ process.stderr.write(`[PROTECT_MCP] Warning: ${w}
3823
+ `);
3824
+ }
3825
+ }
3826
+ if (credentials) {
3827
+ const warnings = validateCredentials(credentials);
3828
+ for (const w of warnings) {
3829
+ process.stderr.write(`[PROTECT_MCP] Warning: ${w}
3830
+ `);
3831
+ }
3832
+ }
3833
+ const config = {
3834
+ command: process.execPath,
3835
+ // node
3836
+ args: [demoServerPath],
3837
+ policy,
3838
+ policyDigest,
3839
+ enforce: false,
3840
+ // Demo always runs in shadow mode
3841
+ verbose: true,
3842
+ signing,
3843
+ credentials
3844
+ };
3845
+ const gateway = new ProtectGateway(config);
3846
+ process.stderr.write(`${bold("Demo ready!")} The demo server is running.
3847
+ `);
3848
+ process.stderr.write(`Send JSON-RPC tool calls on stdin, or use an MCP client.
3849
+
3850
+ `);
3851
+ process.stderr.write(`${dim("Example (paste into stdin):")}
3852
+ `);
3853
+ process.stderr.write(`${dim('{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read_file","arguments":{"path":"/etc/hosts"}}}')}
3854
+
3855
+ `);
3856
+ await gateway.start();
3857
+ }
3858
+ async function handleStatus2(argv) {
3859
+ const { readFileSync: readFileSync5, existsSync: existsSync4 } = await import("fs");
3860
+ const { join: join4 } = await import("path");
3861
+ let dir = process.cwd();
3862
+ const dirIdx = argv.indexOf("--dir");
3863
+ if (dirIdx !== -1 && argv[dirIdx + 1]) {
3864
+ dir = argv[dirIdx + 1];
3865
+ }
3866
+ const logPath = join4(dir, ".protect-mcp-log.jsonl");
3867
+ if (!existsSync4(logPath)) {
3868
+ process.stderr.write(`${bold("protect-mcp status")}
3869
+
3870
+ `);
3871
+ process.stderr.write(`No log file found at ${logPath}
3872
+ `);
3873
+ process.stderr.write(`Run protect-mcp with a wrapped server first to generate logs.
3874
+ `);
3875
+ process.exit(0);
3876
+ }
3877
+ const raw = readFileSync5(logPath, "utf-8");
3878
+ const lines = raw.trim().split("\n").filter(Boolean);
3879
+ if (lines.length === 0) {
3880
+ process.stderr.write(`${bold("protect-mcp status")}
3881
+
3882
+ No entries in log file.
3883
+ `);
3884
+ process.exit(0);
3885
+ }
3886
+ const entries = [];
3887
+ for (const line of lines) {
3888
+ try {
3889
+ entries.push(JSON.parse(line));
3890
+ } catch {
3891
+ }
3892
+ }
3893
+ if (entries.length === 0) {
3894
+ process.stderr.write(`${bold("protect-mcp status")}
3895
+
3896
+ No valid entries in log file.
3897
+ `);
3898
+ process.exit(0);
3899
+ }
3900
+ const toolCounts = /* @__PURE__ */ new Map();
3901
+ let allowCount = 0;
3902
+ let denyCount = 0;
3903
+ let rateLimitCount = 0;
3904
+ const tierCounts = /* @__PURE__ */ new Map();
3905
+ const reasonCounts = /* @__PURE__ */ new Map();
3906
+ for (const entry of entries) {
3907
+ toolCounts.set(entry.tool, (toolCounts.get(entry.tool) || 0) + 1);
3908
+ if (entry.decision === "allow") allowCount++;
3909
+ else if (entry.decision === "deny") denyCount++;
3910
+ if (entry.reason_code === "rate_limit_exceeded") rateLimitCount++;
3911
+ if (entry.tier) tierCounts.set(entry.tier, (tierCounts.get(entry.tier) || 0) + 1);
3912
+ reasonCounts.set(entry.reason_code, (reasonCounts.get(entry.reason_code) || 0) + 1);
3913
+ }
3914
+ const firstTs = new Date(Math.min(...entries.map((e) => e.timestamp)));
3915
+ const lastTs = new Date(Math.max(...entries.map((e) => e.timestamp)));
3916
+ const sortedTools = [...toolCounts.entries()].sort((a, b) => b[1] - a[1]);
3917
+ process.stdout.write(`
3918
+ ${bold("protect-mcp status")}
3919
+
3920
+ `);
3921
+ process.stdout.write(` Total decisions: ${bold(String(entries.length))}
3922
+ `);
3923
+ process.stdout.write(` ${green("\u2713 Allow")}: ${allowCount} ${red("\u2717 Deny")}: ${denyCount} ${yellow("\u2298 Rate-limited")}: ${rateLimitCount}
3924
+
3925
+ `);
3926
+ process.stdout.write(` ${bold("Time range:")}
3927
+ `);
3928
+ process.stdout.write(` First: ${firstTs.toISOString()}
3929
+ `);
3930
+ process.stdout.write(` Last: ${lastTs.toISOString()}
3931
+
3932
+ `);
3933
+ process.stdout.write(` ${bold("Top tools:")}
3934
+ `);
3935
+ for (const [tool, count] of sortedTools.slice(0, 10)) {
3936
+ const bar = "\u2588".repeat(Math.min(Math.ceil(count / entries.length * 30), 30));
3937
+ process.stdout.write(` ${tool.padEnd(20)} ${String(count).padStart(4)} ${dim(bar)}
3938
+ `);
3939
+ }
3940
+ if (tierCounts.size > 0) {
3941
+ process.stdout.write(`
3942
+ ${bold("Trust tiers seen:")}
3943
+ `);
3944
+ for (const [tier, count] of tierCounts) {
3945
+ process.stdout.write(` ${tier.padEnd(15)} ${count}
3946
+ `);
3947
+ }
3948
+ }
3949
+ process.stdout.write(`
3950
+ ${bold("Decision reasons:")}
3951
+ `);
3952
+ for (const [reason, count] of [...reasonCounts.entries()].sort((a, b) => b[1] - a[1])) {
3953
+ process.stdout.write(` ${reason.padEnd(25)} ${count}
3954
+ `);
3955
+ }
3956
+ const evidencePath = join4(dir, ".protect-mcp-evidence.json");
3957
+ if (existsSync4(evidencePath)) {
3958
+ try {
3959
+ const evidenceRaw = readFileSync5(evidencePath, "utf-8");
3960
+ const evidence = JSON.parse(evidenceRaw);
3961
+ const agentCount = Object.keys(evidence.agents || {}).length;
3962
+ process.stdout.write(`
3963
+ ${bold("Evidence store:")} ${agentCount} agent(s) tracked
3964
+ `);
3965
+ } catch {
3966
+ }
3967
+ }
3968
+ process.stdout.write(`
3969
+ Log file: ${dim(logPath)}
3970
+
3394
3971
  `);
3395
3972
  }
3396
3973
  function bold(s) {
3397
3974
  return process.env.NO_COLOR ? s : `\x1B[1m${s}\x1B[0m`;
3398
3975
  }
3976
+ function dim(s) {
3977
+ return process.env.NO_COLOR ? s : `\x1B[2m${s}\x1B[0m`;
3978
+ }
3979
+ function green(s) {
3980
+ return process.env.NO_COLOR ? s : `\x1B[32m${s}\x1B[0m`;
3981
+ }
3982
+ function red(s) {
3983
+ return process.env.NO_COLOR ? s : `\x1B[31m${s}\x1B[0m`;
3984
+ }
3985
+ function yellow(s) {
3986
+ return process.env.NO_COLOR ? s : `\x1B[33m${s}\x1B[0m`;
3987
+ }
3399
3988
  async function main() {
3400
3989
  const args = process.argv.slice(2);
3401
3990
  if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
@@ -3406,6 +3995,14 @@ async function main() {
3406
3995
  await handleInit(args.slice(1));
3407
3996
  process.exit(0);
3408
3997
  }
3998
+ if (args[0] === "demo") {
3999
+ await handleDemo();
4000
+ return;
4001
+ }
4002
+ if (args[0] === "status") {
4003
+ await handleStatus2(args.slice(1));
4004
+ process.exit(0);
4005
+ }
3409
4006
  const { policyPath, slug, enforce, verbose, childCommand } = parseArgs(args);
3410
4007
  let policy = null;
3411
4008
  let policyDigest = "none";