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.
@@ -0,0 +1,1105 @@
1
+ // src/policy.ts
2
+ import { createHash } from "crypto";
3
+ import { readFileSync } from "fs";
4
+ function loadPolicy(path) {
5
+ const raw = readFileSync(path, "utf-8");
6
+ const parsed = JSON.parse(raw);
7
+ if (!parsed.tools || typeof parsed.tools !== "object") {
8
+ throw new Error(`Invalid policy file: missing "tools" object in ${path}`);
9
+ }
10
+ const policy = {
11
+ tools: parsed.tools,
12
+ default_tier: parsed.default_tier || "unknown",
13
+ policy_engine: parsed.policy_engine || "built-in",
14
+ ...parsed.external ? { external: parsed.external } : {}
15
+ };
16
+ const digest = computePolicyDigest(policy);
17
+ return {
18
+ policy,
19
+ digest,
20
+ credentials: parsed.credentials,
21
+ signing: parsed.signing
22
+ };
23
+ }
24
+ function computePolicyDigest(policy) {
25
+ const canonical = JSON.stringify(sortKeysDeep(policy));
26
+ return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
27
+ }
28
+ function sortKeysDeep(obj) {
29
+ if (obj === null || typeof obj !== "object") return obj;
30
+ if (Array.isArray(obj)) return obj.map(sortKeysDeep);
31
+ const sorted = {};
32
+ for (const key of Object.keys(obj).sort()) {
33
+ sorted[key] = sortKeysDeep(obj[key]);
34
+ }
35
+ return sorted;
36
+ }
37
+ function getToolPolicy(toolName, policy) {
38
+ if (!policy) {
39
+ return { require: "any" };
40
+ }
41
+ if (policy.tools[toolName]) {
42
+ return policy.tools[toolName];
43
+ }
44
+ if (policy.tools["*"]) {
45
+ return policy.tools["*"];
46
+ }
47
+ return { require: "any" };
48
+ }
49
+ function parseRateLimit(spec) {
50
+ const match = spec.match(/^(\d+)\/(second|minute|hour|day)$/);
51
+ if (!match) {
52
+ throw new Error(`Invalid rate limit format: "${spec}". Expected "N/unit" (e.g. "5/hour")`);
53
+ }
54
+ const count = parseInt(match[1], 10);
55
+ const unit = match[2];
56
+ const windowMs = {
57
+ second: 1e3,
58
+ minute: 6e4,
59
+ hour: 36e5,
60
+ day: 864e5
61
+ };
62
+ return { count, windowMs: windowMs[unit] };
63
+ }
64
+ function checkRateLimit(key, limit, store) {
65
+ const now = Date.now();
66
+ const windowStart = now - limit.windowMs;
67
+ const timestamps = (store.get(key) || []).filter((t) => t > windowStart);
68
+ if (timestamps.length >= limit.count) {
69
+ store.set(key, timestamps);
70
+ return { allowed: false, remaining: 0 };
71
+ }
72
+ timestamps.push(now);
73
+ store.set(key, timestamps);
74
+ return { allowed: true, remaining: limit.count - timestamps.length };
75
+ }
76
+
77
+ // src/evidence-store.ts
78
+ import { readFileSync as readFileSync2, writeFileSync, existsSync } from "fs";
79
+ import { join } from "path";
80
+ var DEFAULT_THRESHOLDS = {
81
+ min_receipts: 10,
82
+ min_epoch_span: 3,
83
+ min_issuers: 2
84
+ };
85
+ var EvidenceStore = class {
86
+ agents = /* @__PURE__ */ new Map();
87
+ filePath;
88
+ dirty = false;
89
+ constructor(dir) {
90
+ this.filePath = join(dir || process.cwd(), ".protect-mcp-evidence.json");
91
+ this.load();
92
+ }
93
+ /**
94
+ * Record a receipt observation for an agent.
95
+ */
96
+ record(agentId, issuer, timestamp) {
97
+ const ts = timestamp || (/* @__PURE__ */ new Date()).toISOString();
98
+ const epochHour = Math.floor(new Date(ts).getTime() / (3600 * 1e3));
99
+ const existing = this.agents.get(agentId);
100
+ const observation = {
101
+ issuer,
102
+ timestamp: ts,
103
+ epoch_hour: epochHour
104
+ };
105
+ if (existing) {
106
+ existing.receipts.push(observation);
107
+ existing.last_seen = ts;
108
+ if (existing.receipts.length > 200) {
109
+ existing.receipts = existing.receipts.slice(-200);
110
+ }
111
+ } else {
112
+ this.agents.set(agentId, {
113
+ agent_id: agentId,
114
+ receipts: [observation],
115
+ first_seen: ts,
116
+ last_seen: ts
117
+ });
118
+ }
119
+ this.dirty = true;
120
+ }
121
+ /**
122
+ * Get the evidence summary for an agent.
123
+ */
124
+ getSummary(agentId) {
125
+ const record = this.agents.get(agentId);
126
+ if (!record || record.receipts.length === 0) {
127
+ return { receipt_count: 0, epoch_span: 0, issuer_count: 0 };
128
+ }
129
+ const uniqueIssuers = new Set(record.receipts.map((r) => r.issuer));
130
+ const uniqueEpochs = new Set(record.receipts.map((r) => r.epoch_hour));
131
+ return {
132
+ receipt_count: record.receipts.length,
133
+ epoch_span: uniqueEpochs.size,
134
+ issuer_count: uniqueIssuers.size
135
+ };
136
+ }
137
+ /**
138
+ * Check if an agent meets the evidenced tier thresholds.
139
+ */
140
+ meetsEvidencedThreshold(agentId, thresholds = DEFAULT_THRESHOLDS) {
141
+ const summary = this.getSummary(agentId);
142
+ return summary.receipt_count >= thresholds.min_receipts && summary.epoch_span >= thresholds.min_epoch_span && summary.issuer_count >= thresholds.min_issuers;
143
+ }
144
+ /**
145
+ * Persist to disk (call periodically or on shutdown).
146
+ */
147
+ save() {
148
+ if (!this.dirty) return;
149
+ const data = {};
150
+ for (const [id, record] of this.agents) {
151
+ data[id] = record;
152
+ }
153
+ try {
154
+ writeFileSync(this.filePath, JSON.stringify({ v: 1, agents: data }, null, 2) + "\n");
155
+ this.dirty = false;
156
+ } catch {
157
+ }
158
+ }
159
+ /**
160
+ * Load from disk.
161
+ */
162
+ load() {
163
+ if (!existsSync(this.filePath)) return;
164
+ try {
165
+ const raw = readFileSync2(this.filePath, "utf-8");
166
+ const parsed = JSON.parse(raw);
167
+ if (parsed.agents && typeof parsed.agents === "object") {
168
+ for (const [id, record] of Object.entries(parsed.agents)) {
169
+ this.agents.set(id, record);
170
+ }
171
+ }
172
+ } catch {
173
+ }
174
+ }
175
+ /**
176
+ * Get total agent count (for status display).
177
+ */
178
+ agentCount() {
179
+ return this.agents.size;
180
+ }
181
+ /**
182
+ * Get all agent summaries (for status display).
183
+ */
184
+ allSummaries() {
185
+ const result = [];
186
+ for (const [id] of this.agents) {
187
+ result.push({ agent_id: id, summary: this.getSummary(id) });
188
+ }
189
+ return result;
190
+ }
191
+ };
192
+
193
+ // src/admission.ts
194
+ function evaluateTier(manifest, opts) {
195
+ const options = opts && ("evidenceStore" in opts || "overrides" in opts || "thresholds" in opts) ? opts : { overrides: opts };
196
+ const { overrides, evidenceStore, thresholds } = options;
197
+ if (!manifest) {
198
+ return {
199
+ tier: "unknown",
200
+ reason: "no_manifest_presented"
201
+ };
202
+ }
203
+ if (overrides && manifest.agent_id && overrides[manifest.agent_id]) {
204
+ return {
205
+ tier: overrides[manifest.agent_id],
206
+ agent_id: manifest.agent_id,
207
+ manifest_hash: manifest.manifest_hash,
208
+ reason: "operator_override"
209
+ };
210
+ }
211
+ if (manifest.signature_valid === false) {
212
+ return {
213
+ tier: "unknown",
214
+ agent_id: manifest.agent_id,
215
+ manifest_hash: manifest.manifest_hash,
216
+ reason: "invalid_manifest_signature"
217
+ };
218
+ }
219
+ if (manifest.signature_valid === true) {
220
+ if (manifest.evidence_summary) {
221
+ const es = manifest.evidence_summary;
222
+ const t = thresholds || DEFAULT_THRESHOLDS;
223
+ if (es.receipt_count >= t.min_receipts && es.epoch_span >= t.min_epoch_span && es.issuer_count >= t.min_issuers) {
224
+ return {
225
+ tier: "evidenced",
226
+ agent_id: manifest.agent_id,
227
+ manifest_hash: manifest.manifest_hash,
228
+ reason: "evidence_threshold_met"
229
+ };
230
+ }
231
+ }
232
+ if (evidenceStore && manifest.agent_id) {
233
+ if (evidenceStore.meetsEvidencedThreshold(manifest.agent_id, thresholds)) {
234
+ return {
235
+ tier: "evidenced",
236
+ agent_id: manifest.agent_id,
237
+ manifest_hash: manifest.manifest_hash,
238
+ reason: "evidence_store_threshold_met"
239
+ };
240
+ }
241
+ }
242
+ return {
243
+ tier: "signed-known",
244
+ agent_id: manifest.agent_id,
245
+ manifest_hash: manifest.manifest_hash,
246
+ reason: "valid_signed_manifest"
247
+ };
248
+ }
249
+ return {
250
+ tier: "unknown",
251
+ agent_id: manifest.agent_id,
252
+ manifest_hash: manifest.manifest_hash,
253
+ reason: "manifest_unverified"
254
+ };
255
+ }
256
+ function meetsMinTier(actual, required) {
257
+ const order = ["unknown", "signed-known", "evidenced", "privileged"];
258
+ return order.indexOf(actual) >= order.indexOf(required);
259
+ }
260
+
261
+ // src/credentials.ts
262
+ function resolveCredential(label, credentials) {
263
+ if (!credentials || !credentials[label]) {
264
+ return {
265
+ resolved: false,
266
+ label,
267
+ error: `credential "${label}" not configured`
268
+ };
269
+ }
270
+ const config = credentials[label];
271
+ const value = process.env[config.value_env];
272
+ if (!value) {
273
+ return {
274
+ resolved: false,
275
+ label,
276
+ error: `environment variable "${config.value_env}" for credential "${label}" is not set`
277
+ };
278
+ }
279
+ return {
280
+ resolved: true,
281
+ label,
282
+ value,
283
+ inject: config.inject,
284
+ name: config.name
285
+ };
286
+ }
287
+ function listCredentialLabels(credentials) {
288
+ if (!credentials) return [];
289
+ return Object.keys(credentials);
290
+ }
291
+ function validateCredentials(credentials) {
292
+ const warnings = [];
293
+ if (!credentials) return warnings;
294
+ for (const [label, config] of Object.entries(credentials)) {
295
+ if (!config.value_env) {
296
+ warnings.push(`credential "${label}": missing value_env`);
297
+ continue;
298
+ }
299
+ if (!config.inject) {
300
+ warnings.push(`credential "${label}": missing inject type`);
301
+ continue;
302
+ }
303
+ if (!process.env[config.value_env]) {
304
+ warnings.push(`credential "${label}": env var "${config.value_env}" not set`);
305
+ }
306
+ }
307
+ return warnings;
308
+ }
309
+
310
+ // src/signing.ts
311
+ import { readFileSync as readFileSync3, existsSync as existsSync2 } from "fs";
312
+ var signerState = null;
313
+ var artifactsModule = null;
314
+ async function initSigning(config) {
315
+ const warnings = [];
316
+ if (!config || config.enabled === false) {
317
+ return warnings;
318
+ }
319
+ try {
320
+ artifactsModule = await import("@veritasacta/artifacts");
321
+ } catch {
322
+ warnings.push("signing: @veritasacta/artifacts not available \u2014 receipts will be unsigned");
323
+ return warnings;
324
+ }
325
+ if (config.key_path) {
326
+ if (!existsSync2(config.key_path)) {
327
+ warnings.push(`signing: key file not found at ${config.key_path} \u2014 run "protect-mcp init" to generate`);
328
+ return warnings;
329
+ }
330
+ try {
331
+ const keyData = JSON.parse(readFileSync3(config.key_path, "utf-8"));
332
+ if (!keyData.privateKey || !keyData.publicKey) {
333
+ warnings.push("signing: key file missing privateKey or publicKey fields");
334
+ return warnings;
335
+ }
336
+ signerState = {
337
+ privateKey: keyData.privateKey,
338
+ publicKey: keyData.publicKey,
339
+ kid: keyData.kid || artifactsModule.computeKid(keyData.publicKey),
340
+ issuer: config.issuer || keyData.issuer || "protect-mcp"
341
+ };
342
+ } catch (err) {
343
+ warnings.push(`signing: failed to load key file: ${err instanceof Error ? err.message : err}`);
344
+ }
345
+ }
346
+ return warnings;
347
+ }
348
+ function signDecision(entry) {
349
+ if (!signerState || !artifactsModule) {
350
+ return { signed: null, artifact_type: "none" };
351
+ }
352
+ const artifactType = entry.decision === "deny" ? "gateway_restraint" : "decision_receipt";
353
+ try {
354
+ const payload = {
355
+ tool: entry.tool,
356
+ decision: entry.decision,
357
+ reason_code: entry.reason_code,
358
+ policy_digest: entry.policy_digest,
359
+ scope: entry.request_id,
360
+ // request scope
361
+ mode: entry.mode,
362
+ request_id: entry.request_id
363
+ };
364
+ if (entry.tier) payload.tier = entry.tier;
365
+ if (entry.credential_ref) payload.credential_ref = entry.credential_ref;
366
+ if (entry.rate_limit_remaining !== void 0) {
367
+ payload.rate_limit_remaining = entry.rate_limit_remaining;
368
+ }
369
+ if (entry.policy_engine) payload.policy_engine = entry.policy_engine;
370
+ const result = artifactsModule.createSignedArtifact(
371
+ artifactType,
372
+ payload,
373
+ signerState.privateKey,
374
+ {
375
+ kid: signerState.kid,
376
+ issuer: signerState.issuer
377
+ }
378
+ );
379
+ return {
380
+ signed: JSON.stringify(result.artifact),
381
+ artifact_type: artifactType
382
+ };
383
+ } catch (err) {
384
+ return {
385
+ signed: null,
386
+ artifact_type: artifactType,
387
+ warning: `signing failed: ${err instanceof Error ? err.message : "unknown error"}`
388
+ };
389
+ }
390
+ }
391
+ function getSignerInfo() {
392
+ if (!signerState) return null;
393
+ return {
394
+ publicKey: signerState.publicKey,
395
+ kid: signerState.kid,
396
+ issuer: signerState.issuer
397
+ };
398
+ }
399
+ function isSigningEnabled() {
400
+ return signerState !== null && artifactsModule !== null;
401
+ }
402
+
403
+ // src/external-pdp.ts
404
+ async function queryExternalPDP(context, config) {
405
+ const timeout = config.timeout_ms || 500;
406
+ const controller = new AbortController();
407
+ const timer = setTimeout(() => controller.abort(), timeout);
408
+ try {
409
+ const body = formatRequest(context, config.format || "generic");
410
+ const response = await fetch(config.endpoint, {
411
+ method: "POST",
412
+ headers: { "Content-Type": "application/json" },
413
+ body: JSON.stringify(body),
414
+ signal: controller.signal
415
+ });
416
+ clearTimeout(timer);
417
+ if (!response.ok) {
418
+ return fallbackDecision(config, `PDP returned HTTP ${response.status}`);
419
+ }
420
+ const result = await response.json();
421
+ return parseResponse(result, config.format || "generic");
422
+ } catch (err) {
423
+ clearTimeout(timer);
424
+ if (err instanceof Error && err.name === "AbortError") {
425
+ return fallbackDecision(config, `PDP timeout after ${timeout}ms`);
426
+ }
427
+ return fallbackDecision(config, `PDP error: ${err instanceof Error ? err.message : "unknown"}`);
428
+ }
429
+ }
430
+ function formatRequest(context, format) {
431
+ switch (format) {
432
+ case "opa":
433
+ return {
434
+ input: {
435
+ actor: context.actor,
436
+ action: context.action,
437
+ target: context.target,
438
+ credential_ref: context.credential_ref,
439
+ mode: context.mode,
440
+ metadata: context.request_metadata
441
+ }
442
+ };
443
+ case "cerbos":
444
+ return {
445
+ principal: {
446
+ id: context.actor.id || "unknown",
447
+ roles: [context.actor.tier],
448
+ attr: {
449
+ manifest_hash: context.actor.manifest_hash
450
+ }
451
+ },
452
+ resource: {
453
+ kind: "tool",
454
+ id: context.action.tool,
455
+ attr: context.target
456
+ },
457
+ actions: [context.action.operation || "call"]
458
+ };
459
+ case "generic":
460
+ default:
461
+ return context;
462
+ }
463
+ }
464
+ function parseResponse(result, format) {
465
+ switch (format) {
466
+ case "opa":
467
+ if (typeof result.result === "boolean") {
468
+ return { allowed: result.result };
469
+ }
470
+ if (result.result && typeof result.result === "object") {
471
+ const r = result.result;
472
+ return {
473
+ allowed: Boolean(r.allow),
474
+ reason: r.reason,
475
+ metadata: r
476
+ };
477
+ }
478
+ return { allowed: false, reason: "unrecognized OPA response" };
479
+ case "cerbos":
480
+ if (Array.isArray(result.results) && result.results.length > 0) {
481
+ const actions = result.results[0].actions;
482
+ if (actions) {
483
+ const effect = Object.values(actions)[0];
484
+ return { allowed: effect === "EFFECT_ALLOW" };
485
+ }
486
+ }
487
+ return { allowed: false, reason: "unrecognized Cerbos response" };
488
+ case "generic":
489
+ default:
490
+ return {
491
+ allowed: Boolean(result.allowed),
492
+ reason: result.reason,
493
+ metadata: result.metadata
494
+ };
495
+ }
496
+ }
497
+ function fallbackDecision(config, reason) {
498
+ const fallback = config.fallback || "deny";
499
+ return {
500
+ allowed: fallback === "allow",
501
+ reason: `fallback_${fallback}: ${reason}`
502
+ };
503
+ }
504
+ function buildDecisionContext(toolName, tier, opts) {
505
+ return {
506
+ v: 1,
507
+ actor: {
508
+ id: opts.agentId,
509
+ tier,
510
+ manifest_hash: opts.manifestHash
511
+ },
512
+ action: {
513
+ tool: toolName,
514
+ operation: "call"
515
+ },
516
+ target: {
517
+ service: opts.slug || "default"
518
+ },
519
+ credential_ref: opts.credentialRef,
520
+ mode: opts.mode,
521
+ request_metadata: opts.requestMetadata || {}
522
+ };
523
+ }
524
+
525
+ // src/gateway.ts
526
+ import { spawn } from "child_process";
527
+ import { randomUUID, randomBytes } from "crypto";
528
+ import { createInterface } from "readline";
529
+ import { appendFileSync } from "fs";
530
+ import { join as join3 } from "path";
531
+
532
+ // src/http-server.ts
533
+ import { createServer } from "http";
534
+ import { readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
535
+ import { join as join2 } from "path";
536
+ var LOG_FILE = ".protect-mcp-log.jsonl";
537
+ var MAX_RECEIPTS = 100;
538
+ var ReceiptBuffer = class {
539
+ receipts = [];
540
+ add(requestId, receipt) {
541
+ this.receipts.push({
542
+ request_id: requestId,
543
+ receipt,
544
+ timestamp: Date.now()
545
+ });
546
+ if (this.receipts.length > MAX_RECEIPTS) {
547
+ this.receipts = this.receipts.slice(-MAX_RECEIPTS);
548
+ }
549
+ }
550
+ getAll() {
551
+ return [...this.receipts].reverse();
552
+ }
553
+ getById(requestId) {
554
+ return this.receipts.find((r) => r.request_id === requestId);
555
+ }
556
+ count() {
557
+ return this.receipts.length;
558
+ }
559
+ getLatest() {
560
+ return this.receipts.length > 0 ? this.receipts[this.receipts.length - 1] : void 0;
561
+ }
562
+ };
563
+ function startStatusServer(config, receiptBuffer, approvalStore, approvalNonce) {
564
+ const startTime = Date.now();
565
+ const logDir = process.cwd();
566
+ const server = createServer((req, res) => {
567
+ res.setHeader("Access-Control-Allow-Origin", "*");
568
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
569
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
570
+ res.setHeader("Content-Type", "application/json");
571
+ if (req.method === "OPTIONS") {
572
+ res.writeHead(204);
573
+ res.end();
574
+ return;
575
+ }
576
+ const url = new URL(req.url || "/", `http://localhost:${config.port}`);
577
+ const path = url.pathname;
578
+ try {
579
+ if (path === "/health") {
580
+ handleHealth(res, startTime, config);
581
+ } else if (path === "/status") {
582
+ handleStatus(res, logDir);
583
+ } else if (path === "/receipts") {
584
+ handleReceipts(res, receiptBuffer, url);
585
+ } else if (path === "/receipts/latest") {
586
+ handleReceiptLatest(res, receiptBuffer);
587
+ } else if (path.startsWith("/receipts/")) {
588
+ const id = path.slice("/receipts/".length);
589
+ handleReceiptById(res, receiptBuffer, id);
590
+ } else if (path === "/approve" && req.method === "POST") {
591
+ handleApprove(req, res, approvalStore, approvalNonce);
592
+ } else if (path === "/approvals" && req.method === "GET") {
593
+ handleListApprovals(res, approvalStore);
594
+ } else {
595
+ res.writeHead(404);
596
+ res.end(JSON.stringify({ error: "not_found", endpoints: ["/health", "/status", "/receipts", "/receipts/latest", "/receipts/:id", "/approve", "/approvals"] }));
597
+ }
598
+ } catch (err) {
599
+ res.writeHead(500);
600
+ res.end(JSON.stringify({ error: "internal_error" }));
601
+ }
602
+ });
603
+ server.on("error", (err) => {
604
+ if (config.verbose) {
605
+ process.stderr.write(`[PROTECT_MCP] HTTP status server error: ${err.message}
606
+ `);
607
+ }
608
+ });
609
+ server.listen(config.port, "127.0.0.1", () => {
610
+ if (config.verbose) {
611
+ process.stderr.write(`[PROTECT_MCP] HTTP status server listening on http://127.0.0.1:${config.port}
612
+ `);
613
+ }
614
+ });
615
+ server.unref();
616
+ return server;
617
+ }
618
+ function handleHealth(res, startTime, config) {
619
+ res.writeHead(200);
620
+ res.end(JSON.stringify({
621
+ status: "ok",
622
+ uptime_ms: Date.now() - startTime,
623
+ mode: config.mode,
624
+ version: "0.3.1"
625
+ }));
626
+ }
627
+ function handleStatus(res, logDir) {
628
+ const logPath = join2(logDir, LOG_FILE);
629
+ if (!existsSync3(logPath)) {
630
+ res.writeHead(200);
631
+ res.end(JSON.stringify({ entries: 0, message: "no log file yet" }));
632
+ return;
633
+ }
634
+ const raw = readFileSync4(logPath, "utf-8");
635
+ const lines = raw.trim().split("\n").filter(Boolean);
636
+ const entries = [];
637
+ for (const line of lines) {
638
+ try {
639
+ entries.push(JSON.parse(line));
640
+ } catch {
641
+ }
642
+ }
643
+ const toolCounts = {};
644
+ let allowCount = 0, denyCount = 0;
645
+ const tierCounts = {};
646
+ for (const e of entries) {
647
+ toolCounts[e.tool] = (toolCounts[e.tool] || 0) + 1;
648
+ if (e.decision === "allow") allowCount++;
649
+ else denyCount++;
650
+ if (e.tier) tierCounts[e.tier] = (tierCounts[e.tier] || 0) + 1;
651
+ }
652
+ res.writeHead(200);
653
+ res.end(JSON.stringify({
654
+ entries: entries.length,
655
+ allow: allowCount,
656
+ deny: denyCount,
657
+ tools: toolCounts,
658
+ tiers: tierCounts,
659
+ first_timestamp: entries.length > 0 ? entries[0].timestamp : null,
660
+ last_timestamp: entries.length > 0 ? entries[entries.length - 1].timestamp : null
661
+ }));
662
+ }
663
+ function handleReceipts(res, buffer, url) {
664
+ const limit = parseInt(url.searchParams.get("limit") || "20", 10);
665
+ const receipts = buffer.getAll().slice(0, Math.min(limit, MAX_RECEIPTS));
666
+ res.writeHead(200);
667
+ res.end(JSON.stringify({
668
+ count: receipts.length,
669
+ total: buffer.count(),
670
+ receipts
671
+ }));
672
+ }
673
+ function handleReceiptLatest(res, buffer) {
674
+ const latest = buffer.getLatest();
675
+ if (!latest) {
676
+ res.writeHead(404);
677
+ res.end(JSON.stringify({ error: "no_receipts", message: "No receipts yet. Make a tool call through protect-mcp first." }));
678
+ return;
679
+ }
680
+ res.writeHead(200);
681
+ res.end(JSON.stringify(latest));
682
+ }
683
+ function handleReceiptById(res, buffer, id) {
684
+ const receipt = buffer.getById(id);
685
+ if (!receipt) {
686
+ res.writeHead(404);
687
+ res.end(JSON.stringify({ error: "receipt_not_found", request_id: id }));
688
+ return;
689
+ }
690
+ res.writeHead(200);
691
+ res.end(JSON.stringify(receipt));
692
+ }
693
+ function handleApprove(req, res, approvalStore, expectedNonce) {
694
+ if (!approvalStore) {
695
+ res.writeHead(503);
696
+ res.end(JSON.stringify({ error: "approval_store_not_available" }));
697
+ return;
698
+ }
699
+ let body = "";
700
+ req.on("data", (chunk) => {
701
+ body += chunk.toString();
702
+ });
703
+ req.on("end", () => {
704
+ try {
705
+ const { request_id, tool, mode, nonce } = JSON.parse(body);
706
+ if (expectedNonce && nonce !== expectedNonce) {
707
+ res.writeHead(403);
708
+ res.end(JSON.stringify({ error: "invalid_nonce", message: "Approval nonce does not match. Check stderr output for the correct nonce." }));
709
+ return;
710
+ }
711
+ if (!tool || typeof tool !== "string") {
712
+ res.writeHead(400);
713
+ res.end(JSON.stringify({ error: "missing_tool", usage: '{"request_id":"abc123","tool":"send_email","mode":"once|always","nonce":"..."}' }));
714
+ return;
715
+ }
716
+ const grantMode = mode === "always" ? "always" : "once";
717
+ const ttlMs = grantMode === "once" ? 5 * 60 * 1e3 : 24 * 60 * 60 * 1e3;
718
+ const grantEntry = { tool, mode: grantMode, expires_at: Date.now() + ttlMs };
719
+ if (grantMode === "always") {
720
+ approvalStore.set(`always:${tool}`, grantEntry);
721
+ } else if (request_id) {
722
+ approvalStore.set(request_id, grantEntry);
723
+ } else {
724
+ approvalStore.set(tool, grantEntry);
725
+ }
726
+ res.writeHead(200);
727
+ res.end(JSON.stringify({
728
+ approved: true,
729
+ request_id: request_id || null,
730
+ tool,
731
+ mode: grantMode,
732
+ expires_in_seconds: ttlMs / 1e3
733
+ }));
734
+ } catch {
735
+ res.writeHead(400);
736
+ res.end(JSON.stringify({ error: "invalid_json", usage: '{"request_id":"abc123","tool":"send_email","mode":"once","nonce":"..."}' }));
737
+ }
738
+ });
739
+ }
740
+ function handleListApprovals(res, approvalStore) {
741
+ if (!approvalStore) {
742
+ res.writeHead(200);
743
+ res.end(JSON.stringify({ grants: [] }));
744
+ return;
745
+ }
746
+ const now = Date.now();
747
+ const grants = [];
748
+ for (const [key, grant] of approvalStore) {
749
+ if (now < grant.expires_at) {
750
+ grants.push({ key, tool: grant.tool, mode: grant.mode, expires_in_seconds: Math.round((grant.expires_at - now) / 1e3) });
751
+ }
752
+ }
753
+ res.writeHead(200);
754
+ res.end(JSON.stringify({ grants }));
755
+ }
756
+
757
+ // src/gateway.ts
758
+ var LOG_FILE2 = ".protect-mcp-log.jsonl";
759
+ var RECEIPTS_FILE = ".protect-mcp-receipts.jsonl";
760
+ var ProtectGateway = class {
761
+ child = null;
762
+ config;
763
+ rateLimitStore = /* @__PURE__ */ new Map();
764
+ clientReader = null;
765
+ logFilePath;
766
+ receiptFilePath;
767
+ evidenceStore;
768
+ receiptBuffer;
769
+ /** Approval grants keyed by request_id (scoped to the specific action that was requested) */
770
+ approvalStore = /* @__PURE__ */ new Map();
771
+ /** Random nonce generated at startup — required for approval endpoint authentication */
772
+ approvalNonce = randomBytes(16).toString("hex");
773
+ currentTier = "unknown";
774
+ admissionResult = null;
775
+ constructor(config) {
776
+ this.config = config;
777
+ this.logFilePath = join3(process.cwd(), LOG_FILE2);
778
+ this.receiptFilePath = join3(process.cwd(), RECEIPTS_FILE);
779
+ this.evidenceStore = new EvidenceStore();
780
+ this.receiptBuffer = new ReceiptBuffer();
781
+ }
782
+ async start() {
783
+ const { command, args, verbose } = this.config;
784
+ const mode = this.config.enforce ? "enforce" : "shadow";
785
+ if (verbose) {
786
+ this.log(`Starting gateway in ${mode} mode`);
787
+ this.log(`Wrapping: ${command} ${args.join(" ")}`);
788
+ if (this.config.policy) {
789
+ this.log(`Policy digest: ${this.config.policyDigest}`);
790
+ }
791
+ if (isSigningEnabled()) {
792
+ this.log("Signing: enabled (receipts will be signed)");
793
+ }
794
+ if (this.config.credentials) {
795
+ const labels = Object.keys(this.config.credentials);
796
+ this.log(`Credential vault: ${labels.length} credential(s) configured [${labels.join(", ")}]`);
797
+ }
798
+ if (this.config.policy?.policy_engine === "external" || this.config.policy?.policy_engine === "hybrid") {
799
+ this.log(`External PDP: ${this.config.policy.external?.endpoint || "not configured"}`);
800
+ }
801
+ }
802
+ this.log(`Approval nonce: ${this.approvalNonce}`);
803
+ const httpPort = parseInt(process.env.PROTECT_MCP_HTTP_PORT || "9876", 10);
804
+ if (httpPort > 0) {
805
+ try {
806
+ startStatusServer(
807
+ { port: httpPort, mode, verbose },
808
+ this.receiptBuffer,
809
+ this.approvalStore,
810
+ this.approvalNonce
811
+ );
812
+ } catch {
813
+ if (verbose) this.log(`HTTP status server could not start on port ${httpPort}`);
814
+ }
815
+ }
816
+ const childEnv = { ...process.env };
817
+ if (this.config.credentials) {
818
+ for (const [label, credConfig] of Object.entries(this.config.credentials)) {
819
+ if (credConfig.inject === "env" && credConfig.name && credConfig.value_env) {
820
+ const envValue = process.env[credConfig.value_env];
821
+ if (envValue) {
822
+ childEnv[credConfig.name] = envValue;
823
+ if (verbose) this.log(`Credential "${label}": injected as env var "${credConfig.name}"`);
824
+ }
825
+ }
826
+ }
827
+ }
828
+ this.child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"], env: childEnv });
829
+ if (!this.child.stdin || !this.child.stdout || !this.child.stderr) {
830
+ throw new Error("Failed to create pipes to child process");
831
+ }
832
+ this.child.stderr.on("data", (data) => {
833
+ process.stderr.write(data);
834
+ });
835
+ const childReader = createInterface({ input: this.child.stdout, crlfDelay: Infinity });
836
+ childReader.on("line", (line) => {
837
+ this.handleServerMessage(line);
838
+ });
839
+ this.clientReader = createInterface({ input: process.stdin, crlfDelay: Infinity });
840
+ this.clientReader.on("line", (line) => {
841
+ this.handleClientMessage(line);
842
+ });
843
+ this.child.on("exit", (code, signal) => {
844
+ if (verbose) this.log(`Child process exited (code=${code}, signal=${signal})`);
845
+ this.evidenceStore.save();
846
+ process.exit(code ?? 1);
847
+ });
848
+ this.child.on("error", (err) => {
849
+ this.log(`Child process error: ${err.message}`);
850
+ process.exit(1);
851
+ });
852
+ process.on("SIGINT", () => this.stop());
853
+ process.on("SIGTERM", () => this.stop());
854
+ process.stdin.on("end", () => {
855
+ if (verbose) this.log("Client stdin closed, closing child stdin");
856
+ if (this.child?.stdin?.writable) this.child.stdin.end();
857
+ });
858
+ }
859
+ setManifest(manifest) {
860
+ this.admissionResult = evaluateTier(manifest, { evidenceStore: this.evidenceStore });
861
+ this.currentTier = this.admissionResult.tier;
862
+ if (this.config.verbose) {
863
+ this.log(`Admission: tier=${this.currentTier} agent=${this.admissionResult.agent_id || "none"}`);
864
+ }
865
+ return this.admissionResult;
866
+ }
867
+ handleClientMessage(raw) {
868
+ const trimmed = raw.trim();
869
+ if (!trimmed) return;
870
+ let message;
871
+ try {
872
+ message = JSON.parse(trimmed);
873
+ } catch {
874
+ this.sendToChild(trimmed);
875
+ return;
876
+ }
877
+ if (message.method === "tools/call" && message.id !== void 0) {
878
+ this.interceptToolCallAsync(message, trimmed);
879
+ return;
880
+ }
881
+ this.sendToChild(trimmed);
882
+ }
883
+ async interceptToolCallAsync(request, raw) {
884
+ const result = await this.interceptToolCall(request);
885
+ if (result) {
886
+ this.sendToClient(JSON.stringify(result));
887
+ } else {
888
+ const modified = this.injectParamsCredentials(request);
889
+ this.sendToChild(JSON.stringify(modified));
890
+ }
891
+ }
892
+ handleServerMessage(raw) {
893
+ this.sendToClient(raw);
894
+ }
895
+ injectParamsCredentials(request) {
896
+ if (!this.config.credentials) return request;
897
+ const injections = {};
898
+ for (const [label, credConfig] of Object.entries(this.config.credentials)) {
899
+ if (credConfig.inject === "header" || credConfig.inject === "query") {
900
+ const cred = resolveCredential(label, this.config.credentials);
901
+ if (cred.resolved && cred.value && cred.name) {
902
+ injections[cred.name] = cred.value;
903
+ }
904
+ }
905
+ }
906
+ if (Object.keys(injections).length === 0) return request;
907
+ return { ...request, params: { ...request.params, _credentials: injections } };
908
+ }
909
+ async interceptToolCall(request) {
910
+ const toolName = request.params?.name || "unknown";
911
+ const requestId = randomUUID().slice(0, 12);
912
+ const toolPolicy = getToolPolicy(toolName, this.config.policy);
913
+ const mode = this.config.enforce ? "enforce" : "shadow";
914
+ let credentialRef;
915
+ if (this.config.credentials) {
916
+ const cred = resolveCredential(toolName, this.config.credentials);
917
+ if (cred.resolved) {
918
+ credentialRef = cred.label;
919
+ } else if (cred.error && !cred.error.includes("not configured")) {
920
+ this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: "credential_error", request_id: requestId, tier: this.currentTier, credential_ref: toolName });
921
+ if (this.config.enforce) {
922
+ return this.makeErrorResponse(request.id, -32600, `Credential error for tool "${toolName}"`);
923
+ }
924
+ }
925
+ }
926
+ if (this.config.policy?.external && (this.config.policy.policy_engine === "external" || this.config.policy.policy_engine === "hybrid")) {
927
+ try {
928
+ const ctx = buildDecisionContext(toolName, this.currentTier, {
929
+ agentId: this.admissionResult?.agent_id,
930
+ manifestHash: this.admissionResult?.manifest_hash,
931
+ credentialRef,
932
+ mode,
933
+ slug: this.config.slug
934
+ });
935
+ const externalDecision = await queryExternalPDP(ctx, this.config.policy.external);
936
+ if (!externalDecision.allowed) {
937
+ const reason = `external_pdp_deny${externalDecision.reason ? ": " + externalDecision.reason : ""}`;
938
+ this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: reason, request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
939
+ if (this.config.enforce) {
940
+ return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" denied by external policy engine`);
941
+ }
942
+ if (this.config.policy.policy_engine === "external") return null;
943
+ }
944
+ } catch (err) {
945
+ if (this.config.verbose) this.log(`External PDP error: ${err instanceof Error ? err.message : err}`);
946
+ }
947
+ }
948
+ if (toolPolicy.min_tier) {
949
+ if (!meetsMinTier(this.currentTier, toolPolicy.min_tier)) {
950
+ this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: "tier_insufficient", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
951
+ if (this.config.enforce) {
952
+ return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" requires tier "${toolPolicy.min_tier}"`);
953
+ }
954
+ return null;
955
+ }
956
+ }
957
+ if (toolPolicy.block) {
958
+ this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: "policy_block", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
959
+ if (this.config.enforce) {
960
+ return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" is blocked by policy`);
961
+ }
962
+ return null;
963
+ }
964
+ if (toolPolicy.require_approval) {
965
+ const grant = this.approvalStore.get(requestId);
966
+ const alwaysGrant = this.approvalStore.get(`always:${toolName}`);
967
+ if (grant && Date.now() < grant.expires_at || alwaysGrant && Date.now() < alwaysGrant.expires_at) {
968
+ if (grant && grant.mode === "once") this.approvalStore.delete(requestId);
969
+ this.emitDecisionLog({ tool: toolName, decision: "allow", reason_code: "approval_granted", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
970
+ return null;
971
+ }
972
+ this.emitDecisionLog({ tool: toolName, decision: "require_approval", reason_code: "requires_human_approval", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
973
+ if (this.config.enforce) {
974
+ return {
975
+ jsonrpc: "2.0",
976
+ id: request.id,
977
+ result: {
978
+ content: [
979
+ {
980
+ type: "text",
981
+ 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.`
982
+ }
983
+ ],
984
+ isError: true
985
+ }
986
+ };
987
+ }
988
+ return null;
989
+ }
990
+ const rateSpec = this.getTierRateLimit(toolPolicy, this.currentTier);
991
+ if (rateSpec) {
992
+ try {
993
+ const limit = parseRateLimit(rateSpec);
994
+ const key = `tool:${toolName}:${this.currentTier}`;
995
+ const { allowed, remaining } = checkRateLimit(key, limit, this.rateLimitStore);
996
+ if (!allowed) {
997
+ this.emitDecisionLog({ tool: toolName, decision: "deny", reason_code: "rate_limit_exceeded", request_id: requestId, rate_limit_remaining: 0, tier: this.currentTier, credential_ref: credentialRef });
998
+ if (this.config.enforce) {
999
+ return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" rate limit exceeded (${rateSpec})`);
1000
+ }
1001
+ return null;
1002
+ }
1003
+ this.emitDecisionLog({ tool: toolName, decision: "allow", reason_code: "policy_allow", request_id: requestId, rate_limit_remaining: remaining, tier: this.currentTier, credential_ref: credentialRef });
1004
+ } catch {
1005
+ this.emitDecisionLog({ tool: toolName, decision: "allow", reason_code: "default_allow", request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
1006
+ }
1007
+ } else {
1008
+ const reasonCode = this.config.enforce ? "policy_allow" : "observe_mode";
1009
+ this.emitDecisionLog({ tool: toolName, decision: "allow", reason_code: reasonCode, request_id: requestId, tier: this.currentTier, credential_ref: credentialRef });
1010
+ }
1011
+ return null;
1012
+ }
1013
+ getTierRateLimit(policy, tier) {
1014
+ if (policy.rate_limits && policy.rate_limits[tier]) {
1015
+ const tierLimit = policy.rate_limits[tier];
1016
+ return `${tierLimit.max}/${tierLimit.window}`;
1017
+ }
1018
+ return policy.rate_limit;
1019
+ }
1020
+ emitDecisionLog(entry) {
1021
+ const mode = this.config.enforce ? "enforce" : "shadow";
1022
+ const log = {
1023
+ v: 2,
1024
+ tool: entry.tool || "unknown",
1025
+ decision: entry.decision || "allow",
1026
+ reason_code: entry.reason_code || "default_allow",
1027
+ policy_digest: this.config.policyDigest,
1028
+ policy_engine: this.config.policy?.policy_engine || "built-in",
1029
+ request_id: entry.request_id || randomUUID().slice(0, 12),
1030
+ timestamp: Date.now(),
1031
+ mode,
1032
+ ...entry.rate_limit_remaining !== void 0 && { rate_limit_remaining: entry.rate_limit_remaining },
1033
+ ...entry.tier && { tier: entry.tier },
1034
+ ...entry.credential_ref && { credential_ref: entry.credential_ref }
1035
+ };
1036
+ process.stderr.write(`[PROTECT_MCP] ${JSON.stringify(log)}
1037
+ `);
1038
+ try {
1039
+ appendFileSync(this.logFilePath, JSON.stringify(log) + "\n");
1040
+ } catch {
1041
+ }
1042
+ if (isSigningEnabled()) {
1043
+ const signed = signDecision(log);
1044
+ if (signed.signed) {
1045
+ process.stderr.write(`[PROTECT_MCP_RECEIPT] ${signed.signed}
1046
+ `);
1047
+ try {
1048
+ appendFileSync(this.receiptFilePath, signed.signed + "\n");
1049
+ } catch {
1050
+ }
1051
+ this.receiptBuffer.add(log.request_id, signed.signed);
1052
+ if (this.admissionResult?.agent_id) {
1053
+ this.evidenceStore.record(this.admissionResult.agent_id, this.config.signing?.issuer || "protect-mcp");
1054
+ if (this.evidenceStore.getSummary(this.admissionResult.agent_id).receipt_count % 10 === 0) {
1055
+ this.evidenceStore.save();
1056
+ }
1057
+ }
1058
+ } else if (signed.warning) {
1059
+ process.stderr.write(`[PROTECT_MCP] Warning: ${signed.warning}
1060
+ `);
1061
+ }
1062
+ }
1063
+ }
1064
+ makeErrorResponse(id, code, message) {
1065
+ return { jsonrpc: "2.0", id, error: { code, message } };
1066
+ }
1067
+ sendToChild(message) {
1068
+ if (this.child?.stdin?.writable) this.child.stdin.write(message + "\n");
1069
+ }
1070
+ sendToClient(message) {
1071
+ process.stdout.write(message + "\n");
1072
+ }
1073
+ log(message) {
1074
+ process.stderr.write(`[PROTECT_MCP] ${message}
1075
+ `);
1076
+ }
1077
+ stop() {
1078
+ this.evidenceStore.save();
1079
+ if (this.clientReader) this.clientReader.close();
1080
+ if (this.child) {
1081
+ this.child.kill("SIGTERM");
1082
+ this.child = null;
1083
+ }
1084
+ process.exit(0);
1085
+ }
1086
+ };
1087
+
1088
+ export {
1089
+ loadPolicy,
1090
+ getToolPolicy,
1091
+ parseRateLimit,
1092
+ checkRateLimit,
1093
+ evaluateTier,
1094
+ meetsMinTier,
1095
+ resolveCredential,
1096
+ listCredentialLabels,
1097
+ validateCredentials,
1098
+ initSigning,
1099
+ signDecision,
1100
+ getSignerInfo,
1101
+ isSigningEnabled,
1102
+ queryExternalPDP,
1103
+ buildDecisionContext,
1104
+ ProtectGateway
1105
+ };