protect-mcp 0.2.2 → 0.3.1

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
@@ -67,7 +67,7 @@ __export(utils_exports, {
67
67
  isLE: () => isLE,
68
68
  kdfInputToBytes: () => kdfInputToBytes,
69
69
  nextTick: () => nextTick,
70
- randomBytes: () => randomBytes,
70
+ randomBytes: () => randomBytes2,
71
71
  rotl: () => rotl,
72
72
  rotr: () => rotr,
73
73
  swap32IfBE: () => swap32IfBE,
@@ -257,7 +257,7 @@ function createXOFer(hashCons) {
257
257
  hashC.create = (opts) => hashCons(opts);
258
258
  return hashC;
259
259
  }
260
- function randomBytes(bytesLength = 32) {
260
+ function randomBytes2(bytesLength = 32) {
261
261
  if (crypto && typeof crypto.getRandomValues === "function") {
262
262
  return crypto.getRandomValues(new Uint8Array(bytesLength));
263
263
  }
@@ -1700,7 +1700,7 @@ function eddsa(Point, cHash, eddsaOpts = {}) {
1700
1700
  });
1701
1701
  const { prehash } = eddsaOpts;
1702
1702
  const { BASE, Fp: Fp2, Fn: Fn2 } = Point;
1703
- const randomBytes2 = eddsaOpts.randomBytes || randomBytes;
1703
+ const randomBytes3 = eddsaOpts.randomBytes || randomBytes2;
1704
1704
  const adjustScalarBytes2 = eddsaOpts.adjustScalarBytes || ((bytes) => bytes);
1705
1705
  const domain = eddsaOpts.domain || ((data, ctx, phflag) => {
1706
1706
  _abool2(phflag, "phflag");
@@ -1782,7 +1782,7 @@ function eddsa(Point, cHash, eddsaOpts = {}) {
1782
1782
  signature: 2 * _size,
1783
1783
  seed: _size
1784
1784
  };
1785
- function randomSecretKey(seed = randomBytes2(lengths.seed)) {
1785
+ function randomSecretKey(seed = randomBytes3(lengths.seed)) {
1786
1786
  return _abytes2(seed, lengths.seed, "seed");
1787
1787
  }
1788
1788
  function keygen(seed) {
@@ -2137,7 +2137,7 @@ function montgomery(curveDef) {
2137
2137
  const is25519 = type === "x25519";
2138
2138
  if (!is25519 && type !== "x448")
2139
2139
  throw new Error("invalid type");
2140
- const randomBytes_ = rand || randomBytes;
2140
+ const randomBytes_ = rand || randomBytes2;
2141
2141
  const montgomeryBits = is25519 ? 255 : 448;
2142
2142
  const fieldLen = is25519 ? 32 : 56;
2143
2143
  const Gu = is25519 ? BigInt(9) : BigInt(5);
@@ -2638,10 +2638,71 @@ var init_ed25519 = __esm({
2638
2638
  }
2639
2639
  });
2640
2640
 
2641
+ // src/bundle.ts
2642
+ var bundle_exports = {};
2643
+ __export(bundle_exports, {
2644
+ collectSignedReceipts: () => collectSignedReceipts,
2645
+ createAuditBundle: () => createAuditBundle
2646
+ });
2647
+ function createAuditBundle(opts) {
2648
+ const receipts = opts.receipts.filter(
2649
+ (r) => r && typeof r === "object" && typeof r.signature === "string"
2650
+ );
2651
+ if (receipts.length === 0) {
2652
+ throw new Error("Audit bundle requires at least one signed receipt");
2653
+ }
2654
+ const keyMap = /* @__PURE__ */ new Map();
2655
+ for (const key of opts.signingKeys) {
2656
+ if (!keyMap.has(key.kid)) {
2657
+ keyMap.set(key.kid, key);
2658
+ }
2659
+ }
2660
+ let timeRange = opts.timeRange || null;
2661
+ if (!timeRange) {
2662
+ const timestamps = receipts.map((r) => r.issued_at || r.timestamp).filter(Boolean).sort();
2663
+ if (timestamps.length > 0) {
2664
+ timeRange = {
2665
+ from: timestamps[0],
2666
+ to: timestamps[timestamps.length - 1]
2667
+ };
2668
+ }
2669
+ }
2670
+ return {
2671
+ format: "scopeblind:audit-bundle",
2672
+ version: 1,
2673
+ exported_at: (/* @__PURE__ */ new Date()).toISOString(),
2674
+ tenant: opts.tenant,
2675
+ time_range: timeRange,
2676
+ receipts,
2677
+ anchors: opts.anchors || [],
2678
+ verification: {
2679
+ algorithm: "ed25519",
2680
+ signing_keys: Array.from(keyMap.values()),
2681
+ 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`
2682
+ }
2683
+ };
2684
+ }
2685
+ function collectSignedReceipts(logs) {
2686
+ return logs.filter((log) => log.v === 2).map((log) => {
2687
+ const logRecord = log;
2688
+ if (logRecord.receipt) {
2689
+ return logRecord.receipt;
2690
+ }
2691
+ return logRecord;
2692
+ }).filter((r) => typeof r.signature === "string");
2693
+ }
2694
+ var init_bundle = __esm({
2695
+ "src/bundle.ts"() {
2696
+ "use strict";
2697
+ }
2698
+ });
2699
+
2641
2700
  // src/gateway.ts
2642
2701
  var import_node_child_process = require("child_process");
2643
2702
  var import_node_crypto2 = require("crypto");
2644
2703
  var import_node_readline = require("readline");
2704
+ var import_node_fs5 = require("fs");
2705
+ var import_node_path3 = require("path");
2645
2706
 
2646
2707
  // src/policy.ts
2647
2708
  var import_node_crypto = require("crypto");
@@ -2719,8 +2780,126 @@ function checkRateLimit(key, limit, store) {
2719
2780
  return { allowed: true, remaining: limit.count - timestamps.length };
2720
2781
  }
2721
2782
 
2783
+ // src/evidence-store.ts
2784
+ var import_node_fs2 = require("fs");
2785
+ var import_node_path = require("path");
2786
+ var DEFAULT_THRESHOLDS = {
2787
+ min_receipts: 10,
2788
+ min_epoch_span: 3,
2789
+ min_issuers: 2
2790
+ };
2791
+ var EvidenceStore = class {
2792
+ agents = /* @__PURE__ */ new Map();
2793
+ filePath;
2794
+ dirty = false;
2795
+ constructor(dir) {
2796
+ this.filePath = (0, import_node_path.join)(dir || process.cwd(), ".protect-mcp-evidence.json");
2797
+ this.load();
2798
+ }
2799
+ /**
2800
+ * Record a receipt observation for an agent.
2801
+ */
2802
+ record(agentId, issuer, timestamp) {
2803
+ const ts = timestamp || (/* @__PURE__ */ new Date()).toISOString();
2804
+ const epochHour = Math.floor(new Date(ts).getTime() / (3600 * 1e3));
2805
+ const existing = this.agents.get(agentId);
2806
+ const observation = {
2807
+ issuer,
2808
+ timestamp: ts,
2809
+ epoch_hour: epochHour
2810
+ };
2811
+ if (existing) {
2812
+ existing.receipts.push(observation);
2813
+ existing.last_seen = ts;
2814
+ if (existing.receipts.length > 200) {
2815
+ existing.receipts = existing.receipts.slice(-200);
2816
+ }
2817
+ } else {
2818
+ this.agents.set(agentId, {
2819
+ agent_id: agentId,
2820
+ receipts: [observation],
2821
+ first_seen: ts,
2822
+ last_seen: ts
2823
+ });
2824
+ }
2825
+ this.dirty = true;
2826
+ }
2827
+ /**
2828
+ * Get the evidence summary for an agent.
2829
+ */
2830
+ getSummary(agentId) {
2831
+ const record = this.agents.get(agentId);
2832
+ if (!record || record.receipts.length === 0) {
2833
+ return { receipt_count: 0, epoch_span: 0, issuer_count: 0 };
2834
+ }
2835
+ const uniqueIssuers = new Set(record.receipts.map((r) => r.issuer));
2836
+ const uniqueEpochs = new Set(record.receipts.map((r) => r.epoch_hour));
2837
+ return {
2838
+ receipt_count: record.receipts.length,
2839
+ epoch_span: uniqueEpochs.size,
2840
+ issuer_count: uniqueIssuers.size
2841
+ };
2842
+ }
2843
+ /**
2844
+ * Check if an agent meets the evidenced tier thresholds.
2845
+ */
2846
+ meetsEvidencedThreshold(agentId, thresholds = DEFAULT_THRESHOLDS) {
2847
+ const summary = this.getSummary(agentId);
2848
+ return summary.receipt_count >= thresholds.min_receipts && summary.epoch_span >= thresholds.min_epoch_span && summary.issuer_count >= thresholds.min_issuers;
2849
+ }
2850
+ /**
2851
+ * Persist to disk (call periodically or on shutdown).
2852
+ */
2853
+ save() {
2854
+ if (!this.dirty) return;
2855
+ const data = {};
2856
+ for (const [id, record] of this.agents) {
2857
+ data[id] = record;
2858
+ }
2859
+ try {
2860
+ (0, import_node_fs2.writeFileSync)(this.filePath, JSON.stringify({ v: 1, agents: data }, null, 2) + "\n");
2861
+ this.dirty = false;
2862
+ } catch {
2863
+ }
2864
+ }
2865
+ /**
2866
+ * Load from disk.
2867
+ */
2868
+ load() {
2869
+ if (!(0, import_node_fs2.existsSync)(this.filePath)) return;
2870
+ try {
2871
+ const raw = (0, import_node_fs2.readFileSync)(this.filePath, "utf-8");
2872
+ const parsed = JSON.parse(raw);
2873
+ if (parsed.agents && typeof parsed.agents === "object") {
2874
+ for (const [id, record] of Object.entries(parsed.agents)) {
2875
+ this.agents.set(id, record);
2876
+ }
2877
+ }
2878
+ } catch {
2879
+ }
2880
+ }
2881
+ /**
2882
+ * Get total agent count (for status display).
2883
+ */
2884
+ agentCount() {
2885
+ return this.agents.size;
2886
+ }
2887
+ /**
2888
+ * Get all agent summaries (for status display).
2889
+ */
2890
+ allSummaries() {
2891
+ const result = [];
2892
+ for (const [id] of this.agents) {
2893
+ result.push({ agent_id: id, summary: this.getSummary(id) });
2894
+ }
2895
+ return result;
2896
+ }
2897
+ };
2898
+
2722
2899
  // src/admission.ts
2723
- function evaluateTier(manifest, overrides) {
2900
+ function evaluateTier(manifest, opts) {
2901
+ const options = opts && ("evidenceStore" in opts || "overrides" in opts || "thresholds" in opts) ? opts : { overrides: opts };
2902
+ const { overrides, evidenceStore, thresholds } = options;
2724
2903
  if (!manifest) {
2725
2904
  return {
2726
2905
  tier: "unknown",
@@ -2746,7 +2925,8 @@ function evaluateTier(manifest, overrides) {
2746
2925
  if (manifest.signature_valid === true) {
2747
2926
  if (manifest.evidence_summary) {
2748
2927
  const es = manifest.evidence_summary;
2749
- if (es.receipt_count >= 10 && es.epoch_span >= 3 && es.issuer_count >= 2) {
2928
+ const t = thresholds || DEFAULT_THRESHOLDS;
2929
+ if (es.receipt_count >= t.min_receipts && es.epoch_span >= t.min_epoch_span && es.issuer_count >= t.min_issuers) {
2750
2930
  return {
2751
2931
  tier: "evidenced",
2752
2932
  agent_id: manifest.agent_id,
@@ -2755,6 +2935,16 @@ function evaluateTier(manifest, overrides) {
2755
2935
  };
2756
2936
  }
2757
2937
  }
2938
+ if (evidenceStore && manifest.agent_id) {
2939
+ if (evidenceStore.meetsEvidencedThreshold(manifest.agent_id, thresholds)) {
2940
+ return {
2941
+ tier: "evidenced",
2942
+ agent_id: manifest.agent_id,
2943
+ manifest_hash: manifest.manifest_hash,
2944
+ reason: "evidence_store_threshold_met"
2945
+ };
2946
+ }
2947
+ }
2758
2948
  return {
2759
2949
  tier: "signed-known",
2760
2950
  agent_id: manifest.agent_id,
@@ -2820,7 +3010,7 @@ function validateCredentials(credentials) {
2820
3010
  }
2821
3011
 
2822
3012
  // src/signing.ts
2823
- var import_node_fs2 = require("fs");
3013
+ var import_node_fs3 = require("fs");
2824
3014
  var signerState = null;
2825
3015
  var artifactsModule = null;
2826
3016
  async function initSigning(config) {
@@ -2835,12 +3025,12 @@ async function initSigning(config) {
2835
3025
  return warnings;
2836
3026
  }
2837
3027
  if (config.key_path) {
2838
- if (!(0, import_node_fs2.existsSync)(config.key_path)) {
3028
+ if (!(0, import_node_fs3.existsSync)(config.key_path)) {
2839
3029
  warnings.push(`signing: key file not found at ${config.key_path} \u2014 run "protect-mcp init" to generate`);
2840
3030
  return warnings;
2841
3031
  }
2842
3032
  try {
2843
- const keyData = JSON.parse((0, import_node_fs2.readFileSync)(config.key_path, "utf-8"));
3033
+ const keyData = JSON.parse((0, import_node_fs3.readFileSync)(config.key_path, "utf-8"));
2844
3034
  if (!keyData.privateKey || !keyData.publicKey) {
2845
3035
  warnings.push("signing: key file missing privateKey or publicKey fields");
2846
3036
  return warnings;
@@ -2848,8 +3038,8 @@ async function initSigning(config) {
2848
3038
  signerState = {
2849
3039
  privateKey: keyData.privateKey,
2850
3040
  publicKey: keyData.publicKey,
2851
- kid: artifactsModule.computeKid(keyData.publicKey),
2852
- issuer: config.issuer || "protect-mcp"
3041
+ kid: keyData.kid || artifactsModule.computeKid(keyData.publicKey),
3042
+ issuer: config.issuer || keyData.issuer || "protect-mcp"
2853
3043
  };
2854
3044
  } catch (err) {
2855
3045
  warnings.push(`signing: failed to load key file: ${err instanceof Error ? err.message : err}`);
@@ -2904,21 +3094,378 @@ function isSigningEnabled() {
2904
3094
  return signerState !== null && artifactsModule !== null;
2905
3095
  }
2906
3096
 
3097
+ // src/external-pdp.ts
3098
+ async function queryExternalPDP(context, config) {
3099
+ const timeout = config.timeout_ms || 500;
3100
+ const controller = new AbortController();
3101
+ const timer = setTimeout(() => controller.abort(), timeout);
3102
+ try {
3103
+ const body = formatRequest(context, config.format || "generic");
3104
+ const response = await fetch(config.endpoint, {
3105
+ method: "POST",
3106
+ headers: { "Content-Type": "application/json" },
3107
+ body: JSON.stringify(body),
3108
+ signal: controller.signal
3109
+ });
3110
+ clearTimeout(timer);
3111
+ if (!response.ok) {
3112
+ return fallbackDecision(config, `PDP returned HTTP ${response.status}`);
3113
+ }
3114
+ const result = await response.json();
3115
+ return parseResponse(result, config.format || "generic");
3116
+ } catch (err) {
3117
+ clearTimeout(timer);
3118
+ if (err instanceof Error && err.name === "AbortError") {
3119
+ return fallbackDecision(config, `PDP timeout after ${timeout}ms`);
3120
+ }
3121
+ return fallbackDecision(config, `PDP error: ${err instanceof Error ? err.message : "unknown"}`);
3122
+ }
3123
+ }
3124
+ function formatRequest(context, format) {
3125
+ switch (format) {
3126
+ case "opa":
3127
+ return {
3128
+ input: {
3129
+ actor: context.actor,
3130
+ action: context.action,
3131
+ target: context.target,
3132
+ credential_ref: context.credential_ref,
3133
+ mode: context.mode,
3134
+ metadata: context.request_metadata
3135
+ }
3136
+ };
3137
+ case "cerbos":
3138
+ return {
3139
+ principal: {
3140
+ id: context.actor.id || "unknown",
3141
+ roles: [context.actor.tier],
3142
+ attr: {
3143
+ manifest_hash: context.actor.manifest_hash
3144
+ }
3145
+ },
3146
+ resource: {
3147
+ kind: "tool",
3148
+ id: context.action.tool,
3149
+ attr: context.target
3150
+ },
3151
+ actions: [context.action.operation || "call"]
3152
+ };
3153
+ case "generic":
3154
+ default:
3155
+ return context;
3156
+ }
3157
+ }
3158
+ function parseResponse(result, format) {
3159
+ switch (format) {
3160
+ case "opa":
3161
+ if (typeof result.result === "boolean") {
3162
+ return { allowed: result.result };
3163
+ }
3164
+ if (result.result && typeof result.result === "object") {
3165
+ const r = result.result;
3166
+ return {
3167
+ allowed: Boolean(r.allow),
3168
+ reason: r.reason,
3169
+ metadata: r
3170
+ };
3171
+ }
3172
+ return { allowed: false, reason: "unrecognized OPA response" };
3173
+ case "cerbos":
3174
+ if (Array.isArray(result.results) && result.results.length > 0) {
3175
+ const actions = result.results[0].actions;
3176
+ if (actions) {
3177
+ const effect = Object.values(actions)[0];
3178
+ return { allowed: effect === "EFFECT_ALLOW" };
3179
+ }
3180
+ }
3181
+ return { allowed: false, reason: "unrecognized Cerbos response" };
3182
+ case "generic":
3183
+ default:
3184
+ return {
3185
+ allowed: Boolean(result.allowed),
3186
+ reason: result.reason,
3187
+ metadata: result.metadata
3188
+ };
3189
+ }
3190
+ }
3191
+ function fallbackDecision(config, reason) {
3192
+ const fallback = config.fallback || "deny";
3193
+ return {
3194
+ allowed: fallback === "allow",
3195
+ reason: `fallback_${fallback}: ${reason}`
3196
+ };
3197
+ }
3198
+ function buildDecisionContext(toolName, tier, opts) {
3199
+ return {
3200
+ v: 1,
3201
+ actor: {
3202
+ id: opts.agentId,
3203
+ tier,
3204
+ manifest_hash: opts.manifestHash
3205
+ },
3206
+ action: {
3207
+ tool: toolName,
3208
+ operation: "call"
3209
+ },
3210
+ target: {
3211
+ service: opts.slug || "default"
3212
+ },
3213
+ credential_ref: opts.credentialRef,
3214
+ mode: opts.mode,
3215
+ request_metadata: opts.requestMetadata || {}
3216
+ };
3217
+ }
3218
+
3219
+ // src/http-server.ts
3220
+ var import_node_http = require("http");
3221
+ var import_node_fs4 = require("fs");
3222
+ var import_node_path2 = require("path");
3223
+ var LOG_FILE = ".protect-mcp-log.jsonl";
3224
+ var MAX_RECEIPTS = 100;
3225
+ var ReceiptBuffer = class {
3226
+ receipts = [];
3227
+ add(requestId, receipt) {
3228
+ this.receipts.push({
3229
+ request_id: requestId,
3230
+ receipt,
3231
+ timestamp: Date.now()
3232
+ });
3233
+ if (this.receipts.length > MAX_RECEIPTS) {
3234
+ this.receipts = this.receipts.slice(-MAX_RECEIPTS);
3235
+ }
3236
+ }
3237
+ getAll() {
3238
+ return [...this.receipts].reverse();
3239
+ }
3240
+ getById(requestId) {
3241
+ return this.receipts.find((r) => r.request_id === requestId);
3242
+ }
3243
+ count() {
3244
+ return this.receipts.length;
3245
+ }
3246
+ getLatest() {
3247
+ return this.receipts.length > 0 ? this.receipts[this.receipts.length - 1] : void 0;
3248
+ }
3249
+ };
3250
+ function startStatusServer(config, receiptBuffer, approvalStore, approvalNonce) {
3251
+ const startTime = Date.now();
3252
+ const logDir = process.cwd();
3253
+ const server = (0, import_node_http.createServer)((req, res) => {
3254
+ res.setHeader("Access-Control-Allow-Origin", "*");
3255
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
3256
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
3257
+ res.setHeader("Content-Type", "application/json");
3258
+ if (req.method === "OPTIONS") {
3259
+ res.writeHead(204);
3260
+ res.end();
3261
+ return;
3262
+ }
3263
+ const url = new URL(req.url || "/", `http://localhost:${config.port}`);
3264
+ const path = url.pathname;
3265
+ try {
3266
+ if (path === "/health") {
3267
+ handleHealth(res, startTime, config);
3268
+ } else if (path === "/status") {
3269
+ handleStatus(res, logDir);
3270
+ } else if (path === "/receipts") {
3271
+ handleReceipts(res, receiptBuffer, url);
3272
+ } else if (path === "/receipts/latest") {
3273
+ handleReceiptLatest(res, receiptBuffer);
3274
+ } else if (path.startsWith("/receipts/")) {
3275
+ const id = path.slice("/receipts/".length);
3276
+ handleReceiptById(res, receiptBuffer, id);
3277
+ } else if (path === "/approve" && req.method === "POST") {
3278
+ handleApprove(req, res, approvalStore, approvalNonce);
3279
+ } else if (path === "/approvals" && req.method === "GET") {
3280
+ handleListApprovals(res, approvalStore);
3281
+ } else {
3282
+ res.writeHead(404);
3283
+ res.end(JSON.stringify({ error: "not_found", endpoints: ["/health", "/status", "/receipts", "/receipts/latest", "/receipts/:id", "/approve", "/approvals"] }));
3284
+ }
3285
+ } catch (err) {
3286
+ res.writeHead(500);
3287
+ res.end(JSON.stringify({ error: "internal_error" }));
3288
+ }
3289
+ });
3290
+ server.on("error", (err) => {
3291
+ if (config.verbose) {
3292
+ process.stderr.write(`[PROTECT_MCP] HTTP status server error: ${err.message}
3293
+ `);
3294
+ }
3295
+ });
3296
+ server.listen(config.port, "127.0.0.1", () => {
3297
+ if (config.verbose) {
3298
+ process.stderr.write(`[PROTECT_MCP] HTTP status server listening on http://127.0.0.1:${config.port}
3299
+ `);
3300
+ }
3301
+ });
3302
+ server.unref();
3303
+ return server;
3304
+ }
3305
+ function handleHealth(res, startTime, config) {
3306
+ res.writeHead(200);
3307
+ res.end(JSON.stringify({
3308
+ status: "ok",
3309
+ uptime_ms: Date.now() - startTime,
3310
+ mode: config.mode,
3311
+ version: "0.3.1"
3312
+ }));
3313
+ }
3314
+ function handleStatus(res, logDir) {
3315
+ const logPath = (0, import_node_path2.join)(logDir, LOG_FILE);
3316
+ if (!(0, import_node_fs4.existsSync)(logPath)) {
3317
+ res.writeHead(200);
3318
+ res.end(JSON.stringify({ entries: 0, message: "no log file yet" }));
3319
+ return;
3320
+ }
3321
+ const raw = (0, import_node_fs4.readFileSync)(logPath, "utf-8");
3322
+ const lines = raw.trim().split("\n").filter(Boolean);
3323
+ const entries = [];
3324
+ for (const line of lines) {
3325
+ try {
3326
+ entries.push(JSON.parse(line));
3327
+ } catch {
3328
+ }
3329
+ }
3330
+ const toolCounts = {};
3331
+ let allowCount = 0, denyCount = 0;
3332
+ const tierCounts = {};
3333
+ for (const e of entries) {
3334
+ toolCounts[e.tool] = (toolCounts[e.tool] || 0) + 1;
3335
+ if (e.decision === "allow") allowCount++;
3336
+ else denyCount++;
3337
+ if (e.tier) tierCounts[e.tier] = (tierCounts[e.tier] || 0) + 1;
3338
+ }
3339
+ res.writeHead(200);
3340
+ res.end(JSON.stringify({
3341
+ entries: entries.length,
3342
+ allow: allowCount,
3343
+ deny: denyCount,
3344
+ tools: toolCounts,
3345
+ tiers: tierCounts,
3346
+ first_timestamp: entries.length > 0 ? entries[0].timestamp : null,
3347
+ last_timestamp: entries.length > 0 ? entries[entries.length - 1].timestamp : null
3348
+ }));
3349
+ }
3350
+ function handleReceipts(res, buffer, url) {
3351
+ const limit = parseInt(url.searchParams.get("limit") || "20", 10);
3352
+ const receipts = buffer.getAll().slice(0, Math.min(limit, MAX_RECEIPTS));
3353
+ res.writeHead(200);
3354
+ res.end(JSON.stringify({
3355
+ count: receipts.length,
3356
+ total: buffer.count(),
3357
+ receipts
3358
+ }));
3359
+ }
3360
+ function handleReceiptLatest(res, buffer) {
3361
+ const latest = buffer.getLatest();
3362
+ if (!latest) {
3363
+ res.writeHead(404);
3364
+ res.end(JSON.stringify({ error: "no_receipts", message: "No receipts yet. Make a tool call through protect-mcp first." }));
3365
+ return;
3366
+ }
3367
+ res.writeHead(200);
3368
+ res.end(JSON.stringify(latest));
3369
+ }
3370
+ function handleReceiptById(res, buffer, id) {
3371
+ const receipt = buffer.getById(id);
3372
+ if (!receipt) {
3373
+ res.writeHead(404);
3374
+ res.end(JSON.stringify({ error: "receipt_not_found", request_id: id }));
3375
+ return;
3376
+ }
3377
+ res.writeHead(200);
3378
+ res.end(JSON.stringify(receipt));
3379
+ }
3380
+ function handleApprove(req, res, approvalStore, expectedNonce) {
3381
+ if (!approvalStore) {
3382
+ res.writeHead(503);
3383
+ res.end(JSON.stringify({ error: "approval_store_not_available" }));
3384
+ return;
3385
+ }
3386
+ let body = "";
3387
+ req.on("data", (chunk) => {
3388
+ body += chunk.toString();
3389
+ });
3390
+ req.on("end", () => {
3391
+ try {
3392
+ const { request_id, tool, mode, nonce } = JSON.parse(body);
3393
+ if (expectedNonce && nonce !== expectedNonce) {
3394
+ res.writeHead(403);
3395
+ res.end(JSON.stringify({ error: "invalid_nonce", message: "Approval nonce does not match. Check stderr output for the correct nonce." }));
3396
+ return;
3397
+ }
3398
+ if (!tool || typeof tool !== "string") {
3399
+ res.writeHead(400);
3400
+ res.end(JSON.stringify({ error: "missing_tool", usage: '{"request_id":"abc123","tool":"send_email","mode":"once|always","nonce":"..."}' }));
3401
+ return;
3402
+ }
3403
+ const grantMode = mode === "always" ? "always" : "once";
3404
+ const ttlMs = grantMode === "once" ? 5 * 60 * 1e3 : 24 * 60 * 60 * 1e3;
3405
+ const grantEntry = { tool, mode: grantMode, expires_at: Date.now() + ttlMs };
3406
+ if (grantMode === "always") {
3407
+ approvalStore.set(`always:${tool}`, grantEntry);
3408
+ } else if (request_id) {
3409
+ approvalStore.set(request_id, grantEntry);
3410
+ } else {
3411
+ approvalStore.set(tool, grantEntry);
3412
+ }
3413
+ res.writeHead(200);
3414
+ res.end(JSON.stringify({
3415
+ approved: true,
3416
+ request_id: request_id || null,
3417
+ tool,
3418
+ mode: grantMode,
3419
+ expires_in_seconds: ttlMs / 1e3
3420
+ }));
3421
+ } catch {
3422
+ res.writeHead(400);
3423
+ res.end(JSON.stringify({ error: "invalid_json", usage: '{"request_id":"abc123","tool":"send_email","mode":"once","nonce":"..."}' }));
3424
+ }
3425
+ });
3426
+ }
3427
+ function handleListApprovals(res, approvalStore) {
3428
+ if (!approvalStore) {
3429
+ res.writeHead(200);
3430
+ res.end(JSON.stringify({ grants: [] }));
3431
+ return;
3432
+ }
3433
+ const now = Date.now();
3434
+ const grants = [];
3435
+ for (const [key, grant] of approvalStore) {
3436
+ if (now < grant.expires_at) {
3437
+ grants.push({ key, tool: grant.tool, mode: grant.mode, expires_in_seconds: Math.round((grant.expires_at - now) / 1e3) });
3438
+ }
3439
+ }
3440
+ res.writeHead(200);
3441
+ res.end(JSON.stringify({ grants }));
3442
+ }
3443
+
2907
3444
  // src/gateway.ts
3445
+ var LOG_FILE2 = ".protect-mcp-log.jsonl";
3446
+ var RECEIPTS_FILE = ".protect-mcp-receipts.jsonl";
2908
3447
  var ProtectGateway = class {
2909
3448
  child = null;
2910
3449
  config;
2911
3450
  rateLimitStore = /* @__PURE__ */ new Map();
2912
3451
  clientReader = null;
2913
- // Trust-tier state for the current session
3452
+ logFilePath;
3453
+ receiptFilePath;
3454
+ evidenceStore;
3455
+ receiptBuffer;
3456
+ /** Approval grants keyed by request_id (scoped to the specific action that was requested) */
3457
+ approvalStore = /* @__PURE__ */ new Map();
3458
+ /** Random nonce generated at startup — required for approval endpoint authentication */
3459
+ approvalNonce = (0, import_node_crypto2.randomBytes)(16).toString("hex");
2914
3460
  currentTier = "unknown";
2915
3461
  admissionResult = null;
2916
3462
  constructor(config) {
2917
3463
  this.config = config;
3464
+ this.logFilePath = (0, import_node_path3.join)(process.cwd(), LOG_FILE2);
3465
+ this.receiptFilePath = (0, import_node_path3.join)(process.cwd(), RECEIPTS_FILE);
3466
+ this.evidenceStore = new EvidenceStore();
3467
+ this.receiptBuffer = new ReceiptBuffer();
2918
3468
  }
2919
- /**
2920
- * Start the gateway: spawn child process and wire up message relay.
2921
- */
2922
3469
  async start() {
2923
3470
  const { command, args, verbose } = this.config;
2924
3471
  const mode = this.config.enforce ? "enforce" : "shadow";
@@ -2935,11 +3482,37 @@ var ProtectGateway = class {
2935
3482
  const labels = Object.keys(this.config.credentials);
2936
3483
  this.log(`Credential vault: ${labels.length} credential(s) configured [${labels.join(", ")}]`);
2937
3484
  }
3485
+ if (this.config.policy?.policy_engine === "external" || this.config.policy?.policy_engine === "hybrid") {
3486
+ this.log(`External PDP: ${this.config.policy.external?.endpoint || "not configured"}`);
3487
+ }
2938
3488
  }
2939
- this.child = (0, import_node_child_process.spawn)(command, args, {
2940
- stdio: ["pipe", "pipe", "pipe"],
2941
- env: { ...process.env }
2942
- });
3489
+ this.log(`Approval nonce: ${this.approvalNonce}`);
3490
+ const httpPort = parseInt(process.env.PROTECT_MCP_HTTP_PORT || "9876", 10);
3491
+ if (httpPort > 0) {
3492
+ try {
3493
+ startStatusServer(
3494
+ { port: httpPort, mode, verbose },
3495
+ this.receiptBuffer,
3496
+ this.approvalStore,
3497
+ this.approvalNonce
3498
+ );
3499
+ } catch {
3500
+ if (verbose) this.log(`HTTP status server could not start on port ${httpPort}`);
3501
+ }
3502
+ }
3503
+ const childEnv = { ...process.env };
3504
+ if (this.config.credentials) {
3505
+ for (const [label, credConfig] of Object.entries(this.config.credentials)) {
3506
+ if (credConfig.inject === "env" && credConfig.name && credConfig.value_env) {
3507
+ const envValue = process.env[credConfig.value_env];
3508
+ if (envValue) {
3509
+ childEnv[credConfig.name] = envValue;
3510
+ if (verbose) this.log(`Credential "${label}": injected as env var "${credConfig.name}"`);
3511
+ }
3512
+ }
3513
+ }
3514
+ }
3515
+ this.child = (0, import_node_child_process.spawn)(command, args, { stdio: ["pipe", "pipe", "pipe"], env: childEnv });
2943
3516
  if (!this.child.stdin || !this.child.stdout || !this.child.stderr) {
2944
3517
  throw new Error("Failed to create pipes to child process");
2945
3518
  }
@@ -2955,9 +3528,8 @@ var ProtectGateway = class {
2955
3528
  this.handleClientMessage(line);
2956
3529
  });
2957
3530
  this.child.on("exit", (code, signal) => {
2958
- if (this.config.verbose) {
2959
- this.log(`Child process exited (code=${code}, signal=${signal})`);
2960
- }
3531
+ if (verbose) this.log(`Child process exited (code=${code}, signal=${signal})`);
3532
+ this.evidenceStore.save();
2961
3533
  process.exit(code ?? 1);
2962
3534
  });
2963
3535
  this.child.on("error", (err) => {
@@ -2967,30 +3539,18 @@ var ProtectGateway = class {
2967
3539
  process.on("SIGINT", () => this.stop());
2968
3540
  process.on("SIGTERM", () => this.stop());
2969
3541
  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
- }
3542
+ if (verbose) this.log("Client stdin closed, closing child stdin");
3543
+ if (this.child?.stdin?.writable) this.child.stdin.end();
2976
3544
  });
2977
3545
  }
2978
- /**
2979
- * Set the trust tier for this session.
2980
- * Called at admission (first interaction) or by explicit manifest presentation.
2981
- */
2982
3546
  setManifest(manifest) {
2983
- this.admissionResult = evaluateTier(manifest);
3547
+ this.admissionResult = evaluateTier(manifest, { evidenceStore: this.evidenceStore });
2984
3548
  this.currentTier = this.admissionResult.tier;
2985
3549
  if (this.config.verbose) {
2986
- this.log(`Admission: tier=${this.currentTier} agent=${this.admissionResult.agent_id || "none"} reason=${this.admissionResult.reason}`);
3550
+ this.log(`Admission: tier=${this.currentTier} agent=${this.admissionResult.agent_id || "none"}`);
2987
3551
  }
2988
3552
  return this.admissionResult;
2989
3553
  }
2990
- /**
2991
- * Handle a message from the MCP client (stdin).
2992
- * Intercept tools/call requests; pass through everything else.
2993
- */
2994
3554
  handleClientMessage(raw) {
2995
3555
  const trimmed = raw.trim();
2996
3556
  if (!trimmed) return;
@@ -3002,25 +3562,38 @@ var ProtectGateway = class {
3002
3562
  return;
3003
3563
  }
3004
3564
  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
- }
3565
+ this.interceptToolCallAsync(message, trimmed);
3566
+ return;
3010
3567
  }
3011
3568
  this.sendToChild(trimmed);
3012
3569
  }
3013
- /**
3014
- * Handle a message from the wrapped MCP server (child stdout).
3015
- * Forward to client (stdout) transparently.
3016
- */
3570
+ async interceptToolCallAsync(request, raw) {
3571
+ const result = await this.interceptToolCall(request);
3572
+ if (result) {
3573
+ this.sendToClient(JSON.stringify(result));
3574
+ } else {
3575
+ const modified = this.injectParamsCredentials(request);
3576
+ this.sendToChild(JSON.stringify(modified));
3577
+ }
3578
+ }
3017
3579
  handleServerMessage(raw) {
3018
3580
  this.sendToClient(raw);
3019
3581
  }
3020
- /**
3021
- * Intercept a tools/call request. Returns a JSON-RPC error response if denied, null if allowed.
3022
- */
3023
- interceptToolCall(request) {
3582
+ injectParamsCredentials(request) {
3583
+ if (!this.config.credentials) return request;
3584
+ const injections = {};
3585
+ for (const [label, credConfig] of Object.entries(this.config.credentials)) {
3586
+ if (credConfig.inject === "header" || credConfig.inject === "query") {
3587
+ const cred = resolveCredential(label, this.config.credentials);
3588
+ if (cred.resolved && cred.value && cred.name) {
3589
+ injections[cred.name] = cred.value;
3590
+ }
3591
+ }
3592
+ }
3593
+ if (Object.keys(injections).length === 0) return request;
3594
+ return { ...request, params: { ...request.params, _credentials: injections } };
3595
+ }
3596
+ async interceptToolCall(request) {
3024
3597
  const toolName = request.params?.name || "unknown";
3025
3598
  const requestId = (0, import_node_crypto2.randomUUID)().slice(0, 12);
3026
3599
  const toolPolicy = getToolPolicy(toolName, this.config.policy);
@@ -3031,54 +3604,76 @@ var ProtectGateway = class {
3031
3604
  if (cred.resolved) {
3032
3605
  credentialRef = cred.label;
3033
3606
  } 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
- });
3607
+ this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: "credential_error", request_id: requestId, tier: this.currentTier, credential_ref: toolName });
3042
3608
  if (this.config.enforce) {
3043
- this.log(`Credential error for "${toolName}": ${cred.error}`);
3044
3609
  return this.makeErrorResponse(request.id, -32600, `Credential error for tool "${toolName}"`);
3045
3610
  }
3046
3611
  }
3047
3612
  }
3613
+ if (this.config.policy?.external && (this.config.policy.policy_engine === "external" || this.config.policy.policy_engine === "hybrid")) {
3614
+ try {
3615
+ const ctx = buildDecisionContext(toolName, this.currentTier, {
3616
+ agentId: this.admissionResult?.agent_id,
3617
+ manifestHash: this.admissionResult?.manifest_hash,
3618
+ credentialRef,
3619
+ mode,
3620
+ slug: this.config.slug
3621
+ });
3622
+ const externalDecision = await queryExternalPDP(ctx, this.config.policy.external);
3623
+ if (!externalDecision.allowed) {
3624
+ const reason = `external_pdp_deny${externalDecision.reason ? ": " + externalDecision.reason : ""}`;
3625
+ this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: reason, request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
3626
+ if (this.config.enforce) {
3627
+ return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" denied by external policy engine`);
3628
+ }
3629
+ if (this.config.policy.policy_engine === "external") return null;
3630
+ }
3631
+ } catch (err) {
3632
+ if (this.config.verbose) this.log(`External PDP error: ${err instanceof Error ? err.message : err}`);
3633
+ }
3634
+ }
3048
3635
  if (toolPolicy.min_tier) {
3049
3636
  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
- });
3637
+ this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: "tier_insufficient", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
3058
3638
  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
