protect-mcp 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-3WCA7O4D.mjs +977 -0
- package/dist/cli.js +760 -163
- package/dist/cli.mjs +241 -5
- package/dist/demo-server.d.mts +1 -0
- package/dist/demo-server.d.ts +1 -0
- package/dist/demo-server.js +137 -0
- package/dist/demo-server.mjs +136 -0
- package/dist/index.d.mts +75 -60
- package/dist/index.d.ts +75 -60
- package/dist/index.js +507 -269
- package/dist/index.mjs +3 -123
- package/package.json +4 -4
- package/dist/chunk-ZCKNFULF.mjs +0 -613
package/dist/chunk-ZCKNFULF.mjs
DELETED
|
@@ -1,613 +0,0 @@
|
|
|
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
|
-
};
|