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/README.md +11 -1
- package/dist/bundle-TXOTFJIJ.mjs +8 -0
- package/dist/chunk-5JXFV37Y.mjs +53 -0
- package/dist/chunk-U7TMVD3E.mjs +1105 -0
- package/dist/cli.js +1176 -172
- package/dist/cli.mjs +462 -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 +83 -61
- package/dist/index.d.ts +83 -61
- package/dist/index.js +637 -271
- package/dist/index.mjs +7 -172
- package/package.json +3 -3
- package/dist/chunk-ZCKNFULF.mjs +0 -613
package/dist/index.js
CHANGED
|
@@ -61,6 +61,8 @@ module.exports = __toCommonJS(index_exports);
|
|
|
61
61
|
var import_node_child_process = require("child_process");
|
|
62
62
|
var import_node_crypto2 = require("crypto");
|
|
63
63
|
var import_node_readline = require("readline");
|
|
64
|
+
var import_node_fs5 = require("fs");
|
|
65
|
+
var import_node_path3 = require("path");
|
|
64
66
|
|
|
65
67
|
// src/policy.ts
|
|
66
68
|
var import_node_crypto = require("crypto");
|
|
@@ -138,8 +140,126 @@ function checkRateLimit(key, limit, store) {
|
|
|
138
140
|
return { allowed: true, remaining: limit.count - timestamps.length };
|
|
139
141
|
}
|
|
140
142
|
|
|
143
|
+
// src/evidence-store.ts
|
|
144
|
+
var import_node_fs2 = require("fs");
|
|
145
|
+
var import_node_path = require("path");
|
|
146
|
+
var DEFAULT_THRESHOLDS = {
|
|
147
|
+
min_receipts: 10,
|
|
148
|
+
min_epoch_span: 3,
|
|
149
|
+
min_issuers: 2
|
|
150
|
+
};
|
|
151
|
+
var EvidenceStore = class {
|
|
152
|
+
agents = /* @__PURE__ */ new Map();
|
|
153
|
+
filePath;
|
|
154
|
+
dirty = false;
|
|
155
|
+
constructor(dir) {
|
|
156
|
+
this.filePath = (0, import_node_path.join)(dir || process.cwd(), ".protect-mcp-evidence.json");
|
|
157
|
+
this.load();
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Record a receipt observation for an agent.
|
|
161
|
+
*/
|
|
162
|
+
record(agentId, issuer, timestamp) {
|
|
163
|
+
const ts = timestamp || (/* @__PURE__ */ new Date()).toISOString();
|
|
164
|
+
const epochHour = Math.floor(new Date(ts).getTime() / (3600 * 1e3));
|
|
165
|
+
const existing = this.agents.get(agentId);
|
|
166
|
+
const observation = {
|
|
167
|
+
issuer,
|
|
168
|
+
timestamp: ts,
|
|
169
|
+
epoch_hour: epochHour
|
|
170
|
+
};
|
|
171
|
+
if (existing) {
|
|
172
|
+
existing.receipts.push(observation);
|
|
173
|
+
existing.last_seen = ts;
|
|
174
|
+
if (existing.receipts.length > 200) {
|
|
175
|
+
existing.receipts = existing.receipts.slice(-200);
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
this.agents.set(agentId, {
|
|
179
|
+
agent_id: agentId,
|
|
180
|
+
receipts: [observation],
|
|
181
|
+
first_seen: ts,
|
|
182
|
+
last_seen: ts
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
this.dirty = true;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Get the evidence summary for an agent.
|
|
189
|
+
*/
|
|
190
|
+
getSummary(agentId) {
|
|
191
|
+
const record = this.agents.get(agentId);
|
|
192
|
+
if (!record || record.receipts.length === 0) {
|
|
193
|
+
return { receipt_count: 0, epoch_span: 0, issuer_count: 0 };
|
|
194
|
+
}
|
|
195
|
+
const uniqueIssuers = new Set(record.receipts.map((r) => r.issuer));
|
|
196
|
+
const uniqueEpochs = new Set(record.receipts.map((r) => r.epoch_hour));
|
|
197
|
+
return {
|
|
198
|
+
receipt_count: record.receipts.length,
|
|
199
|
+
epoch_span: uniqueEpochs.size,
|
|
200
|
+
issuer_count: uniqueIssuers.size
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Check if an agent meets the evidenced tier thresholds.
|
|
205
|
+
*/
|
|
206
|
+
meetsEvidencedThreshold(agentId, thresholds = DEFAULT_THRESHOLDS) {
|
|
207
|
+
const summary = this.getSummary(agentId);
|
|
208
|
+
return summary.receipt_count >= thresholds.min_receipts && summary.epoch_span >= thresholds.min_epoch_span && summary.issuer_count >= thresholds.min_issuers;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Persist to disk (call periodically or on shutdown).
|
|
212
|
+
*/
|
|
213
|
+
save() {
|
|
214
|
+
if (!this.dirty) return;
|
|
215
|
+
const data = {};
|
|
216
|
+
for (const [id, record] of this.agents) {
|
|
217
|
+
data[id] = record;
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
(0, import_node_fs2.writeFileSync)(this.filePath, JSON.stringify({ v: 1, agents: data }, null, 2) + "\n");
|
|
221
|
+
this.dirty = false;
|
|
222
|
+
} catch {
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Load from disk.
|
|
227
|
+
*/
|
|
228
|
+
load() {
|
|
229
|
+
if (!(0, import_node_fs2.existsSync)(this.filePath)) return;
|
|
230
|
+
try {
|
|
231
|
+
const raw = (0, import_node_fs2.readFileSync)(this.filePath, "utf-8");
|
|
232
|
+
const parsed = JSON.parse(raw);
|
|
233
|
+
if (parsed.agents && typeof parsed.agents === "object") {
|
|
234
|
+
for (const [id, record] of Object.entries(parsed.agents)) {
|
|
235
|
+
this.agents.set(id, record);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
} catch {
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Get total agent count (for status display).
|
|
243
|
+
*/
|
|
244
|
+
agentCount() {
|
|
245
|
+
return this.agents.size;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Get all agent summaries (for status display).
|
|
249
|
+
*/
|
|
250
|
+
allSummaries() {
|
|
251
|
+
const result = [];
|
|
252
|
+
for (const [id] of this.agents) {
|
|
253
|
+
result.push({ agent_id: id, summary: this.getSummary(id) });
|
|
254
|
+
}
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
141
259
|
// src/admission.ts
|
|
142
|
-
function evaluateTier(manifest,
|
|
260
|
+
function evaluateTier(manifest, opts) {
|
|
261
|
+
const options = opts && ("evidenceStore" in opts || "overrides" in opts || "thresholds" in opts) ? opts : { overrides: opts };
|
|
262
|
+
const { overrides, evidenceStore, thresholds } = options;
|
|
143
263
|
if (!manifest) {
|
|
144
264
|
return {
|
|
145
265
|
tier: "unknown",
|
|
@@ -165,7 +285,8 @@ function evaluateTier(manifest, overrides) {
|
|
|
165
285
|
if (manifest.signature_valid === true) {
|
|
166
286
|
if (manifest.evidence_summary) {
|
|
167
287
|
const es = manifest.evidence_summary;
|
|
168
|
-
|
|
288
|
+
const t = thresholds || DEFAULT_THRESHOLDS;
|
|
289
|
+
if (es.receipt_count >= t.min_receipts && es.epoch_span >= t.min_epoch_span && es.issuer_count >= t.min_issuers) {
|
|
169
290
|
return {
|
|
170
291
|
tier: "evidenced",
|
|
171
292
|
agent_id: manifest.agent_id,
|
|
@@ -174,6 +295,16 @@ function evaluateTier(manifest, overrides) {
|
|
|
174
295
|
};
|
|
175
296
|
}
|
|
176
297
|
}
|
|
298
|
+
if (evidenceStore && manifest.agent_id) {
|
|
299
|
+
if (evidenceStore.meetsEvidencedThreshold(manifest.agent_id, thresholds)) {
|
|
300
|
+
return {
|
|
301
|
+
tier: "evidenced",
|
|
302
|
+
agent_id: manifest.agent_id,
|
|
303
|
+
manifest_hash: manifest.manifest_hash,
|
|
304
|
+
reason: "evidence_store_threshold_met"
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
}
|
|
177
308
|
return {
|
|
178
309
|
tier: "signed-known",
|
|
179
310
|
agent_id: manifest.agent_id,
|
|
@@ -243,7 +374,7 @@ function validateCredentials(credentials) {
|
|
|
243
374
|
}
|
|
244
375
|
|
|
245
376
|
// src/signing.ts
|
|
246
|
-
var
|
|
377
|
+
var import_node_fs3 = require("fs");
|
|
247
378
|
var signerState = null;
|
|
248
379
|
var artifactsModule = null;
|
|
249
380
|
async function initSigning(config) {
|
|
@@ -258,12 +389,12 @@ async function initSigning(config) {
|
|
|
258
389
|
return warnings;
|
|
259
390
|
}
|
|
260
391
|
if (config.key_path) {
|
|
261
|
-
if (!(0,
|
|
392
|
+
if (!(0, import_node_fs3.existsSync)(config.key_path)) {
|
|
262
393
|
warnings.push(`signing: key file not found at ${config.key_path} \u2014 run "protect-mcp init" to generate`);
|
|
263
394
|
return warnings;
|
|
264
395
|
}
|
|
265
396
|
try {
|
|
266
|
-
const keyData = JSON.parse((0,
|
|
397
|
+
const keyData = JSON.parse((0, import_node_fs3.readFileSync)(config.key_path, "utf-8"));
|
|
267
398
|
if (!keyData.privateKey || !keyData.publicKey) {
|
|
268
399
|
warnings.push("signing: key file missing privateKey or publicKey fields");
|
|
269
400
|
return warnings;
|
|
@@ -271,8 +402,8 @@ async function initSigning(config) {
|
|
|
271
402
|
signerState = {
|
|
272
403
|
privateKey: keyData.privateKey,
|
|
273
404
|
publicKey: keyData.publicKey,
|
|
274
|
-
kid: artifactsModule.computeKid(keyData.publicKey),
|
|
275
|
-
issuer: config.issuer || "protect-mcp"
|
|
405
|
+
kid: keyData.kid || artifactsModule.computeKid(keyData.publicKey),
|
|
406
|
+
issuer: config.issuer || keyData.issuer || "protect-mcp"
|
|
276
407
|
};
|
|
277
408
|
} catch (err) {
|
|
278
409
|
warnings.push(`signing: failed to load key file: ${err instanceof Error ? err.message : err}`);
|
|
@@ -335,21 +466,378 @@ function isSigningEnabled() {
|
|
|
335
466
|
return signerState !== null && artifactsModule !== null;
|
|
336
467
|
}
|
|
337
468
|
|
|
469
|
+
// src/external-pdp.ts
|
|
470
|
+
async function queryExternalPDP(context, config) {
|
|
471
|
+
const timeout = config.timeout_ms || 500;
|
|
472
|
+
const controller = new AbortController();
|
|
473
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
474
|
+
try {
|
|
475
|
+
const body = formatRequest(context, config.format || "generic");
|
|
476
|
+
const response = await fetch(config.endpoint, {
|
|
477
|
+
method: "POST",
|
|
478
|
+
headers: { "Content-Type": "application/json" },
|
|
479
|
+
body: JSON.stringify(body),
|
|
480
|
+
signal: controller.signal
|
|
481
|
+
});
|
|
482
|
+
clearTimeout(timer);
|
|
483
|
+
if (!response.ok) {
|
|
484
|
+
return fallbackDecision(config, `PDP returned HTTP ${response.status}`);
|
|
485
|
+
}
|
|
486
|
+
const result = await response.json();
|
|
487
|
+
return parseResponse(result, config.format || "generic");
|
|
488
|
+
} catch (err) {
|
|
489
|
+
clearTimeout(timer);
|
|
490
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
491
|
+
return fallbackDecision(config, `PDP timeout after ${timeout}ms`);
|
|
492
|
+
}
|
|
493
|
+
return fallbackDecision(config, `PDP error: ${err instanceof Error ? err.message : "unknown"}`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
function formatRequest(context, format) {
|
|
497
|
+
switch (format) {
|
|
498
|
+
case "opa":
|
|
499
|
+
return {
|
|
500
|
+
input: {
|
|
501
|
+
actor: context.actor,
|
|
502
|
+
action: context.action,
|
|
503
|
+
target: context.target,
|
|
504
|
+
credential_ref: context.credential_ref,
|
|
505
|
+
mode: context.mode,
|
|
506
|
+
metadata: context.request_metadata
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
case "cerbos":
|
|
510
|
+
return {
|
|
511
|
+
principal: {
|
|
512
|
+
id: context.actor.id || "unknown",
|
|
513
|
+
roles: [context.actor.tier],
|
|
514
|
+
attr: {
|
|
515
|
+
manifest_hash: context.actor.manifest_hash
|
|
516
|
+
}
|
|
517
|
+
},
|
|
518
|
+
resource: {
|
|
519
|
+
kind: "tool",
|
|
520
|
+
id: context.action.tool,
|
|
521
|
+
attr: context.target
|
|
522
|
+
},
|
|
523
|
+
actions: [context.action.operation || "call"]
|
|
524
|
+
};
|
|
525
|
+
case "generic":
|
|
526
|
+
default:
|
|
527
|
+
return context;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
function parseResponse(result, format) {
|
|
531
|
+
switch (format) {
|
|
532
|
+
case "opa":
|
|
533
|
+
if (typeof result.result === "boolean") {
|
|
534
|
+
return { allowed: result.result };
|
|
535
|
+
}
|
|
536
|
+
if (result.result && typeof result.result === "object") {
|
|
537
|
+
const r = result.result;
|
|
538
|
+
return {
|
|
539
|
+
allowed: Boolean(r.allow),
|
|
540
|
+
reason: r.reason,
|
|
541
|
+
metadata: r
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
return { allowed: false, reason: "unrecognized OPA response" };
|
|
545
|
+
case "cerbos":
|
|
546
|
+
if (Array.isArray(result.results) && result.results.length > 0) {
|
|
547
|
+
const actions = result.results[0].actions;
|
|
548
|
+
if (actions) {
|
|
549
|
+
const effect = Object.values(actions)[0];
|
|
550
|
+
return { allowed: effect === "EFFECT_ALLOW" };
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return { allowed: false, reason: "unrecognized Cerbos response" };
|
|
554
|
+
case "generic":
|
|
555
|
+
default:
|
|
556
|
+
return {
|
|
557
|
+
allowed: Boolean(result.allowed),
|
|
558
|
+
reason: result.reason,
|
|
559
|
+
metadata: result.metadata
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
function fallbackDecision(config, reason) {
|
|
564
|
+
const fallback = config.fallback || "deny";
|
|
565
|
+
return {
|
|
566
|
+
allowed: fallback === "allow",
|
|
567
|
+
reason: `fallback_${fallback}: ${reason}`
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
function buildDecisionContext(toolName, tier, opts) {
|
|
571
|
+
return {
|
|
572
|
+
v: 1,
|
|
573
|
+
actor: {
|
|
574
|
+
id: opts.agentId,
|
|
575
|
+
tier,
|
|
576
|
+
manifest_hash: opts.manifestHash
|
|
577
|
+
},
|
|
578
|
+
action: {
|
|
579
|
+
tool: toolName,
|
|
580
|
+
operation: "call"
|
|
581
|
+
},
|
|
582
|
+
target: {
|
|
583
|
+
service: opts.slug || "default"
|
|
584
|
+
},
|
|
585
|
+
credential_ref: opts.credentialRef,
|
|
586
|
+
mode: opts.mode,
|
|
587
|
+
request_metadata: opts.requestMetadata || {}
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// src/http-server.ts
|
|
592
|
+
var import_node_http = require("http");
|
|
593
|
+
var import_node_fs4 = require("fs");
|
|
594
|
+
var import_node_path2 = require("path");
|
|
595
|
+
var LOG_FILE = ".protect-mcp-log.jsonl";
|
|
596
|
+
var MAX_RECEIPTS = 100;
|
|
597
|
+
var ReceiptBuffer = class {
|
|
598
|
+
receipts = [];
|
|
599
|
+
add(requestId, receipt) {
|
|
600
|
+
this.receipts.push({
|
|
601
|
+
request_id: requestId,
|
|
602
|
+
receipt,
|
|
603
|
+
timestamp: Date.now()
|
|
604
|
+
});
|
|
605
|
+
if (this.receipts.length > MAX_RECEIPTS) {
|
|
606
|
+
this.receipts = this.receipts.slice(-MAX_RECEIPTS);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
getAll() {
|
|
610
|
+
return [...this.receipts].reverse();
|
|
611
|
+
}
|
|
612
|
+
getById(requestId) {
|
|
613
|
+
return this.receipts.find((r) => r.request_id === requestId);
|
|
614
|
+
}
|
|
615
|
+
count() {
|
|
616
|
+
return this.receipts.length;
|
|
617
|
+
}
|
|
618
|
+
getLatest() {
|
|
619
|
+
return this.receipts.length > 0 ? this.receipts[this.receipts.length - 1] : void 0;
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
function startStatusServer(config, receiptBuffer, approvalStore, approvalNonce) {
|
|
623
|
+
const startTime = Date.now();
|
|
624
|
+
const logDir = process.cwd();
|
|
625
|
+
const server = (0, import_node_http.createServer)((req, res) => {
|
|
626
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
627
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
628
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
629
|
+
res.setHeader("Content-Type", "application/json");
|
|
630
|
+
if (req.method === "OPTIONS") {
|
|
631
|
+
res.writeHead(204);
|
|
632
|
+
res.end();
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
const url = new URL(req.url || "/", `http://localhost:${config.port}`);
|
|
636
|
+
const path = url.pathname;
|
|
637
|
+
try {
|
|
638
|
+
if (path === "/health") {
|
|
639
|
+
handleHealth(res, startTime, config);
|
|
640
|
+
} else if (path === "/status") {
|
|
641
|
+
handleStatus(res, logDir);
|
|
642
|
+
} else if (path === "/receipts") {
|
|
643
|
+
handleReceipts(res, receiptBuffer, url);
|
|
644
|
+
} else if (path === "/receipts/latest") {
|
|
645
|
+
handleReceiptLatest(res, receiptBuffer);
|
|
646
|
+
} else if (path.startsWith("/receipts/")) {
|
|
647
|
+
const id = path.slice("/receipts/".length);
|
|
648
|
+
handleReceiptById(res, receiptBuffer, id);
|
|
649
|
+
} else if (path === "/approve" && req.method === "POST") {
|
|
650
|
+
handleApprove(req, res, approvalStore, approvalNonce);
|
|
651
|
+
} else if (path === "/approvals" && req.method === "GET") {
|
|
652
|
+
handleListApprovals(res, approvalStore);
|
|
653
|
+
} else {
|
|
654
|
+
res.writeHead(404);
|
|
655
|
+
res.end(JSON.stringify({ error: "not_found", endpoints: ["/health", "/status", "/receipts", "/receipts/latest", "/receipts/:id", "/approve", "/approvals"] }));
|
|
656
|
+
}
|
|
657
|
+
} catch (err) {
|
|
658
|
+
res.writeHead(500);
|
|
659
|
+
res.end(JSON.stringify({ error: "internal_error" }));
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
server.on("error", (err) => {
|
|
663
|
+
if (config.verbose) {
|
|
664
|
+
process.stderr.write(`[PROTECT_MCP] HTTP status server error: ${err.message}
|
|
665
|
+
`);
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
server.listen(config.port, "127.0.0.1", () => {
|
|
669
|
+
if (config.verbose) {
|
|
670
|
+
process.stderr.write(`[PROTECT_MCP] HTTP status server listening on http://127.0.0.1:${config.port}
|
|
671
|
+
`);
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
server.unref();
|
|
675
|
+
return server;
|
|
676
|
+
}
|
|
677
|
+
function handleHealth(res, startTime, config) {
|
|
678
|
+
res.writeHead(200);
|
|
679
|
+
res.end(JSON.stringify({
|
|
680
|
+
status: "ok",
|
|
681
|
+
uptime_ms: Date.now() - startTime,
|
|
682
|
+
mode: config.mode,
|
|
683
|
+
version: "0.3.1"
|
|
684
|
+
}));
|
|
685
|
+
}
|
|
686
|
+
function handleStatus(res, logDir) {
|
|
687
|
+
const logPath = (0, import_node_path2.join)(logDir, LOG_FILE);
|
|
688
|
+
if (!(0, import_node_fs4.existsSync)(logPath)) {
|
|
689
|
+
res.writeHead(200);
|
|
690
|
+
res.end(JSON.stringify({ entries: 0, message: "no log file yet" }));
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
const raw = (0, import_node_fs4.readFileSync)(logPath, "utf-8");
|
|
694
|
+
const lines = raw.trim().split("\n").filter(Boolean);
|
|
695
|
+
const entries = [];
|
|
696
|
+
for (const line of lines) {
|
|
697
|
+
try {
|
|
698
|
+
entries.push(JSON.parse(line));
|
|
699
|
+
} catch {
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
const toolCounts = {};
|
|
703
|
+
let allowCount = 0, denyCount = 0;
|
|
704
|
+
const tierCounts = {};
|
|
705
|
+
for (const e of entries) {
|
|
706
|
+
toolCounts[e.tool] = (toolCounts[e.tool] || 0) + 1;
|
|
707
|
+
if (e.decision === "allow") allowCount++;
|
|
708
|
+
else denyCount++;
|
|
709
|
+
if (e.tier) tierCounts[e.tier] = (tierCounts[e.tier] || 0) + 1;
|
|
710
|
+
}
|
|
711
|
+
res.writeHead(200);
|
|
712
|
+
res.end(JSON.stringify({
|
|
713
|
+
entries: entries.length,
|
|
714
|
+
allow: allowCount,
|
|
715
|
+
deny: denyCount,
|
|
716
|
+
tools: toolCounts,
|
|
717
|
+
tiers: tierCounts,
|
|
718
|
+
first_timestamp: entries.length > 0 ? entries[0].timestamp : null,
|
|
719
|
+
last_timestamp: entries.length > 0 ? entries[entries.length - 1].timestamp : null
|
|
720
|
+
}));
|
|
721
|
+
}
|
|
722
|
+
function handleReceipts(res, buffer, url) {
|
|
723
|
+
const limit = parseInt(url.searchParams.get("limit") || "20", 10);
|
|
724
|
+
const receipts = buffer.getAll().slice(0, Math.min(limit, MAX_RECEIPTS));
|
|
725
|
+
res.writeHead(200);
|
|
726
|
+
res.end(JSON.stringify({
|
|
727
|
+
count: receipts.length,
|
|
728
|
+
total: buffer.count(),
|
|
729
|
+
receipts
|
|
730
|
+
}));
|
|
731
|
+
}
|
|
732
|
+
function handleReceiptLatest(res, buffer) {
|
|
733
|
+
const latest = buffer.getLatest();
|
|
734
|
+
if (!latest) {
|
|
735
|
+
res.writeHead(404);
|
|
736
|
+
res.end(JSON.stringify({ error: "no_receipts", message: "No receipts yet. Make a tool call through protect-mcp first." }));
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
res.writeHead(200);
|
|
740
|
+
res.end(JSON.stringify(latest));
|
|
741
|
+
}
|
|
742
|
+
function handleReceiptById(res, buffer, id) {
|
|
743
|
+
const receipt = buffer.getById(id);
|
|
744
|
+
if (!receipt) {
|
|
745
|
+
res.writeHead(404);
|
|
746
|
+
res.end(JSON.stringify({ error: "receipt_not_found", request_id: id }));
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
res.writeHead(200);
|
|
750
|
+
res.end(JSON.stringify(receipt));
|
|
751
|
+
}
|
|
752
|
+
function handleApprove(req, res, approvalStore, expectedNonce) {
|
|
753
|
+
if (!approvalStore) {
|
|
754
|
+
res.writeHead(503);
|
|
755
|
+
res.end(JSON.stringify({ error: "approval_store_not_available" }));
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
let body = "";
|
|
759
|
+
req.on("data", (chunk) => {
|
|
760
|
+
body += chunk.toString();
|
|
761
|
+
});
|
|
762
|
+
req.on("end", () => {
|
|
763
|
+
try {
|
|
764
|
+
const { request_id, tool, mode, nonce } = JSON.parse(body);
|
|
765
|
+
if (expectedNonce && nonce !== expectedNonce) {
|
|
766
|
+
res.writeHead(403);
|
|
767
|
+
res.end(JSON.stringify({ error: "invalid_nonce", message: "Approval nonce does not match. Check stderr output for the correct nonce." }));
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
if (!tool || typeof tool !== "string") {
|
|
771
|
+
res.writeHead(400);
|
|
772
|
+
res.end(JSON.stringify({ error: "missing_tool", usage: '{"request_id":"abc123","tool":"send_email","mode":"once|always","nonce":"..."}' }));
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
const grantMode = mode === "always" ? "always" : "once";
|
|
776
|
+
const ttlMs = grantMode === "once" ? 5 * 60 * 1e3 : 24 * 60 * 60 * 1e3;
|
|
777
|
+
const grantEntry = { tool, mode: grantMode, expires_at: Date.now() + ttlMs };
|
|
778
|
+
if (grantMode === "always") {
|
|
779
|
+
approvalStore.set(`always:${tool}`, grantEntry);
|
|
780
|
+
} else if (request_id) {
|
|
781
|
+
approvalStore.set(request_id, grantEntry);
|
|
782
|
+
} else {
|
|
783
|
+
approvalStore.set(tool, grantEntry);
|
|
784
|
+
}
|
|
785
|
+
res.writeHead(200);
|
|
786
|
+
res.end(JSON.stringify({
|
|
787
|
+
approved: true,
|
|
788
|
+
request_id: request_id || null,
|
|
789
|
+
tool,
|
|
790
|
+
mode: grantMode,
|
|
791
|
+
expires_in_seconds: ttlMs / 1e3
|
|
792
|
+
}));
|
|
793
|
+
} catch {
|
|
794
|
+
res.writeHead(400);
|
|
795
|
+
res.end(JSON.stringify({ error: "invalid_json", usage: '{"request_id":"abc123","tool":"send_email","mode":"once","nonce":"..."}' }));
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
function handleListApprovals(res, approvalStore) {
|
|
800
|
+
if (!approvalStore) {
|
|
801
|
+
res.writeHead(200);
|
|
802
|
+
res.end(JSON.stringify({ grants: [] }));
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
const now = Date.now();
|
|
806
|
+
const grants = [];
|
|
807
|
+
for (const [key, grant] of approvalStore) {
|
|
808
|
+
if (now < grant.expires_at) {
|
|
809
|
+
grants.push({ key, tool: grant.tool, mode: grant.mode, expires_in_seconds: Math.round((grant.expires_at - now) / 1e3) });
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
res.writeHead(200);
|
|
813
|
+
res.end(JSON.stringify({ grants }));
|
|
814
|
+
}
|
|
815
|
+
|
|
338
816
|
// src/gateway.ts
|
|
817
|
+
var LOG_FILE2 = ".protect-mcp-log.jsonl";
|
|
818
|
+
var RECEIPTS_FILE = ".protect-mcp-receipts.jsonl";
|
|
339
819
|
var ProtectGateway = class {
|
|
340
820
|
child = null;
|
|
341
821
|
config;
|
|
342
822
|
rateLimitStore = /* @__PURE__ */ new Map();
|
|
343
823
|
clientReader = null;
|
|
344
|
-
|
|
824
|
+
logFilePath;
|
|
825
|
+
receiptFilePath;
|
|
826
|
+
evidenceStore;
|
|
827
|
+
receiptBuffer;
|
|
828
|
+
/** Approval grants keyed by request_id (scoped to the specific action that was requested) */
|
|
829
|
+
approvalStore = /* @__PURE__ */ new Map();
|
|
830
|
+
/** Random nonce generated at startup — required for approval endpoint authentication */
|
|
831
|
+
approvalNonce = (0, import_node_crypto2.randomBytes)(16).toString("hex");
|
|
345
832
|
currentTier = "unknown";
|
|
346
833
|
admissionResult = null;
|
|
347
834
|
constructor(config) {
|
|
348
835
|
this.config = config;
|
|
836
|
+
this.logFilePath = (0, import_node_path3.join)(process.cwd(), LOG_FILE2);
|
|
837
|
+
this.receiptFilePath = (0, import_node_path3.join)(process.cwd(), RECEIPTS_FILE);
|
|
838
|
+
this.evidenceStore = new EvidenceStore();
|
|
839
|
+
this.receiptBuffer = new ReceiptBuffer();
|
|
349
840
|
}
|
|
350
|
-
/**
|
|
351
|
-
* Start the gateway: spawn child process and wire up message relay.
|
|
352
|
-
*/
|
|
353
841
|
async start() {
|
|
354
842
|
const { command, args, verbose } = this.config;
|
|
355
843
|
const mode = this.config.enforce ? "enforce" : "shadow";
|
|
@@ -366,11 +854,37 @@ var ProtectGateway = class {
|
|
|
366
854
|
const labels = Object.keys(this.config.credentials);
|
|
367
855
|
this.log(`Credential vault: ${labels.length} credential(s) configured [${labels.join(", ")}]`);
|
|
368
856
|
}
|
|
857
|
+
if (this.config.policy?.policy_engine === "external" || this.config.policy?.policy_engine === "hybrid") {
|
|
858
|
+
this.log(`External PDP: ${this.config.policy.external?.endpoint || "not configured"}`);
|
|
859
|
+
}
|
|
369
860
|
}
|
|
370
|
-
this.
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
861
|
+
this.log(`Approval nonce: ${this.approvalNonce}`);
|
|
862
|
+
const httpPort = parseInt(process.env.PROTECT_MCP_HTTP_PORT || "9876", 10);
|
|
863
|
+
if (httpPort > 0) {
|
|
864
|
+
try {
|
|
865
|
+
startStatusServer(
|
|
866
|
+
{ port: httpPort, mode, verbose },
|
|
867
|
+
this.receiptBuffer,
|
|
868
|
+
this.approvalStore,
|
|
869
|
+
this.approvalNonce
|
|
870
|
+
);
|
|
871
|
+
} catch {
|
|
872
|
+
if (verbose) this.log(`HTTP status server could not start on port ${httpPort}`);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
const childEnv = { ...process.env };
|
|
876
|
+
if (this.config.credentials) {
|
|
877
|
+
for (const [label, credConfig] of Object.entries(this.config.credentials)) {
|
|
878
|
+
if (credConfig.inject === "env" && credConfig.name && credConfig.value_env) {
|
|
879
|
+
const envValue = process.env[credConfig.value_env];
|
|
880
|
+
if (envValue) {
|
|
881
|
+
childEnv[credConfig.name] = envValue;
|
|
882
|
+
if (verbose) this.log(`Credential "${label}": injected as env var "${credConfig.name}"`);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
this.child = (0, import_node_child_process.spawn)(command, args, { stdio: ["pipe", "pipe", "pipe"], env: childEnv });
|
|
374
888
|
if (!this.child.stdin || !this.child.stdout || !this.child.stderr) {
|
|
375
889
|
throw new Error("Failed to create pipes to child process");
|
|
376
890
|
}
|
|
@@ -386,9 +900,8 @@ var ProtectGateway = class {
|
|
|
386
900
|
this.handleClientMessage(line);
|
|
387
901
|
});
|
|
388
902
|
this.child.on("exit", (code, signal) => {
|
|
389
|
-
if (
|
|
390
|
-
|
|
391
|
-
}
|
|
903
|
+
if (verbose) this.log(`Child process exited (code=${code}, signal=${signal})`);
|
|
904
|
+
this.evidenceStore.save();
|
|
392
905
|
process.exit(code ?? 1);
|
|
393
906
|
});
|
|
394
907
|
this.child.on("error", (err) => {
|
|
@@ -398,30 +911,18 @@ var ProtectGateway = class {
|
|
|
398
911
|
process.on("SIGINT", () => this.stop());
|
|
399
912
|
process.on("SIGTERM", () => this.stop());
|
|
400
913
|
process.stdin.on("end", () => {
|
|
401
|
-
if (this.
|
|
402
|
-
|
|
403
|
-
}
|
|
404
|
-
if (this.child?.stdin?.writable) {
|
|
405
|
-
this.child.stdin.end();
|
|
406
|
-
}
|
|
914
|
+
if (verbose) this.log("Client stdin closed, closing child stdin");
|
|
915
|
+
if (this.child?.stdin?.writable) this.child.stdin.end();
|
|
407
916
|
});
|
|
408
917
|
}
|
|
409
|
-
/**
|
|
410
|
-
* Set the trust tier for this session.
|
|
411
|
-
* Called at admission (first interaction) or by explicit manifest presentation.
|
|
412
|
-
*/
|
|
413
918
|
setManifest(manifest) {
|
|
414
|
-
this.admissionResult = evaluateTier(manifest);
|
|
919
|
+
this.admissionResult = evaluateTier(manifest, { evidenceStore: this.evidenceStore });
|
|
415
920
|
this.currentTier = this.admissionResult.tier;
|
|
416
921
|
if (this.config.verbose) {
|
|
417
|
-
this.log(`Admission: tier=${this.currentTier} agent=${this.admissionResult.agent_id || "none"}
|
|
922
|
+
this.log(`Admission: tier=${this.currentTier} agent=${this.admissionResult.agent_id || "none"}`);
|
|
418
923
|
}
|
|
419
924
|
return this.admissionResult;
|
|
420
925
|
}
|
|
421
|
-
/**
|
|
422
|
-
* Handle a message from the MCP client (stdin).
|
|
423
|
-
* Intercept tools/call requests; pass through everything else.
|
|
424
|
-
*/
|
|
425
926
|
handleClientMessage(raw) {
|
|
426
927
|
const trimmed = raw.trim();
|
|
427
928
|
if (!trimmed) return;
|
|
@@ -433,25 +934,38 @@ var ProtectGateway = class {
|
|
|
433
934
|
return;
|
|
434
935
|
}
|
|
435
936
|
if (message.method === "tools/call" && message.id !== void 0) {
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
this.sendToClient(JSON.stringify(result));
|
|
439
|
-
return;
|
|
440
|
-
}
|
|
937
|
+
this.interceptToolCallAsync(message, trimmed);
|
|
938
|
+
return;
|
|
441
939
|
}
|
|
442
940
|
this.sendToChild(trimmed);
|
|
443
941
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
942
|
+
async interceptToolCallAsync(request, raw) {
|
|
943
|
+
const result = await this.interceptToolCall(request);
|
|
944
|
+
if (result) {
|
|
945
|
+
this.sendToClient(JSON.stringify(result));
|
|
946
|
+
} else {
|
|
947
|
+
const modified = this.injectParamsCredentials(request);
|
|
948
|
+
this.sendToChild(JSON.stringify(modified));
|
|
949
|
+
}
|
|
950
|
+
}
|
|
448
951
|
handleServerMessage(raw) {
|
|
449
952
|
this.sendToClient(raw);
|
|
450
953
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
954
|
+
injectParamsCredentials(request) {
|
|
955
|
+
if (!this.config.credentials) return request;
|
|
956
|
+
const injections = {};
|
|
957
|
+
for (const [label, credConfig] of Object.entries(this.config.credentials)) {
|
|
958
|
+
if (credConfig.inject === "header" || credConfig.inject === "query") {
|
|
959
|
+
const cred = resolveCredential(label, this.config.credentials);
|
|
960
|
+
if (cred.resolved && cred.value && cred.name) {
|
|
961
|
+
injections[cred.name] = cred.value;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
if (Object.keys(injections).length === 0) return request;
|
|
966
|
+
return { ...request, params: { ...request.params, _credentials: injections } };
|
|
967
|
+
}
|
|
968
|
+
async interceptToolCall(request) {
|
|
455
969
|
const toolName = request.params?.name || "unknown";
|
|
456
970
|
const requestId = (0, import_node_crypto2.randomUUID)().slice(0, 12);
|
|
457
971
|
const toolPolicy = getToolPolicy(toolName, this.config.policy);
|
|
@@ -462,54 +976,76 @@ var ProtectGateway = class {
|
|
|
462
976
|
if (cred.resolved) {
|
|
463
977
|
credentialRef = cred.label;
|
|
464
978
|
} else if (cred.error && !cred.error.includes("not configured")) {
|
|
465
|
-
this.emitDecisionLog({
|
|
466
|
-
tool: toolName,
|
|
467
|
-
decision: "deny",
|
|
468
|
-
reason_code: "policy_block",
|
|
469
|
-
request_id: requestId,
|
|
470
|
-
credential_ref: toolName,
|
|
471
|
-
tier: this.currentTier
|
|
472
|
-
});
|
|
979
|
+
this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: "credential_error", request_id: requestId, tier: this.currentTier, credential_ref: toolName });
|
|
473
980
|
if (this.config.enforce) {
|
|
474
|
-
this.log(`Credential error for "${toolName}": ${cred.error}`);
|
|
475
981
|
return this.makeErrorResponse(request.id, -32600, `Credential error for tool "${toolName}"`);
|
|
476
982
|
}
|
|
477
983
|
}
|
|
478
984
|
}
|
|
985
|
+
if (this.config.policy?.external && (this.config.policy.policy_engine === "external" || this.config.policy.policy_engine === "hybrid")) {
|
|
986
|
+
try {
|
|
987
|
+
const ctx = buildDecisionContext(toolName, this.currentTier, {
|
|
988
|
+
agentId: this.admissionResult?.agent_id,
|
|
989
|
+
manifestHash: this.admissionResult?.manifest_hash,
|
|
990
|
+
credentialRef,
|
|
991
|
+
mode,
|
|
992
|
+
slug: this.config.slug
|
|
993
|
+
});
|
|
994
|
+
const externalDecision = await queryExternalPDP(ctx, this.config.policy.external);
|
|
995
|
+
if (!externalDecision.allowed) {
|
|
996
|
+
const reason = `external_pdp_deny${externalDecision.reason ? ": " + externalDecision.reason : ""}`;
|
|
997
|
+
this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: reason, request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
|
|
998
|
+
if (this.config.enforce) {
|
|
999
|
+
return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" denied by external policy engine`);
|
|
1000
|
+
}
|
|
1001
|
+
if (this.config.policy.policy_engine === "external") return null;
|
|
1002
|
+
}
|
|
1003
|
+
} catch (err) {
|
|
1004
|
+
if (this.config.verbose) this.log(`External PDP error: ${err instanceof Error ? err.message : err}`);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
479
1007
|
if (toolPolicy.min_tier) {
|
|
480
1008
|
if (!meetsMinTier(this.currentTier, toolPolicy.min_tier)) {
|
|
481
|
-
this.emitDecisionLog({
|
|
482
|
-
tool: toolName,
|
|
483
|
-
decision: "deny",
|
|
484
|
-
reason_code: "tier_insufficient",
|
|
485
|
-
request_id: requestId,
|
|
486
|
-
tier: this.currentTier,
|
|
487
|
-
credential_ref: credentialRef
|
|
488
|
-
});
|
|
1009
|
+
this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: "tier_insufficient", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
|
|
489
1010
|
if (this.config.enforce) {
|
|
490
|
-
return this.makeErrorResponse(
|
|
491
|
-
request.id,
|
|
492
|
-
-32600,
|
|
493
|
-
`Tool "${toolName}" requires tier "${toolPolicy.min_tier}", agent has "${this.currentTier}"`
|
|
494
|
-
);
|
|
1011
|
+
return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" requires tier "${toolPolicy.min_tier}"`);
|
|
495
1012
|
}
|
|
496
1013
|
return null;
|
|
497
1014
|
}
|
|
498
1015
|
}
|
|
499
1016
|
if (toolPolicy.block) {
|
|
500
|
-
this.emitDecisionLog({
|
|
501
|
-
tool: toolName,
|
|
502
|
-
decision: "deny",
|
|
503
|
-
reason_code: "policy_block",
|
|
504
|
-
request_id: requestId,
|
|
505
|
-
tier: this.currentTier,
|
|
506
|
-
credential_ref: credentialRef
|
|
507
|
-
});
|
|
1017
|
+
this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: "policy_block", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
|
|
508
1018
|
if (this.config.enforce) {
|
|
509
1019
|
return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" is blocked by policy`);
|
|
510
1020
|
}
|
|
511
1021
|
return null;
|
|
512
1022
|
}
|
|
1023
|
+
if (toolPolicy.require_approval) {
|
|
1024
|
+
const grant = this.approvalStore.get(requestId);
|
|
1025
|
+
const alwaysGrant = this.approvalStore.get(`always:${toolName}`);
|
|
1026
|
+
if (grant && Date.now() < grant.expires_at || alwaysGrant && Date.now() < alwaysGrant.expires_at) {
|
|
1027
|
+
if (grant && grant.mode === "once") this.approvalStore.delete(requestId);
|
|
1028
|
+
this.emitDecisionLog({ tool: toolName, decision: "allow", reason_code: "approval_granted", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
|
|
1029
|
+
return null;
|
|
1030
|
+
}
|
|
1031
|
+
this.emitDecisionLog({ tool: toolName, decision: "require_approval", reason_code: "requires_human_approval", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
|
|
1032
|
+
if (this.config.enforce) {
|
|
1033
|
+
return {
|
|
1034
|
+
jsonrpc: "2.0",
|
|
1035
|
+
id: request.id,
|
|
1036
|
+
result: {
|
|
1037
|
+
content: [
|
|
1038
|
+
{
|
|
1039
|
+
type: "text",
|
|
1040
|
+
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.`
|
|
1041
|
+
}
|
|
1042
|
+
],
|
|
1043
|
+
isError: true
|
|
1044
|
+
}
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
return null;
|
|
1048
|
+
}
|
|
513
1049
|
const rateSpec = this.getTierRateLimit(toolPolicy, this.currentTier);
|
|
514
1050
|
if (rateSpec) {
|
|
515
1051
|
try {
|
|
@@ -517,59 +1053,22 @@ var ProtectGateway = class {
|
|
|
517
1053
|
const key = `tool:${toolName}:${this.currentTier}`;
|
|
518
1054
|
const { allowed, remaining } = checkRateLimit(key, limit, this.rateLimitStore);
|
|
519
1055
|
if (!allowed) {
|
|
520
|
-
this.emitDecisionLog({
|
|
521
|
-
tool: toolName,
|
|
522
|
-
decision: "deny",
|
|
523
|
-
reason_code: "rate_limit_exceeded",
|
|
524
|
-
request_id: requestId,
|
|
525
|
-
rate_limit_remaining: 0,
|
|
526
|
-
tier: this.currentTier,
|
|
527
|
-
credential_ref: credentialRef
|
|
528
|
-
});
|
|
1056
|
+
this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: "rate_limit_exceeded", request_id: requestId, rate_limit_remaining: 0, tier: this.currentTier, credential_ref: credentialRef });
|
|
529
1057
|
if (this.config.enforce) {
|
|
530
|
-
return this.makeErrorResponse(
|
|
531
|
-
request.id,
|
|
532
|
-
-32600,
|
|
533
|
-
`Tool "${toolName}" rate limit exceeded (${rateSpec})`
|
|
534
|
-
);
|
|
1058
|
+
return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" rate limit exceeded (${rateSpec})`);
|
|
535
1059
|
}
|
|
536
1060
|
return null;
|
|
537
1061
|
}
|
|
538
|
-
this.emitDecisionLog({
|
|
539
|
-
tool: toolName,
|
|
540
|
-
decision: "allow",
|
|
541
|
-
reason_code: "policy_allow",
|
|
542
|
-
request_id: requestId,
|
|
543
|
-
rate_limit_remaining: remaining,
|
|
544
|
-
tier: this.currentTier,
|
|
545
|
-
credential_ref: credentialRef
|
|
546
|
-
});
|
|
1062
|
+
this.emitDecisionLog({ tool: toolName, decision: "allow", reason_code: "policy_allow", request_id: requestId, rate_limit_remaining: remaining, tier: this.currentTier, credential_ref: credentialRef });
|
|
547
1063
|
} catch {
|
|
548
|
-
this.emitDecisionLog({
|
|
549
|
-
tool: toolName,
|
|
550
|
-
decision: "allow",
|
|
551
|
-
reason_code: "default_allow",
|
|
552
|
-
request_id: requestId,
|
|
553
|
-
tier: this.currentTier,
|
|
554
|
-
credential_ref: credentialRef
|
|
555
|
-
});
|
|
1064
|
+
this.emitDecisionLog({ tool: toolName, decision: "allow", reason_code: "default_allow", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
|
|
556
1065
|
}
|
|
557
1066
|
} else {
|
|
558
1067
|
const reasonCode = this.config.enforce ? "policy_allow" : "observe_mode";
|
|
559
|
-
this.emitDecisionLog({
|
|
560
|
-
tool: toolName,
|
|
561
|
-
decision: "allow",
|
|
562
|
-
reason_code: reasonCode,
|
|
563
|
-
request_id: requestId,
|
|
564
|
-
tier: this.currentTier,
|
|
565
|
-
credential_ref: credentialRef
|
|
566
|
-
});
|
|
1068
|
+
this.emitDecisionLog({ tool: toolName, decision: "allow", reason_code: reasonCode, request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
|
|
567
1069
|
}
|
|
568
1070
|
return null;
|
|
569
1071
|
}
|
|
570
|
-
/**
|
|
571
|
-
* Get the applicable rate limit spec based on the agent's tier.
|
|
572
|
-
*/
|
|
573
1072
|
getTierRateLimit(policy, tier) {
|
|
574
1073
|
if (policy.rate_limits && policy.rate_limits[tier]) {
|
|
575
1074
|
const tierLimit = policy.rate_limits[tier];
|
|
@@ -577,10 +1076,6 @@ var ProtectGateway = class {
|
|
|
577
1076
|
}
|
|
578
1077
|
return policy.rate_limit;
|
|
579
1078
|
}
|
|
580
|
-
/**
|
|
581
|
-
* Emit a structured decision log to stderr.
|
|
582
|
-
* If signing is enabled, also emits a signed artifact.
|
|
583
|
-
*/
|
|
584
1079
|
emitDecisionLog(entry) {
|
|
585
1080
|
const mode = this.config.enforce ? "enforce" : "shadow";
|
|
586
1081
|
const log = {
|
|
@@ -599,55 +1094,48 @@ var ProtectGateway = class {
|
|
|
599
1094
|
};
|
|
600
1095
|
process.stderr.write(`[PROTECT_MCP] ${JSON.stringify(log)}
|
|
601
1096
|
`);
|
|
1097
|
+
try {
|
|
1098
|
+
(0, import_node_fs5.appendFileSync)(this.logFilePath, JSON.stringify(log) + "\n");
|
|
1099
|
+
} catch {
|
|
1100
|
+
}
|
|
602
1101
|
if (isSigningEnabled()) {
|
|
603
1102
|
const signed = signDecision(log);
|
|
604
1103
|
if (signed.signed) {
|
|
605
1104
|
process.stderr.write(`[PROTECT_MCP_RECEIPT] ${signed.signed}
|
|
606
1105
|
`);
|
|
1106
|
+
try {
|
|
1107
|
+
(0, import_node_fs5.appendFileSync)(this.receiptFilePath, signed.signed + "\n");
|
|
1108
|
+
} catch {
|
|
1109
|
+
}
|
|
1110
|
+
this.receiptBuffer.add(log.request_id, signed.signed);
|
|
1111
|
+
if (this.admissionResult?.agent_id) {
|
|
1112
|
+
this.evidenceStore.record(this.admissionResult.agent_id, this.config.signing?.issuer || "protect-mcp");
|
|
1113
|
+
if (this.evidenceStore.getSummary(this.admissionResult.agent_id).receipt_count % 10 === 0) {
|
|
1114
|
+
this.evidenceStore.save();
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
607
1117
|
} else if (signed.warning) {
|
|
608
1118
|
process.stderr.write(`[PROTECT_MCP] Warning: ${signed.warning}
|
|
609
1119
|
`);
|
|
610
1120
|
}
|
|
611
1121
|
}
|
|
612
1122
|
}
|
|
613
|
-
/**
|
|
614
|
-
* Create a JSON-RPC error response.
|
|
615
|
-
*/
|
|
616
1123
|
makeErrorResponse(id, code, message) {
|
|
617
|
-
return {
|
|
618
|
-
jsonrpc: "2.0",
|
|
619
|
-
id,
|
|
620
|
-
error: { code, message }
|
|
621
|
-
};
|
|
1124
|
+
return { jsonrpc: "2.0", id, error: { code, message } };
|
|
622
1125
|
}
|
|
623
|
-
/**
|
|
624
|
-
* Send a message to the child process (wrapped MCP server).
|
|
625
|
-
*/
|
|
626
1126
|
sendToChild(message) {
|
|
627
|
-
if (this.child?.stdin?.writable)
|
|
628
|
-
this.child.stdin.write(message + "\n");
|
|
629
|
-
}
|
|
1127
|
+
if (this.child?.stdin?.writable) this.child.stdin.write(message + "\n");
|
|
630
1128
|
}
|
|
631
|
-
/**
|
|
632
|
-
* Send a message to the MCP client (stdout).
|
|
633
|
-
*/
|
|
634
1129
|
sendToClient(message) {
|
|
635
1130
|
process.stdout.write(message + "\n");
|
|
636
1131
|
}
|
|
637
|
-
/**
|
|
638
|
-
* Log a message to stderr (debug output).
|
|
639
|
-
*/
|
|
640
1132
|
log(message) {
|
|
641
1133
|
process.stderr.write(`[PROTECT_MCP] ${message}
|
|
642
1134
|
`);
|
|
643
1135
|
}
|
|
644
|
-
/**
|
|
645
|
-
* Stop the gateway: kill child process and exit.
|
|
646
|
-
*/
|
|
647
1136
|
stop() {
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
}
|
|
1137
|
+
this.evidenceStore.save();
|
|
1138
|
+
if (this.clientReader) this.clientReader.close();
|
|
651
1139
|
if (this.child) {
|
|
652
1140
|
this.child.kill("SIGTERM");
|
|
653
1141
|
this.child = null;
|
|
@@ -656,128 +1144,6 @@ var ProtectGateway = class {
|
|
|
656
1144
|
}
|
|
657
1145
|
};
|
|
658
1146
|
|
|
659
|
-
// src/external-pdp.ts
|
|
660
|
-
async function queryExternalPDP(context, config) {
|
|
661
|
-
const timeout = config.timeout_ms || 500;
|
|
662
|
-
const controller = new AbortController();
|
|
663
|
-
const timer = setTimeout(() => controller.abort(), timeout);
|
|
664
|
-
try {
|
|
665
|
-
const body = formatRequest(context, config.format || "generic");
|
|
666
|
-
const response = await fetch(config.endpoint, {
|
|
667
|
-
method: "POST",
|
|
668
|
-
headers: { "Content-Type": "application/json" },
|
|
669
|
-
body: JSON.stringify(body),
|
|
670
|
-
signal: controller.signal
|
|
671
|
-
});
|
|
672
|
-
clearTimeout(timer);
|
|
673
|
-
if (!response.ok) {
|
|
674
|
-
return fallbackDecision(config, `PDP returned HTTP ${response.status}`);
|
|
675
|
-
}
|
|
676
|
-
const result = await response.json();
|
|
677
|
-
return parseResponse(result, config.format || "generic");
|
|
678
|
-
} catch (err) {
|
|
679
|
-
clearTimeout(timer);
|
|
680
|
-
if (err instanceof Error && err.name === "AbortError") {
|
|
681
|
-
return fallbackDecision(config, `PDP timeout after ${timeout}ms`);
|
|
682
|
-
}
|
|
683
|
-
return fallbackDecision(config, `PDP error: ${err instanceof Error ? err.message : "unknown"}`);
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
function formatRequest(context, format) {
|
|
687
|
-
switch (format) {
|
|
688
|
-
case "opa":
|
|
689
|
-
return {
|
|
690
|
-
input: {
|
|
691
|
-
actor: context.actor,
|
|
692
|
-
action: context.action,
|
|
693
|
-
target: context.target,
|
|
694
|
-
credential_ref: context.credential_ref,
|
|
695
|
-
mode: context.mode,
|
|
696
|
-
metadata: context.request_metadata
|
|
697
|
-
}
|
|
698
|
-
};
|
|
699
|
-
case "cerbos":
|
|
700
|
-
return {
|
|
701
|
-
principal: {
|
|
702
|
-
id: context.actor.id || "unknown",
|
|
703
|
-
roles: [context.actor.tier],
|
|
704
|
-
attr: {
|
|
705
|
-
manifest_hash: context.actor.manifest_hash
|
|
706
|
-
}
|
|
707
|
-
},
|
|
708
|
-
resource: {
|
|
709
|
-
kind: "tool",
|
|
710
|
-
id: context.action.tool,
|
|
711
|
-
attr: context.target
|
|
712
|
-
},
|
|
713
|
-
actions: [context.action.operation || "call"]
|
|
714
|
-
};
|
|
715
|
-
case "generic":
|
|
716
|
-
default:
|
|
717
|
-
return context;
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
function parseResponse(result, format) {
|
|
721
|
-
switch (format) {
|
|
722
|
-
case "opa":
|
|
723
|
-
if (typeof result.result === "boolean") {
|
|
724
|
-
return { allowed: result.result };
|
|
725
|
-
}
|
|
726
|
-
if (result.result && typeof result.result === "object") {
|
|
727
|
-
const r = result.result;
|
|
728
|
-
return {
|
|
729
|
-
allowed: Boolean(r.allow),
|
|
730
|
-
reason: r.reason,
|
|
731
|
-
metadata: r
|
|
732
|
-
};
|
|
733
|
-
}
|
|
734
|
-
return { allowed: false, reason: "unrecognized OPA response" };
|
|
735
|
-
case "cerbos":
|
|
736
|
-
if (Array.isArray(result.results) && result.results.length > 0) {
|
|
737
|
-
const actions = result.results[0].actions;
|
|
738
|
-
if (actions) {
|
|
739
|
-
const effect = Object.values(actions)[0];
|
|
740
|
-
return { allowed: effect === "EFFECT_ALLOW" };
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
return { allowed: false, reason: "unrecognized Cerbos response" };
|
|
744
|
-
case "generic":
|
|
745
|
-
default:
|
|
746
|
-
return {
|
|
747
|
-
allowed: Boolean(result.allowed),
|
|
748
|
-
reason: result.reason,
|
|
749
|
-
metadata: result.metadata
|
|
750
|
-
};
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
function fallbackDecision(config, reason) {
|
|
754
|
-
const fallback = config.fallback || "deny";
|
|
755
|
-
return {
|
|
756
|
-
allowed: fallback === "allow",
|
|
757
|
-
reason: `fallback_${fallback}: ${reason}`
|
|
758
|
-
};
|
|
759
|
-
}
|
|
760
|
-
function buildDecisionContext(toolName, tier, opts) {
|
|
761
|
-
return {
|
|
762
|
-
v: 1,
|
|
763
|
-
actor: {
|
|
764
|
-
id: opts.agentId,
|
|
765
|
-
tier,
|
|
766
|
-
manifest_hash: opts.manifestHash
|
|
767
|
-
},
|
|
768
|
-
action: {
|
|
769
|
-
tool: toolName,
|
|
770
|
-
operation: "call"
|
|
771
|
-
},
|
|
772
|
-
target: {
|
|
773
|
-
service: opts.slug || "default"
|
|
774
|
-
},
|
|
775
|
-
credential_ref: opts.credentialRef,
|
|
776
|
-
mode: opts.mode,
|
|
777
|
-
request_metadata: opts.requestMetadata || {}
|
|
778
|
-
};
|
|
779
|
-
}
|
|
780
|
-
|
|
781
1147
|
// src/bundle.ts
|
|
782
1148
|
function createAuditBundle(opts) {
|
|
783
1149
|
const receipts = opts.receipts.filter(
|