- );
3639
+ return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" requires tier "${toolPolicy.min_tier}"`);
3064
3640
  }
3065
3641
  return null;
3066
3642
  }
3067
3643
  }
3068
3644
  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
- });
3645
+ this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: "policy_block", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
3077
3646
  if (this.config.enforce) {
3078
3647
  return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" is blocked by policy`);
3079
3648
  }
3080
3649
  return null;
3081
3650
  }
3651
+ if (toolPolicy.require_approval) {
3652
+ const grant = this.approvalStore.get(requestId);
3653
+ const alwaysGrant = this.approvalStore.get(`always:${toolName}`);
3654
+ if (grant && Date.now() < grant.expires_at || alwaysGrant && Date.now() < alwaysGrant.expires_at) {
3655
+ if (grant && grant.mode === "once") this.approvalStore.delete(requestId);
3656
+ this.emitDecisionLog({ tool: toolName, decision: "allow", reason_code: "approval_granted", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
3657
+ return null;
3658
+ }
3659
+ this.emitDecisionLog({ tool: toolName, decision: "require_approval", reason_code: "requires_human_approval", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
3660
+ if (this.config.enforce) {
3661
+ return {
3662
+ jsonrpc: "2.0",
3663
+ id: request.id,
3664
+ result: {
3665
+ content: [
3666
+ {
3667
+ type: "text",
3668
+ 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.`
3669
+ }
3670
+ ],
3671
+ isError: true
3672
+ }
3673
+ };
3674
+ }
3675
+ return null;
3676
+ }
3082
3677
  const rateSpec = this.getTierRateLimit(toolPolicy, this.currentTier);
3083
3678
  if (rateSpec) {
3084
3679
  try {
@@ -3086,59 +3681,22 @@ var ProtectGateway = class {
3086
3681
  const key = `tool:${toolName}:${this.currentTier}`;
3087
3682
  const { allowed, remaining } = checkRateLimit(key, limit, this.rateLimitStore);
3088
3683
  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
- });
3684
+ 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
3685
  if (this.config.enforce) {
3099
- return this.makeErrorResponse(
3100
- request.id,
3101
- -32600,
3102
- `Tool "${toolName}" rate limit exceeded (${rateSpec})`
3103
- );
3686
+ return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" rate limit exceeded (${rateSpec})`);
3104
3687
  }
3105
3688
  return null;
3106
3689
  }
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
- });
3690
+ this.emitDecisionLog({ tool: toolName, decision: "allow", reason_code: "policy_allow", request_id: requestId, rate_limit_remaining: remaining, tier: this.currentTier, credential_ref: credentialRef });
3116
3691
  } 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
- });
3692
+ this.emitDecisionLog({ tool: toolName, decision: "allow", reason_code: "default_allow", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
3125
3693
  }
3126
3694
  } else {
3127
3695
  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
- });
3696
+ this.emitDecisionLog({ tool: toolName, decision: "allow", reason_code: reasonCode, request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
3136
3697
  }
3137
3698
  return null;
3138
3699
  }
3139
- /**
3140
- * Get the applicable rate limit spec based on the agent's tier.
3141
- */
3142
3700
  getTierRateLimit(policy, tier) {
3143
3701
  if (policy.rate_limits && policy.rate_limits[tier]) {
3144
3702
  const tierLimit = policy.rate_limits[tier];
@@ -3146,10 +3704,6 @@ var ProtectGateway = class {
3146
3704
  }
3147
3705
  return policy.rate_limit;
3148
3706
  }
3149
- /**
3150
- * Emit a structured decision log to stderr.
3151
- * If signing is enabled, also emits a signed artifact.
3152
- */
3153
3707
  emitDecisionLog(entry) {
3154
3708
  const mode = this.config.enforce ? "enforce" : "shadow";
3155
3709
  const log = {
@@ -3168,55 +3722,48 @@ var ProtectGateway = class {
3168
3722
  };
3169
3723
  process.stderr.write(`[PROTECT_MCP] ${JSON.stringify(log)}
3170
3724
  `);
3725
+ try {
3726
+ (0, import_node_fs5.appendFileSync)(this.logFilePath, JSON.stringify(log) + "\n");
3727
+ } catch {
3728
+ }
3171
3729
  if (isSigningEnabled()) {
3172
3730
  const signed = signDecision(log);
3173
3731
  if (signed.signed) {
3174
3732
  process.stderr.write(`[PROTECT_MCP_RECEIPT] ${signed.signed}
3175
3733
  `);
3734
+ try {
3735
+ (0, import_node_fs5.appendFileSync)(this.receiptFilePath, signed.signed + "\n");
3736
+ } catch {
3737
+ }
3738
+ this.receiptBuffer.add(log.request_id, signed.signed);
3739
+ if (this.admissionResult?.agent_id) {
3740
+ this.evidenceStore.record(this.admissionResult.agent_id, this.config.signing?.issuer || "protect-mcp");
3741
+ if (this.evidenceStore.getSummary(this.admissionResult.agent_id).receipt_count % 10 === 0) {
3742
+ this.evidenceStore.save();
3743
+ }
3744
+ }
3176
3745
  } else if (signed.warning) {
3177
3746
  process.stderr.write(`[PROTECT_MCP] Warning: ${signed.warning}
3178
3747
  `);
3179
3748
  }
3180
3749
  }
3181
3750
  }
3182
- /**
3183
- * Create a JSON-RPC error response.
3184
- */
3185
3751
  makeErrorResponse(id, code, message) {
3186
- return {
3187
- jsonrpc: "2.0",
3188
- id,
3189
- error: { code, message }
3190
- };
3752
+ return { jsonrpc: "2.0", id, error: { code, message } };
3191
3753
  }
3192
- /**
3193
- * Send a message to the child process (wrapped MCP server).
3194
- */
3195
3754
  sendToChild(message) {
3196
- if (this.child?.stdin?.writable) {
3197
- this.child.stdin.write(message + "\n");
3198
- }
3755
+ if (this.child?.stdin?.writable) this.child.stdin.write(message + "\n");
3199
3756
  }
3200
- /**
3201
- * Send a message to the MCP client (stdout).
3202
- */
3203
3757
  sendToClient(message) {
3204
3758
  process.stdout.write(message + "\n");
3205
3759
  }
3206
- /**
3207
- * Log a message to stderr (debug output).
3208
- */
3209
3760
  log(message) {
3210
3761
  process.stderr.write(`[PROTECT_MCP] ${message}
3211
3762
  `);
3212
3763
  }
3213
- /**
3214
- * Stop the gateway: kill child process and exit.
3215
- */
3216
3764
  stop() {
3217
- if (this.clientReader) {
3218
- this.clientReader.close();
3219
- }
3765
+ this.evidenceStore.save();
3766
+ if (this.clientReader) this.clientReader.close();
3220
3767
  if (this.child) {
3221
3768
  this.child.kill("SIGTERM");
3222
3769
  this.child = null;
@@ -3233,6 +3780,11 @@ protect-mcp \u2014 Shadow-mode security gateway for MCP servers
3233
3780
  Usage:
3234
3781
  protect-mcp [options] -- <command> [args...]
3235
3782
  protect-mcp init [--dir <path>]
3783
+ protect-mcp demo
3784
+ protect-mcp status [--dir <path>]
3785
+ protect-mcp digest [--today] [--dir <path>]
3786
+ protect-mcp receipts [--last <n>] [--dir <path>]
3787
+ protect-mcp bundle [--output <path>] [--dir <path>]
3236
3788
 
3237
3789
  Options:
3238
3790
  --policy <path> Policy/config JSON file (default: allow-all)
@@ -3243,12 +3795,22 @@ Options:
3243
3795
 
3244
3796
  Commands:
3245
3797
  init Generate config template, Ed25519 keypair, and sample policy
3798
+ demo Start a demo server wrapped with protect-mcp (see receipts instantly)
3799
+ status Show tool call statistics from the local decision log
3800
+ digest Generate a human-readable summary of agent activity
3801
+ receipts Show recent persisted signed receipts
3802
+ bundle Export an offline-verifiable audit bundle
3246
3803
 
3247
3804
  Examples:
3248
3805
  protect-mcp -- node my-server.js
3249
3806
  protect-mcp --policy protect-mcp.json -- node my-server.js
3250
3807
  protect-mcp --policy protect-mcp.json --enforce -- node my-server.js
3251
3808
  protect-mcp init
3809
+ protect-mcp demo
3810
+ protect-mcp status
3811
+ protect-mcp digest --today
3812
+ protect-mcp receipts --last 10
3813
+ protect-mcp bundle --output audit.json
3252
3814
 
3253
3815
  `);
3254
3816
  }
@@ -3292,17 +3854,17 @@ function parseArgs(argv) {
3292
3854
  return { policyPath, slug, enforce, verbose, childCommand };
3293
3855
  }
3294
3856
  async function handleInit(argv) {
3295
- const { writeFileSync, existsSync: existsSync2, mkdirSync } = await import("fs");
3296
- const { join } = await import("path");
3857
+ const { writeFileSync: writeFileSync2, existsSync: existsSync4, mkdirSync } = await import("fs");
3858
+ const { join: join4 } = await import("path");
3297
3859
  let dir = process.cwd();
3298
3860
  const dirIdx = argv.indexOf("--dir");
3299
3861
  if (dirIdx !== -1 && argv[dirIdx + 1]) {
3300
3862
  dir = argv[dirIdx + 1];
3301
3863
  }
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)) {
3864
+ const configPath = join4(dir, "protect-mcp.json");
3865
+ const keysDir = join4(dir, "keys");
3866
+ const keyPath = join4(keysDir, "gateway.json");
3867
+ if (existsSync4(configPath)) {
3306
3868
  process.stderr.write(`[PROTECT_MCP] Config already exists at ${configPath}
3307
3869
  `);
3308
3870
  process.stderr.write("[PROTECT_MCP] Delete it first if you want to regenerate.\n");
@@ -3313,10 +3875,10 @@ async function handleInit(argv) {
3313
3875
  const artifacts = await import("@veritasacta/artifacts");
3314
3876
  keypair = artifacts.generateKeypair();
3315
3877
  } catch {
3316
- const { randomBytes: randomBytes2 } = await import("crypto");
3878
+ const { randomBytes: randomBytes3 } = await import("crypto");
3317
3879
  const { ed25519: ed255192 } = await Promise.resolve().then(() => (init_ed25519(), ed25519_exports));
3318
3880
  const { bytesToHex: bytesToHex2 } = await Promise.resolve().then(() => (init_utils(), utils_exports));
3319
- const privateKey = randomBytes2(32);
3881
+ const privateKey = randomBytes3(32);
3320
3882
  const publicKey = ed255192.getPublicKey(privateKey);
3321
3883
  keypair = {
3322
3884
  privateKey: bytesToHex2(privateKey),
@@ -3324,19 +3886,19 @@ async function handleInit(argv) {
3324
3886
  kid: "generated"
3325
3887
  };
3326
3888
  }
3327
- if (!existsSync2(keysDir)) {
3889
+ if (!existsSync4(keysDir)) {
3328
3890
  mkdirSync(keysDir, { recursive: true });
3329
3891
  }
3330
- writeFileSync(keyPath, JSON.stringify({
3892
+ writeFileSync2(keyPath, JSON.stringify({
3331
3893
  privateKey: keypair.privateKey,
3332
3894
  publicKey: keypair.publicKey,
3333
3895
  kid: keypair.kid,
3334
3896
  generated_at: (/* @__PURE__ */ new Date()).toISOString(),
3335
3897
  warning: "KEEP THIS FILE SECRET. Never commit to version control."
3336
3898
  }, null, 2) + "\n");
3337
- const gitignorePath = join(keysDir, ".gitignore");
3338
- if (!existsSync2(gitignorePath)) {
3339
- writeFileSync(gitignorePath, "# Never commit signing keys\n*.json\n");
3899
+ const gitignorePath = join4(keysDir, ".gitignore");
3900
+ if (!existsSync4(gitignorePath)) {
3901
+ writeFileSync2(gitignorePath, "# Never commit signing keys\n*.json\n");
3340
3902
  }
3341
3903
  const config = {
3342
3904
  tools: {
@@ -3363,19 +3925,27 @@ async function handleInit(argv) {
3363
3925
  },
3364
3926
  credentials: {
3365
3927
  _example_api: {
3366
- inject: "header",
3367
- name: "Authorization",
3928
+ inject: "env",
3929
+ name: "EXAMPLE_API_KEY",
3368
3930
  value_env: "EXAMPLE_API_KEY",
3369
3931
  _comment: "Remove the underscore prefix and set EXAMPLE_API_KEY in your environment"
3370
3932
  }
3371
3933
  }
3372
3934
  };
3373
- writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
3935
+ writeFileSync2(configPath, JSON.stringify(config, null, 2) + "\n");
3936
+ const claudeConfig = {
3937
+ "mcpServers": {
3938
+ "my-server": {
3939
+ "command": "npx",
3940
+ "args": ["protect-mcp", "--policy", configPath, "--", "node", "my-server.js"]
3941
+ }
3942
+ }
3943
+ };
3374
3944
  process.stderr.write(`
3375
3945
  ${bold("protect-mcp initialized!")}
3376
3946
 
3377
3947
  Created:
3378
- ${configPath} Config with shadow mode + optional local signing
3948
+ ${configPath} Config with shadow mode + local signing
3379
3949
  ${keyPath} Ed25519 signing keypair
3380
3950
 
3381
3951
  ${bold("Next steps:")}
@@ -3389,13 +3959,427 @@ ${bold("Your gateway public key:")}
3389
3959
  ${bold("Key ID (kid):")}
3390
3960
  ${keypair.kid}
3391
3961
 
3962
+ ${bold("Claude Desktop config snippet")} (add to claude_desktop_config.json):
3963
+ ${dim(JSON.stringify(claudeConfig, null, 2))}
3964
+
3965
+ ${bold("Quick demo:")}
3966
+ protect-mcp demo
3967
+
3392
3968
  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.
3969
+ Add --enforce when ready to block policy violations.
3970
+ `);
3971
+ }
3972
+ async function handleDemo() {
3973
+ const { existsSync: existsSync4 } = await import("fs");
3974
+ const { join: join4, dirname, resolve } = await import("path");
3975
+ const cliPath = resolve(process.argv[1] || "dist/cli.js");
3976
+ const cliDir = dirname(cliPath);
3977
+ const demoServerPath = join4(cliDir, "demo-server.js");
3978
+ const configPath = join4(process.cwd(), "protect-mcp.json");
3979
+ const hasConfig = existsSync4(configPath);
3980
+ if (!hasConfig) {
3981
+ process.stderr.write(`
3982
+ ${bold("protect-mcp demo")}
3983
+
3984
+ Starting demo with default shadow mode (no signing).
3985
+ For signed receipts, run ${dim("npx protect-mcp init")} first.
3986
+
3987
+ `);
3988
+ } else {
3989
+ process.stderr.write(`
3990
+ ${bold("protect-mcp demo")}
3991
+
3992
+ Using config from ${configPath}
3993
+ Starting demo server with 5 tools...
3994
+
3995
+ `);
3996
+ }
3997
+ let policy = null;
3998
+ let policyDigest = "none";
3999
+ let credentials;
4000
+ let signing;
4001
+ if (hasConfig) {
4002
+ try {
4003
+ const loaded = loadPolicy(configPath);
4004
+ policy = loaded.policy;
4005
+ policyDigest = loaded.digest;
4006
+ credentials = loaded.credentials;
4007
+ signing = loaded.signing;
4008
+ } catch (err) {
4009
+ process.stderr.write(`[PROTECT_MCP] Warning: Could not load config: ${err instanceof Error ? err.message : err}
4010
+ `);
4011
+ }
4012
+ }
4013
+ if (signing) {
4014
+ const warnings = await initSigning(signing);
4015
+ for (const w of warnings) {
4016
+ process.stderr.write(`[PROTECT_MCP] Warning: ${w}
4017
+ `);
4018
+ }
4019
+ }
4020
+ if (credentials) {
4021
+ const warnings = validateCredentials(credentials);
4022
+ for (const w of warnings) {
4023
+ process.stderr.write(`[PROTECT_MCP] Warning: ${w}
4024
+ `);
4025
+ }
4026
+ }
4027
+ const config = {
4028
+ command: process.execPath,
4029
+ // node
4030
+ args: [demoServerPath],
4031
+ policy,
4032
+ policyDigest,
4033
+ enforce: false,
4034
+ // Demo always runs in shadow mode
4035
+ verbose: true,
4036
+ signing,
4037
+ credentials
4038
+ };
4039
+ const gateway = new ProtectGateway(config);
4040
+ process.stderr.write(`${bold("Demo ready!")} The demo server is running.
4041
+ `);
4042
+ process.stderr.write(`Send JSON-RPC tool calls on stdin, or use an MCP client.
4043
+
4044
+ `);
4045
+ process.stderr.write(`${dim("Example (paste into stdin):")}
4046
+ `);
4047
+ process.stderr.write(`${dim('{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read_file","arguments":{"path":"/etc/hosts"}}}')}
4048
+
4049
+ `);
4050
+ await gateway.start();
4051
+ }
4052
+ async function handleStatus2(argv) {
4053
+ const { readFileSync: readFileSync5, existsSync: existsSync4 } = await import("fs");
4054
+ const { join: join4 } = await import("path");
4055
+ let dir = process.cwd();
4056
+ const dirIdx = argv.indexOf("--dir");
4057
+ if (dirIdx !== -1 && argv[dirIdx + 1]) {
4058
+ dir = argv[dirIdx + 1];
4059
+ }
4060
+ const logPath = join4(dir, ".protect-mcp-log.jsonl");
4061
+ if (!existsSync4(logPath)) {
4062
+ process.stderr.write(`${bold("protect-mcp status")}
4063
+
4064
+ `);
4065
+ process.stderr.write(`No log file found at ${logPath}
4066
+ `);
4067
+ process.stderr.write(`Run protect-mcp with a wrapped server first to generate logs.
4068
+ `);
4069
+ process.exit(0);
4070
+ }
4071
+ const raw = readFileSync5(logPath, "utf-8");
4072
+ const lines = raw.trim().split("\n").filter(Boolean);
4073
+ if (lines.length === 0) {
4074
+ process.stderr.write(`${bold("protect-mcp status")}
4075
+
4076
+ No entries in log file.
4077
+ `);
4078
+ process.exit(0);
4079
+ }
4080
+ const entries = [];
4081
+ for (const line of lines) {
4082
+ try {
4083
+ entries.push(JSON.parse(line));
4084
+ } catch {
4085
+ }
4086
+ }
4087
+ if (entries.length === 0) {
4088
+ process.stderr.write(`${bold("protect-mcp status")}
4089
+
4090
+ No valid entries in log file.
4091
+ `);
4092
+ process.exit(0);
4093
+ }
4094
+ const toolCounts = /* @__PURE__ */ new Map();
4095
+ let allowCount = 0;
4096
+ let denyCount = 0;
4097
+ let rateLimitCount = 0;
4098
+ const tierCounts = /* @__PURE__ */ new Map();
4099
+ const reasonCounts = /* @__PURE__ */ new Map();
4100
+ for (const entry of entries) {
4101
+ toolCounts.set(entry.tool, (toolCounts.get(entry.tool) || 0) + 1);
4102
+ if (entry.decision === "allow") allowCount++;
4103
+ else if (entry.decision === "deny") denyCount++;
4104
+ if (entry.reason_code === "rate_limit_exceeded") rateLimitCount++;
4105
+ if (entry.tier) tierCounts.set(entry.tier, (tierCounts.get(entry.tier) || 0) + 1);
4106
+ reasonCounts.set(entry.reason_code, (reasonCounts.get(entry.reason_code) || 0) + 1);
4107
+ }
4108
+ const firstTs = new Date(Math.min(...entries.map((e) => e.timestamp)));
4109
+ const lastTs = new Date(Math.max(...entries.map((e) => e.timestamp)));
4110
+ const sortedTools = [...toolCounts.entries()].sort((a, b) => b[1] - a[1]);
4111
+ process.stdout.write(`
4112
+ ${bold("protect-mcp status")}
4113
+
4114
+ `);
4115
+ process.stdout.write(` Total decisions: ${bold(String(entries.length))}
4116
+ `);
4117
+ process.stdout.write(` ${green("\u2713 Allow")}: ${allowCount} ${red("\u2717 Deny")}: ${denyCount} ${yellow("\u2298 Rate-limited")}: ${rateLimitCount}
4118
+
4119
+ `);
4120
+ process.stdout.write(` ${bold("Time range:")}
4121
+ `);
4122
+ process.stdout.write(` First: ${firstTs.toISOString()}
4123
+ `);
4124
+ process.stdout.write(` Last: ${lastTs.toISOString()}
4125
+
4126
+ `);
4127
+ process.stdout.write(` ${bold("Top tools:")}
4128
+ `);
4129
+ for (const [tool, count] of sortedTools.slice(0, 10)) {
4130
+ const bar = "\u2588".repeat(Math.min(Math.ceil(count / entries.length * 30), 30));
4131
+ process.stdout.write(` ${tool.padEnd(20)} ${String(count).padStart(4)} ${dim(bar)}
4132
+ `);
4133
+ }
4134
+ if (tierCounts.size > 0) {
4135
+ process.stdout.write(`
4136
+ ${bold("Trust tiers seen:")}
4137
+ `);
4138
+ for (const [tier, count] of tierCounts) {
4139
+ process.stdout.write(` ${tier.padEnd(15)} ${count}
4140
+ `);
4141
+ }
4142
+ }
4143
+ process.stdout.write(`
4144
+ ${bold("Decision reasons:")}
4145
+ `);
4146
+ for (const [reason, count] of [...reasonCounts.entries()].sort((a, b) => b[1] - a[1])) {
4147
+ process.stdout.write(` ${reason.padEnd(25)} ${count}
4148
+ `);
4149
+ }
4150
+ const evidencePath = join4(dir, ".protect-mcp-evidence.json");
4151
+ if (existsSync4(evidencePath)) {
4152
+ try {
4153
+ const evidenceRaw = readFileSync5(evidencePath, "utf-8");
4154
+ const evidence = JSON.parse(evidenceRaw);
4155
+ const agentCount = Object.keys(evidence.agents || {}).length;
4156
+ process.stdout.write(`
4157
+ ${bold("Evidence store:")} ${agentCount} agent(s) tracked
4158
+ `);
4159
+ } catch {
4160
+ }
4161
+ }
4162
+ const keyPath = join4(dir, "keys", "gateway.json");
4163
+ if (existsSync4(keyPath)) {
4164
+ try {
4165
+ const keyData = JSON.parse(readFileSync5(keyPath, "utf-8"));
4166
+ if (keyData.publicKey) {
4167
+ const fingerprint = keyData.publicKey.slice(0, 16) + "...";
4168
+ process.stdout.write(`
4169
+ ${bold("\u{1F6E1}\uFE0F Passport identity:")}
4170
+ `);
4171
+ process.stdout.write(` Public key: ${fingerprint}
4172
+ `);
4173
+ if (keyData.kid) process.stdout.write(` Key ID: ${keyData.kid}
4174
+ `);
4175
+ process.stdout.write(` Issuer: ${keyData.issuer || "protect-mcp"}
4176
+ `);
4177
+ process.stdout.write(` Verify: ${dim("npx @veritasacta/verify <receipt.json>")}
4178
+ `);
4179
+ }
4180
+ } catch {
4181
+ }
4182
+ }
4183
+ process.stdout.write(`
4184
+ Log file: ${dim(logPath)}
4185
+
3394
4186
  `);
3395
4187
  }
3396
4188
  function bold(s) {
3397
4189
  return process.env.NO_COLOR ? s : `\x1B[1m${s}\x1B[0m`;
3398
4190
  }
4191
+ function dim(s) {
4192
+ return process.env.NO_COLOR ? s : `\x1B[2m${s}\x1B[0m`;
4193
+ }
4194
+ function green(s) {
4195
+ return process.env.NO_COLOR ? s : `\x1B[32m${s}\x1B[0m`;
4196
+ }
4197
+ function red(s) {
4198
+ return process.env.NO_COLOR ? s : `\x1B[31m${s}\x1B[0m`;
4199
+ }
4200
+ function yellow(s) {
4201
+ return process.env.NO_COLOR ? s : `\x1B[33m${s}\x1B[0m`;
4202
+ }
4203
+ async function handleDigest(argv) {
4204
+ const { readFileSync: readFileSync5, existsSync: existsSync4 } = await import("fs");
4205
+ const { join: join4 } = await import("path");
4206
+ let dir = process.cwd();
4207
+ const dirIdx = argv.indexOf("--dir");
4208
+ if (dirIdx !== -1 && argv[dirIdx + 1]) dir = argv[dirIdx + 1];
4209
+ const today = argv.includes("--today");
4210
+ const logPath = join4(dir, ".protect-mcp-log.jsonl");
4211
+ if (!existsSync4(logPath)) {
4212
+ process.stderr.write(`${bold("protect-mcp digest")}
4213
+
4214
+ No log file found. Run protect-mcp first.
4215
+ `);
4216
+ process.exit(0);
4217
+ }
4218
+ const raw = readFileSync5(logPath, "utf-8");
4219
+ const lines = raw.trim().split("\n").filter(Boolean);
4220
+ let entries = [];
4221
+ for (const line of lines) {
4222
+ try {
4223
+ entries.push(JSON.parse(line));
4224
+ } catch {
4225
+ }
4226
+ }
4227
+ if (today) {
4228
+ const todayStart = /* @__PURE__ */ new Date();
4229
+ todayStart.setHours(0, 0, 0, 0);
4230
+ entries = entries.filter((e) => e.timestamp >= todayStart.getTime());
4231
+ }
4232
+ if (entries.length === 0) {
4233
+ process.stdout.write(`
4234
+ ${bold("\u{1F6E1}\uFE0F Agent Digest")}
4235
+
4236
+ No activity${today ? " today" : ""}.
4237
+
4238
+ `);
4239
+ process.exit(0);
4240
+ }
4241
+ const allowed = entries.filter((e) => e.decision === "allow").length;
4242
+ const denied = entries.filter((e) => e.decision === "deny").length;
4243
+ const approvalRequired = entries.filter((e) => e.decision === "require_approval").length;
4244
+ const toolUsage = /* @__PURE__ */ new Map();
4245
+ for (const e of entries) {
4246
+ toolUsage.set(e.tool, (toolUsage.get(e.tool) || 0) + 1);
4247
+ }
4248
+ const sortedTools = [...toolUsage.entries()].sort((a, b) => b[1] - a[1]);
4249
+ const currentTier = entries[entries.length - 1]?.tier || "unknown";
4250
+ const firstTime = new Date(Math.min(...entries.map((e) => e.timestamp)));
4251
+ const lastTime = new Date(Math.max(...entries.map((e) => e.timestamp)));
4252
+ const durationMs = lastTime.getTime() - firstTime.getTime();
4253
+ const durationStr = durationMs < 6e4 ? `${Math.round(durationMs / 1e3)}s` : durationMs < 36e5 ? `${Math.round(durationMs / 6e4)}m` : `${(durationMs / 36e5).toFixed(1)}h`;
4254
+ process.stdout.write(`
4255
+ ${bold("\u{1F6E1}\uFE0F Agent Daily Digest")}
4256
+
4257
+ `);
4258
+ process.stdout.write(` \u{1F4CA} ${bold(String(entries.length))} actions | `);
4259
+ process.stdout.write(`${green("\u2713 " + allowed)} allowed | `);
4260
+ process.stdout.write(`${red("\u2717 " + denied)} blocked`);
4261
+ if (approvalRequired > 0) process.stdout.write(` | ${yellow("\u23F3 " + approvalRequired)} awaiting approval`);
4262
+ process.stdout.write(`
4263
+ `);
4264
+ process.stdout.write(` \u{1F3C5} Trust tier: ${bold(currentTier)} | \u23F1 Active: ${durationStr}
4265
+
4266
+ `);
4267
+ process.stdout.write(` ${bold("Tools used:")}
4268
+ `);
4269
+ for (const [tool, count] of sortedTools.slice(0, 8)) {
4270
+ process.stdout.write(` ${tool.padEnd(22)} ${count}x
4271
+ `);
4272
+ }
4273
+ if (denied > 0) {
4274
+ const deniedTools = entries.filter((e) => e.decision === "deny");
4275
+ const deniedToolNames = [...new Set(deniedTools.map((e) => e.tool))];
4276
+ process.stdout.write(`
4277
+ ${bold(red("Blocked tools:"))}
4278
+ `);
4279
+ for (const tool of deniedToolNames) {
4280
+ const reason = deniedTools.find((e) => e.tool === tool)?.reason_code || "policy";
4281
+ process.stdout.write(` ${red("\u2717")} ${tool} (${reason})
4282
+ `);
4283
+ }
4284
+ }
4285
+ process.stdout.write(`
4286
+ ${dim("Latest receipt: curl -s http://127.0.0.1:9876/receipts/latest | jq -r .receipt > receipt.json")}
4287
+ `);
4288
+ process.stdout.write(` ${dim("Verify: npx @veritasacta/verify receipt.json --key <public-key-hex>")}
4289
+ `);
4290
+ process.stdout.write(` ${dim("Export: npx protect-mcp bundle --output audit.json")}
4291
+
4292
+ `);
4293
+ }
4294
+ async function handleReceipts2(argv) {
4295
+ const { readFileSync: readFileSync5, existsSync: existsSync4 } = await import("fs");
4296
+ const { join: join4 } = await import("path");
4297
+ let dir = process.cwd();
4298
+ const dirIdx = argv.indexOf("--dir");
4299
+ if (dirIdx !== -1 && argv[dirIdx + 1]) dir = argv[dirIdx + 1];
4300
+ const lastIdx = argv.indexOf("--last");
4301
+ const count = lastIdx !== -1 && argv[lastIdx + 1] ? parseInt(argv[lastIdx + 1], 10) : 20;
4302
+ const receiptsPath = join4(dir, ".protect-mcp-receipts.jsonl");
4303
+ if (!existsSync4(receiptsPath)) {
4304
+ process.stderr.write(`${bold("protect-mcp receipts")}
4305
+
4306
+ No signed receipt file found. Run protect-mcp with signing enabled first.
4307
+ `);
4308
+ process.exit(0);
4309
+ }
4310
+ const raw = readFileSync5(receiptsPath, "utf-8");
4311
+ const lines = raw.trim().split("\n").filter(Boolean);
4312
+ const recent = lines.slice(-count);
4313
+ process.stdout.write(`
4314
+ ${bold("\u{1F6E1}\uFE0F Recent Receipts")} (last ${recent.length})
4315
+
4316
+ `);
4317
+ for (const line of recent) {
4318
+ try {
4319
+ const entry = JSON.parse(line);
4320
+ const payload = entry.payload || {};
4321
+ const time = typeof entry.issued_at === "string" ? new Date(entry.issued_at).toLocaleTimeString() : "unknown";
4322
+ const decision = payload.decision || "unknown";
4323
+ const icon = decision === "allow" ? green("\u2713") : decision === "require_approval" ? yellow("\u23F3") : red("\u2717");
4324
+ process.stdout.write(` ${dim(time)} ${icon} ${String(payload.tool || "unknown").padEnd(22)} ${String(entry.type || "receipt").padEnd(18)} ${dim(String(payload.reason_code || "signed"))}
4325
+ `);
4326
+ } catch {
4327
+ }
4328
+ }
4329
+ process.stdout.write(`
4330
+ `);
4331
+ }
4332
+ async function handleBundle(argv) {
4333
+ const { readFileSync: readFileSync5, writeFileSync: writeFileSync2, existsSync: existsSync4 } = await import("fs");
4334
+ const { join: join4 } = await import("path");
4335
+ const { createAuditBundle: createAuditBundle2 } = await Promise.resolve().then(() => (init_bundle(), bundle_exports));
4336
+ let dir = process.cwd();
4337
+ const dirIdx = argv.indexOf("--dir");
4338
+ if (dirIdx !== -1 && argv[dirIdx + 1]) dir = argv[dirIdx + 1];
4339
+ const outputIdx = argv.indexOf("--output");
4340
+ const outputPath = outputIdx !== -1 && argv[outputIdx + 1] ? argv[outputIdx + 1] : join4(dir, "audit-bundle.json");
4341
+ const receiptsPath = join4(dir, ".protect-mcp-receipts.jsonl");
4342
+ const keyPath = join4(dir, "keys", "gateway.json");
4343
+ if (!existsSync4(receiptsPath)) {
4344
+ process.stderr.write(`${bold("protect-mcp bundle")}
4345
+
4346
+ No signed receipt file found. Run protect-mcp with signing enabled first.
4347
+ `);
4348
+ process.exit(0);
4349
+ }
4350
+ if (!existsSync4(keyPath)) {
4351
+ process.stderr.write(`${bold("protect-mcp bundle")}
4352
+
4353
+ No key file found at ${keyPath}
4354
+ `);
4355
+ process.exit(1);
4356
+ }
4357
+ const receipts = readFileSync5(receiptsPath, "utf-8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line));
4358
+ const keyData = JSON.parse(readFileSync5(keyPath, "utf-8"));
4359
+ const bundle = createAuditBundle2({
4360
+ tenant: keyData.issuer || "protect-mcp",
4361
+ receipts,
4362
+ signingKeys: [{
4363
+ kty: "OKP",
4364
+ crv: "Ed25519",
4365
+ kid: keyData.kid || "unknown",
4366
+ x: Buffer.from(keyData.publicKey, "hex").toString("base64url"),
4367
+ use: "sig"
4368
+ }]
4369
+ });
4370
+ writeFileSync2(outputPath, JSON.stringify(bundle, null, 2) + "\n");
4371
+ process.stdout.write(`
4372
+ ${bold("protect-mcp bundle")}
4373
+
4374
+ `);
4375
+ process.stdout.write(` Receipts: ${receipts.length}
4376
+ `);
4377
+ process.stdout.write(` Output: ${outputPath}
4378
+ `);
4379
+ process.stdout.write(` Verify: npx @veritasacta/verify ${outputPath} --bundle
4380
+
4381
+ `);
4382
+ }
3399
4383
  async function main() {
3400
4384
  const args = process.argv.slice(2);
3401
4385
  if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
@@ -3406,6 +4390,26 @@ async function main() {
3406
4390
  await handleInit(args.slice(1));
3407
4391
  process.exit(0);
3408
4392
  }
4393
+ if (args[0] === "demo") {
4394
+ await handleDemo();
4395
+ return;
4396
+ }
4397
+ if (args[0] === "status") {
4398
+ await handleStatus2(args.slice(1));
4399
+ process.exit(0);
4400
+ }
4401
+ if (args[0] === "digest") {
4402
+ await handleDigest(args.slice(1));
4403
+ process.exit(0);
4404
+ }
4405
+ if (args[0] === "receipts") {
4406
+ await handleReceipts2(args.slice(1));
4407
+ process.exit(0);
4408
+ }
4409
+ if (args[0] === "bundle") {
4410
+ await handleBundle(args.slice(1));
4411
+ process.exit(0);
4412
+ }
3409
4413
  const { policyPath, slug, enforce, verbose, childCommand } = parseArgs(args);
3410
4414
  let policy = null;
3411
4415
  let policyDigest = "none";