protect-mcp 0.1.1 → 0.2.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,613 @@
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/admission.ts
78
+ function evaluateTier(manifest, overrides) {
79
+ if (!manifest) {
80
+ return {
81
+ tier: "unknown",
82
+ reason: "no_manifest_presented"
83
+ };
84
+ }
85
+ if (overrides && manifest.agent_id && overrides[manifest.agent_id]) {
86
+ return {
87
+ tier: overrides[manifest.agent_id],
88
+ agent_id: manifest.agent_id,
89
+ manifest_hash: manifest.manifest_hash,
90
+ reason: "operator_override"
91
+ };
92
+ }
93
+ if (manifest.signature_valid === false) {
94
+ return {
95
+ tier: "unknown",
96
+ agent_id: manifest.agent_id,
97
+ manifest_hash: manifest.manifest_hash,
98
+ reason: "invalid_manifest_signature"
99
+ };
100
+ }
101
+ if (manifest.signature_valid === true) {
102
+ if (manifest.evidence_summary) {
103
+ const es = manifest.evidence_summary;
104
+ if (es.receipt_count >= 10 && es.epoch_span >= 3 && es.issuer_count >= 2) {
105
+ return {
106
+ tier: "evidenced",
107
+ agent_id: manifest.agent_id,
108
+ manifest_hash: manifest.manifest_hash,
109
+ reason: "evidence_threshold_met"
110
+ };
111
+ }
112
+ }
113
+ return {
114
+ tier: "signed-known",
115
+ agent_id: manifest.agent_id,
116
+ manifest_hash: manifest.manifest_hash,
117
+ reason: "valid_signed_manifest"
118
+ };
119
+ }
120
+ return {
121
+ tier: "unknown",
122
+ agent_id: manifest.agent_id,
123
+ manifest_hash: manifest.manifest_hash,
124
+ reason: "manifest_unverified"
125
+ };
126
+ }
127
+ function meetsMinTier(actual, required) {
128
+ const order = ["unknown", "signed-known", "evidenced", "privileged"];
129
+ return order.indexOf(actual) >= order.indexOf(required);
130
+ }
131
+
132
+ // src/credentials.ts
133
+ function resolveCredential(label, credentials) {
134
+ if (!credentials || !credentials[label]) {
135
+ return {
136
+ resolved: false,
137
+ label,
138
+ error: `credential "${label}" not configured`
139
+ };
140
+ }
141
+ const config = credentials[label];
142
+ const value = process.env[config.value_env];
143
+ if (!value) {
144
+ return {
145
+ resolved: false,
146
+ label,
147
+ error: `environment variable "${config.value_env}" for credential "${label}" is not set`
148
+ };
149
+ }
150
+ return {
151
+ resolved: true,
152
+ label,
153
+ value,
154
+ inject: config.inject,
155
+ name: config.name
156
+ };
157
+ }
158
+ function listCredentialLabels(credentials) {
159
+ if (!credentials) return [];
160
+ return Object.keys(credentials);
161
+ }
162
+ function validateCredentials(credentials) {
163
+ const warnings = [];
164
+ if (!credentials) return warnings;
165
+ for (const [label, config] of Object.entries(credentials)) {
166
+ if (!config.value_env) {
167
+ warnings.push(`credential "${label}": missing value_env`);
168
+ continue;
169
+ }
170
+ if (!config.inject) {
171
+ warnings.push(`credential "${label}": missing inject type`);
172
+ continue;
173
+ }
174
+ if (!process.env[config.value_env]) {
175
+ warnings.push(`credential "${label}": env var "${config.value_env}" not set`);
176
+ }
177
+ }
178
+ return warnings;
179
+ }
180
+
181
+ // src/signing.ts
182
+ import { readFileSync as readFileSync2, existsSync } from "fs";
183
+ var signerState = null;
184
+ var artifactsModule = null;
185
+ async function initSigning(config) {
186
+ const warnings = [];
187
+ if (!config || config.enabled === false) {
188
+ return warnings;
189
+ }
190
+ try {
191
+ artifactsModule = await import("@veritasacta/artifacts");
192
+ } catch {
193
+ warnings.push("signing: @veritasacta/artifacts not available \u2014 receipts will be unsigned");
194
+ return warnings;
195
+ }
196
+ if (config.key_path) {
197
+ if (!existsSync(config.key_path)) {
198
+ warnings.push(`signing: key file not found at ${config.key_path} \u2014 run "protect-mcp init" to generate`);
199
+ return warnings;
200
+ }
201
+ try {
202
+ const keyData = JSON.parse(readFileSync2(config.key_path, "utf-8"));
203
+ if (!keyData.privateKey || !keyData.publicKey) {
204
+ warnings.push("signing: key file missing privateKey or publicKey fields");
205
+ return warnings;
206
+ }
207
+ signerState = {
208
+ privateKey: keyData.privateKey,
209
+ publicKey: keyData.publicKey,
210
+ kid: artifactsModule.computeKid(keyData.publicKey),
211
+ issuer: config.issuer || "protect-mcp"
212
+ };
213
+ } catch (err) {
214
+ warnings.push(`signing: failed to load key file: ${err instanceof Error ? err.message : err}`);
215
+ }
216
+ }
217
+ return warnings;
218
+ }
219
+ function signDecision(entry) {
220
+ if (!signerState || !artifactsModule) {
221
+ return { signed: null, artifact_type: "none" };
222
+ }
223
+ const artifactType = entry.decision === "deny" ? "gateway_restraint" : "decision_receipt";
224
+ try {
225
+ const payload = {
226
+ tool: entry.tool,
227
+ decision: entry.decision,
228
+ reason_code: entry.reason_code,
229
+ policy_digest: entry.policy_digest,
230
+ scope: entry.request_id,
231
+ // request scope
232
+ mode: entry.mode,
233
+ request_id: entry.request_id
234
+ };
235
+ if (entry.tier) payload.tier = entry.tier;
236
+ if (entry.credential_ref) payload.credential_ref = entry.credential_ref;
237
+ if (entry.rate_limit_remaining !== void 0) {
238
+ payload.rate_limit_remaining = entry.rate_limit_remaining;
239
+ }
240
+ if (entry.policy_engine) payload.policy_engine = entry.policy_engine;
241
+ const result = artifactsModule.createSignedArtifact(
242
+ artifactType,
243
+ payload,
244
+ signerState.privateKey,
245
+ {
246
+ kid: signerState.kid,
247
+ issuer: signerState.issuer
248
+ }
249
+ );
250
+ return {
251
+ signed: JSON.stringify(result.artifact),
252
+ artifact_type: artifactType
253
+ };
254
+ } catch (err) {
255
+ return {
256
+ signed: null,
257
+ artifact_type: artifactType,
258
+ warning: `signing failed: ${err instanceof Error ? err.message : "unknown error"}`
259
+ };
260
+ }
261
+ }
262
+ function getSignerInfo() {
263
+ if (!signerState) return null;
264
+ return {
265
+ publicKey: signerState.publicKey,
266
+ kid: signerState.kid,
267
+ issuer: signerState.issuer
268
+ };
269
+ }
270
+ function isSigningEnabled() {
271
+ return signerState !== null && artifactsModule !== null;
272
+ }
273
+
274
+ // src/gateway.ts
275
+ import { spawn } from "child_process";
276
+ import { randomUUID } from "crypto";
277
+ import { createInterface } from "readline";
278
+ var ProtectGateway = class {
279
+ child = null;
280
+ config;
281
+ rateLimitStore = /* @__PURE__ */ new Map();
282
+ clientReader = null;
283
+ // Trust-tier state for the current session
284
+ currentTier = "unknown";
285
+ admissionResult = null;
286
+ constructor(config) {
287
+ this.config = config;
288
+ }
289
+ /**
290
+ * Start the gateway: spawn child process and wire up message relay.
291
+ */
292
+ async start() {
293
+ const { command, args, verbose } = this.config;
294
+ const mode = this.config.enforce ? "enforce" : "shadow";
295
+ if (verbose) {
296
+ this.log(`Starting gateway in ${mode} mode`);
297
+ this.log(`Wrapping: ${command} ${args.join(" ")}`);
298
+ if (this.config.policy) {
299
+ this.log(`Policy digest: ${this.config.policyDigest}`);
300
+ }
301
+ if (isSigningEnabled()) {
302
+ this.log("Signing: enabled (receipts will be signed)");
303
+ }
304
+ if (this.config.credentials) {
305
+ const labels = Object.keys(this.config.credentials);
306
+ this.log(`Credential vault: ${labels.length} credential(s) configured [${labels.join(", ")}]`);
307
+ }
308
+ }
309
+ this.child = spawn(command, args, {
310
+ stdio: ["pipe", "pipe", "pipe"],
311
+ env: { ...process.env }
312
+ });
313
+ if (!this.child.stdin || !this.child.stdout || !this.child.stderr) {
314
+ throw new Error("Failed to create pipes to child process");
315
+ }
316
+ this.child.stderr.on("data", (data) => {
317
+ process.stderr.write(data);
318
+ });
319
+ const childReader = createInterface({ input: this.child.stdout, crlfDelay: Infinity });
320
+ childReader.on("line", (line) => {
321
+ this.handleServerMessage(line);
322
+ });
323
+ this.clientReader = createInterface({ input: process.stdin, crlfDelay: Infinity });
324
+ this.clientReader.on("line", (line) => {
325
+ this.handleClientMessage(line);
326
+ });
327
+ this.child.on("exit", (code, signal) => {
328
+ if (this.config.verbose) {
329
+ this.log(`Child process exited (code=${code}, signal=${signal})`);
330
+ }
331
+ process.exit(code ?? 1);
332
+ });
333
+ this.child.on("error", (err) => {
334
+ this.log(`Child process error: ${err.message}`);
335
+ process.exit(1);
336
+ });
337
+ process.on("SIGINT", () => this.stop());
338
+ process.on("SIGTERM", () => this.stop());
339
+ process.stdin.on("end", () => {
340
+ if (this.config.verbose) {
341
+ this.log("Client stdin closed, closing child stdin");
342
+ }
343
+ if (this.child?.stdin?.writable) {
344
+ this.child.stdin.end();
345
+ }
346
+ });
347
+ }
348
+ /**
349
+ * Set the trust tier for this session.
350
+ * Called at admission (first interaction) or by explicit manifest presentation.
351
+ */
352
+ setManifest(manifest) {
353
+ this.admissionResult = evaluateTier(manifest);
354
+ this.currentTier = this.admissionResult.tier;
355
+ if (this.config.verbose) {
356
+ this.log(`Admission: tier=${this.currentTier} agent=${this.admissionResult.agent_id || "none"} reason=${this.admissionResult.reason}`);
357
+ }
358
+ return this.admissionResult;
359
+ }
360
+ /**
361
+ * Handle a message from the MCP client (stdin).
362
+ * Intercept tools/call requests; pass through everything else.
363
+ */
364
+ handleClientMessage(raw) {
365
+ const trimmed = raw.trim();
366
+ if (!trimmed) return;
367
+ let message;
368
+ try {
369
+ message = JSON.parse(trimmed);
370
+ } catch {
371
+ this.sendToChild(trimmed);
372
+ return;
373
+ }
374
+ if (message.method === "tools/call" && message.id !== void 0) {
375
+ const result = this.interceptToolCall(message);
376
+ if (result) {
377
+ this.sendToClient(JSON.stringify(result));
378
+ return;
379
+ }
380
+ }
381
+ this.sendToChild(trimmed);
382
+ }
383
+ /**
384
+ * Handle a message from the wrapped MCP server (child stdout).
385
+ * Forward to client (stdout) transparently.
386
+ */
387
+ handleServerMessage(raw) {
388
+ this.sendToClient(raw);
389
+ }
390
+ /**
391
+ * Intercept a tools/call request. Returns a JSON-RPC error response if denied, null if allowed.
392
+ */
393
+ interceptToolCall(request) {
394
+ const toolName = request.params?.name || "unknown";
395
+ const requestId = randomUUID().slice(0, 12);
396
+ const toolPolicy = getToolPolicy(toolName, this.config.policy);
397
+ const mode = this.config.enforce ? "enforce" : "shadow";
398
+ let credentialRef;
399
+ if (this.config.credentials) {
400
+ const cred = resolveCredential(toolName, this.config.credentials);
401
+ if (cred.resolved) {
402
+ credentialRef = cred.label;
403
+ } else if (cred.error && !cred.error.includes("not configured")) {
404
+ this.emitDecisionLog({
405
+ tool: toolName,
406
+ decision: "deny",
407
+ reason_code: "policy_block",
408
+ request_id: requestId,
409
+ credential_ref: toolName,
410
+ tier: this.currentTier
411
+ });
412
+ if (this.config.enforce) {
413
+ this.log(`Credential error for "${toolName}": ${cred.error}`);
414
+ return this.makeErrorResponse(request.id, -32600, `Credential error for tool "${toolName}"`);
415
+ }
416
+ }
417
+ }
418
+ if (toolPolicy.min_tier) {
419
+ if (!meetsMinTier(this.currentTier, toolPolicy.min_tier)) {
420
+ this.emitDecisionLog({
421
+ tool: toolName,
422
+ decision: "deny",
423
+ reason_code: "tier_insufficient",
424
+ request_id: requestId,
425
+ tier: this.currentTier,
426
+ credential_ref: credentialRef
427
+ });
428
+ if (this.config.enforce) {
429
+ return this.makeErrorResponse(
430
+ request.id,
431
+ -32600,
432
+ `Tool "${toolName}" requires tier "${toolPolicy.min_tier}", agent has "${this.currentTier}"`
433
+ );
434
+ }
435
+ return null;
436
+ }
437
+ }
438
+ if (toolPolicy.block) {
439
+ this.emitDecisionLog({
440
+ tool: toolName,
441
+ decision: "deny",
442
+ reason_code: "policy_block",
443
+ request_id: requestId,
444
+ tier: this.currentTier,
445
+ credential_ref: credentialRef
446
+ });
447
+ if (this.config.enforce) {
448
+ return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" is blocked by policy`);
449
+ }
450
+ return null;
451
+ }
452
+ const rateSpec = this.getTierRateLimit(toolPolicy, this.currentTier);
453
+ if (rateSpec) {
454
+ try {
455
+ const limit = parseRateLimit(rateSpec);
456
+ const key = `tool:${toolName}:${this.currentTier}`;
457
+ const { allowed, remaining } = checkRateLimit(key, limit, this.rateLimitStore);
458
+ if (!allowed) {
459
+ this.emitDecisionLog({
460
+ tool: toolName,
461
+ decision: "deny",
462
+ reason_code: "rate_limit_exceeded",
463
+ request_id: requestId,
464
+ rate_limit_remaining: 0,
465
+ tier: this.currentTier,
466
+ credential_ref: credentialRef
467
+ });
468
+ if (this.config.enforce) {
469
+ return this.makeErrorResponse(
470
+ request.id,
471
+ -32600,
472
+ `Tool "${toolName}" rate limit exceeded (${rateSpec})`
473
+ );
474
+ }
475
+ return null;
476
+ }
477
+ this.emitDecisionLog({
478
+ tool: toolName,
479
+ decision: "allow",
480
+ reason_code: "policy_allow",
481
+ request_id: requestId,
482
+ rate_limit_remaining: remaining,
483
+ tier: this.currentTier,
484
+ credential_ref: credentialRef
485
+ });
486
+ } catch {
487
+ this.emitDecisionLog({
488
+ tool: toolName,
489
+ decision: "allow",
490
+ reason_code: "default_allow",
491
+ request_id: requestId,
492
+ tier: this.currentTier,
493
+ credential_ref: credentialRef
494
+ });
495
+ }
496
+ } else {
497
+ const reasonCode = this.config.enforce ? "policy_allow" : "observe_mode";
498
+ this.emitDecisionLog({
499
+ tool: toolName,
500
+ decision: "allow",
501
+ reason_code: reasonCode,
502
+ request_id: requestId,
503
+ tier: this.currentTier,
504
+ credential_ref: credentialRef
505
+ });
506
+ }
507
+ return null;
508
+ }
509
+ /**
510
+ * Get the applicable rate limit spec based on the agent's tier.
511
+ */
512
+ getTierRateLimit(policy, tier) {
513
+ if (policy.rate_limits && policy.rate_limits[tier]) {
514
+ const tierLimit = policy.rate_limits[tier];
515
+ return `${tierLimit.max}/${tierLimit.window}`;
516
+ }
517
+ return policy.rate_limit;
518
+ }
519
+ /**
520
+ * Emit a structured decision log to stderr.
521
+ * If signing is enabled, also emits a signed artifact.
522
+ */
523
+ emitDecisionLog(entry) {
524
+ const mode = this.config.enforce ? "enforce" : "shadow";
525
+ const log = {
526
+ v: 2,
527
+ tool: entry.tool || "unknown",
528
+ decision: entry.decision || "allow",
529
+ reason_code: entry.reason_code || "default_allow",
530
+ policy_digest: this.config.policyDigest,
531
+ policy_engine: this.config.policy?.policy_engine || "built-in",
532
+ request_id: entry.request_id || randomUUID().slice(0, 12),
533
+ timestamp: Date.now(),
534
+ mode,
535
+ ...entry.rate_limit_remaining !== void 0 && { rate_limit_remaining: entry.rate_limit_remaining },
536
+ ...entry.tier && { tier: entry.tier },
537
+ ...entry.credential_ref && { credential_ref: entry.credential_ref }
538
+ };
539
+ process.stderr.write(`[PROTECT_MCP] ${JSON.stringify(log)}
540
+ `);
541
+ if (isSigningEnabled()) {
542
+ const signed = signDecision(log);
543
+ if (signed.signed) {
544
+ process.stderr.write(`[PROTECT_MCP_RECEIPT] ${signed.signed}
545
+ `);
546
+ } else if (signed.warning) {
547
+ process.stderr.write(`[PROTECT_MCP] Warning: ${signed.warning}
548
+ `);
549
+ }
550
+ }
551
+ }
552
+ /**
553
+ * Create a JSON-RPC error response.
554
+ */
555
+ makeErrorResponse(id, code, message) {
556
+ return {
557
+ jsonrpc: "2.0",
558
+ id,
559
+ error: { code, message }
560
+ };
561
+ }
562
+ /**
563
+ * Send a message to the child process (wrapped MCP server).
564
+ */
565
+ sendToChild(message) {
566
+ if (this.child?.stdin?.writable) {
567
+ this.child.stdin.write(message + "\n");
568
+ }
569
+ }
570
+ /**
571
+ * Send a message to the MCP client (stdout).
572
+ */
573
+ sendToClient(message) {
574
+ process.stdout.write(message + "\n");
575
+ }
576
+ /**
577
+ * Log a message to stderr (debug output).
578
+ */
579
+ log(message) {
580
+ process.stderr.write(`[PROTECT_MCP] ${message}
581
+ `);
582
+ }
583
+ /**
584
+ * Stop the gateway: kill child process and exit.
585
+ */
586
+ stop() {
587
+ if (this.clientReader) {
588
+ this.clientReader.close();
589
+ }
590
+ if (this.child) {
591
+ this.child.kill("SIGTERM");
592
+ this.child = null;
593
+ }
594
+ process.exit(0);
595
+ }
596
+ };
597
+
598
+ export {
599
+ loadPolicy,
600
+ getToolPolicy,
601
+ parseRateLimit,
602
+ checkRateLimit,
603
+ evaluateTier,
604
+ meetsMinTier,
605
+ resolveCredential,
606
+ listCredentialLabels,
607
+ validateCredentials,
608
+ initSigning,
609
+ signDecision,
610
+ getSignerInfo,
611
+ isSigningEnabled,
612
+ ProtectGateway
613
+ };