protect-mcp 0.2.2 → 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/chunk-3WCA7O4D.mjs +977 -0
- package/dist/cli.js +760 -163
- package/dist/cli.mjs +241 -5
- package/dist/demo-server.d.mts +1 -0
- package/dist/demo-server.d.ts +1 -0
- package/dist/demo-server.js +137 -0
- package/dist/demo-server.mjs +136 -0
- package/dist/index.d.mts +75 -60
- package/dist/index.d.ts +75 -60
- package/dist/index.js +507 -269
- package/dist/index.mjs +3 -123
- package/package.json +3 -3
- package/dist/chunk-ZCKNFULF.mjs +0 -613
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,
|
|
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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
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 (
|
|
2959
|
-
|
|
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.
|
|
2971
|
-
|
|
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"}
|
|
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
|
-
|
|
3006
|
-
|
|
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
|
-
|
|
3015
|
-
|
|
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
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
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
|
-
|
|
3218
|
-
|
|
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:
|
|
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 =
|
|
3303
|
-
const keysDir =
|
|
3304
|
-
const keyPath =
|
|
3305
|
-
if (
|
|
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 (!
|
|
3694
|
+
if (!existsSync4(keysDir)) {
|
|
3328
3695
|
mkdirSync(keysDir, { recursive: true });
|
|
3329
3696
|
}
|
|
3330
|
-
|
|
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 =
|
|
3338
|
-
if (!
|
|
3339
|
-
|
|
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: "
|
|
3367
|
-
name: "
|
|
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
|
-
|
|
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 +
|
|
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
|
-
|
|
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";
|