agentgate-runtime-control 2.13.8
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/CHANGELOG.md +229 -0
- package/LICENSE +21 -0
- package/README.md +534 -0
- package/SECURITY.md +19 -0
- package/bin/agentgate.js +303 -0
- package/docs/case-study-technical-validation.md +25 -0
- package/docs/case-study-template.md +37 -0
- package/docs/data-protection.md +49 -0
- package/docs/design-partner-checklist.md +32 -0
- package/docs/design-partner-kit.md +51 -0
- package/docs/design-partner-rollout.md +35 -0
- package/docs/design-partner.md +67 -0
- package/docs/external-security-review-test-pack.md +132 -0
- package/docs/external-security-review.md +33 -0
- package/docs/incident-response.md +54 -0
- package/docs/integration-matrix.md +17 -0
- package/docs/managed-postgres-acceptance-test.md +138 -0
- package/docs/marketing-plan.md +33 -0
- package/docs/observability-alerting.md +44 -0
- package/docs/outreach.md +26 -0
- package/docs/partner-intake-template.md +26 -0
- package/docs/performance-baseline.md +23 -0
- package/docs/performance.md +27 -0
- package/docs/pricing.md +53 -0
- package/docs/production-deployment.md +70 -0
- package/docs/production-quickstart.md +58 -0
- package/docs/production-readiness.md +29 -0
- package/docs/quickstart.md +115 -0
- package/docs/release-checklist.md +33 -0
- package/docs/security-hardening-release-report.md +69 -0
- package/docs/threat-model.md +47 -0
- package/docs/website-copy.md +44 -0
- package/examples/basic.mjs +14 -0
- package/examples/control-plane.mjs +17 -0
- package/examples/design-partner-refund.mjs +33 -0
- package/examples/design-partner-shadow.mjs +27 -0
- package/examples/mcp-gateway.mjs +26 -0
- package/examples/policy-bundle.mjs +18 -0
- package/examples/refund-agent.mjs +20 -0
- package/examples/runtime.mjs +12 -0
- package/package.json +49 -0
- package/schema/postgres.sql +17 -0
- package/src/admin-rbac.js +3 -0
- package/src/agentgate.js +85 -0
- package/src/approval.js +30 -0
- package/src/attack-lab.js +94 -0
- package/src/auth.js +27 -0
- package/src/behavior.js +146 -0
- package/src/control-plane.js +215 -0
- package/src/egress-guard.js +132 -0
- package/src/event-bus.js +10 -0
- package/src/identity.js +109 -0
- package/src/index.js +44 -0
- package/src/local-experience.js +46 -0
- package/src/mcp-gateway.js +383 -0
- package/src/mcp-scanner.js +45 -0
- package/src/middleware.js +17 -0
- package/src/multi-tenant.js +29 -0
- package/src/observability.js +395 -0
- package/src/oidc.js +38 -0
- package/src/persistent-store.js +56 -0
- package/src/policy-builder.js +74 -0
- package/src/policy-bundles.js +17 -0
- package/src/policy-engine.js +67 -0
- package/src/policy-packs.js +115 -0
- package/src/policy-registry.js +58 -0
- package/src/postgres-adapter.js +76 -0
- package/src/runtime.js +138 -0
- package/src/saas.js +67 -0
- package/src/security-report.js +42 -0
- package/src/security-validation.js +92 -0
- package/src/shadow-mode.js +47 -0
- package/src/telemetry.js +28 -0
- package/src/webhook-delivery.js +70 -0
- package/standalone.html +86 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { evaluate } from './policy-engine.js';
|
|
5
|
+
import { createApprovalStore, createApprovalRequest, APPROVAL_STATUS } from './approval.js';
|
|
6
|
+
import { createPersistentRunStore, createPersistentApprovalStore } from './persistent-store.js';
|
|
7
|
+
import { createEventBus } from './event-bus.js';
|
|
8
|
+
import { createTelemetry } from './telemetry.js';
|
|
9
|
+
import { createEgressGuard } from './egress-guard.js';
|
|
10
|
+
import { createObservability, calculateCost, COST_DECISIONS } from './observability.js';
|
|
11
|
+
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const PACKAGE_VERSION = require('../package.json').version;
|
|
14
|
+
|
|
15
|
+
export const MCP_PROTOCOL_VERSION = '2025-06-18';
|
|
16
|
+
|
|
17
|
+
function rpcResult(id, result) {
|
|
18
|
+
return { jsonrpc: '2.0', id, result };
|
|
19
|
+
}
|
|
20
|
+
function rpcError(id, code, message, data) {
|
|
21
|
+
const error = { code, message };
|
|
22
|
+
if (data !== undefined) error.data = data;
|
|
23
|
+
return { jsonrpc: '2.0', id, error };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createMCPGateway(options = {}) {
|
|
27
|
+
const tools = new Map();
|
|
28
|
+
const policies = options.policies || {};
|
|
29
|
+
let policyResolverRef = typeof options.policyResolver === 'function' ? options.policyResolver : null;
|
|
30
|
+
const mode = options.mode === 'observe' ? 'observe' : 'enforce';
|
|
31
|
+
let killSwitch = Boolean(options.killSwitch);
|
|
32
|
+
const killReason = { value: options.killReason || 'Emergency security lock' };
|
|
33
|
+
const maxRuns = Number(options.maxRuns || 25000);
|
|
34
|
+
let retentionWarned = false;
|
|
35
|
+
const runStore = options.runStore || (options.persistence ? createPersistentRunStore({ filePath: options.runPersistence || `${options.persistence}/runs.json`, limit: maxRuns }) : null);
|
|
36
|
+
const runs = runStore ? null : [];
|
|
37
|
+
const approvals = createApprovalStore({ store: options.approvalStore || (options.persistence ? createPersistentApprovalStore({ filePath: options.approvalPersistence || `${options.persistence}/approvals.json`, limit: options.maxApprovals || maxRuns }) : undefined), limit: options.maxApprovals || maxRuns });
|
|
38
|
+
const eventBus = options.eventBus || createEventBus();
|
|
39
|
+
const telemetry = options.telemetry || createTelemetry();
|
|
40
|
+
const egressGuard = options.egressGuard || (options.egress ? createEgressGuard(options.egress === true ? {} : options.egress) : null);
|
|
41
|
+
const observability = options.observability || createObservability({ pricing: options.pricing || {}, budgets: options.budgets || options.costBudgets || {} });
|
|
42
|
+
const serverInfo = {
|
|
43
|
+
name: options.name || 'AgentGate Gateway',
|
|
44
|
+
version: options.version || PACKAGE_VERSION
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const api = {
|
|
48
|
+
mode,
|
|
49
|
+
policies,
|
|
50
|
+
registerTool,
|
|
51
|
+
handle,
|
|
52
|
+
tools: () => [...tools.keys()],
|
|
53
|
+
runs: (tenantId) => { const all = runStore ? runStore.list() : [...runs]; return tenantId ? all.filter(run => run.tenantId === tenantId) : all; },
|
|
54
|
+
replay: (runId, tenantId) => { const run = runStore ? runStore.get(runId) : (runs.find((item) => item.id === runId) || null); return !tenantId || run?.tenantId === tenantId ? run : null; },
|
|
55
|
+
approvals: (status, tenantId) => { const all = approvals.list(status); return tenantId ? all.filter(item => item.tenantId === tenantId) : all; },
|
|
56
|
+
getApproval: (approvalId, tenantId) => { const item = approvals.get(approvalId); return !tenantId || item?.tenantId === tenantId ? item : null; },
|
|
57
|
+
approve: (approvalId, tenantId) => resolveApproval(approvalId, true, undefined, tenantId),
|
|
58
|
+
deny: (approvalId, reason, tenantId) => resolveApproval(approvalId, false, reason, tenantId),
|
|
59
|
+
events: eventBus,
|
|
60
|
+
telemetry,
|
|
61
|
+
observability,
|
|
62
|
+
setPolicyResolver(resolver) { policyResolverRef = typeof resolver === 'function' ? resolver : null; return api; },
|
|
63
|
+
kill(reason = 'Emergency security lock') { killSwitch = true; killReason.value = String(reason); eventBus.publish('gateway.killed', { reason: killReason.value }); return { killed:true, reason:killReason.value }; },
|
|
64
|
+
unkill() { killSwitch = false; eventBus.publish('gateway.unkilled', {}); return { killed:false }; },
|
|
65
|
+
isKilled() { return killSwitch; },
|
|
66
|
+
killStatus() { return { killed:killSwitch, reason:killReason.value }; },
|
|
67
|
+
egressGuard,
|
|
68
|
+
retention: () => {
|
|
69
|
+
const count = runStore ? runStore.list().length : runs.length;
|
|
70
|
+
const nearLimit = count >= Math.max(1, Math.ceil(maxRuns * 0.9));
|
|
71
|
+
return { count, limit: maxRuns, nearLimit, persistent: Boolean(runStore), truncated: count >= maxRuns };
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
for (const tool of options.tools || []) registerTool(tool);
|
|
76
|
+
|
|
77
|
+
function registerTool(tool) {
|
|
78
|
+
if (!tool?.name || typeof tool.handler !== 'function') {
|
|
79
|
+
throw new TypeError('registerTool() expects { name, handler, description?, inputSchema? }');
|
|
80
|
+
}
|
|
81
|
+
tools.set(tool.name, {
|
|
82
|
+
name: tool.name,
|
|
83
|
+
description: tool.description || '',
|
|
84
|
+
inputSchema: tool.inputSchema || { type: 'object' },
|
|
85
|
+
handler: tool.handler
|
|
86
|
+
});
|
|
87
|
+
return api;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function record(event) {
|
|
91
|
+
if (runStore) {
|
|
92
|
+
const added = runStore.add(event);
|
|
93
|
+
const count = runStore.list().length;
|
|
94
|
+
if (!retentionWarned && count >= Math.max(1, Math.ceil(maxRuns * 0.9))) {
|
|
95
|
+
retentionWarned = true;
|
|
96
|
+
eventBus.publish('runs.retention.warning', { count, limit: maxRuns, persistent: true, message: 'Run history is approaching its retention limit; older runs may be evicted.' });
|
|
97
|
+
}
|
|
98
|
+
return added;
|
|
99
|
+
}
|
|
100
|
+
runs.push(event);
|
|
101
|
+
if (runs.length > maxRuns) runs.splice(0, runs.length - maxRuns);
|
|
102
|
+
const count = runs.length;
|
|
103
|
+
if (!retentionWarned && count >= Math.max(1, Math.ceil(maxRuns * 0.9))) {
|
|
104
|
+
retentionWarned = true;
|
|
105
|
+
eventBus.publish('runs.retention.warning', { count, limit: maxRuns, persistent: false, message: 'Run history is approaching its retention limit; older runs may be evicted.' });
|
|
106
|
+
}
|
|
107
|
+
return event;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function callTool(id, params = {}) {
|
|
111
|
+
const name = params.name;
|
|
112
|
+
const tool = tools.get(name);
|
|
113
|
+
if (!tool) return rpcError(id, -32602, `Unknown tool: ${name}`);
|
|
114
|
+
|
|
115
|
+
const input = params.arguments || {};
|
|
116
|
+
const request = {
|
|
117
|
+
...input,
|
|
118
|
+
tenantId: params.tenantId || params.context?.tenantId || options.tenantId || null,
|
|
119
|
+
agent: params.agent || options.agent || params.identity?.agentId || params.identity?.agent || 'unknown',
|
|
120
|
+
userId: params.userId || params.identity?.userId || params.identity?.user,
|
|
121
|
+
identity: params.identity || params.context?.identity || options.identity,
|
|
122
|
+
roles: params.roles || params.identity?.roles,
|
|
123
|
+
attributes: params.attributes || params.identity?.attributes,
|
|
124
|
+
resource: params.resource || input.resource,
|
|
125
|
+
environment: params.environment || input.environment || options.environment || 'production',
|
|
126
|
+
tool: name,
|
|
127
|
+
action: params.action || name,
|
|
128
|
+
model: params.model || params.context?.model || input.model,
|
|
129
|
+
usage: params.usage || params.context?.usage || input.usage,
|
|
130
|
+
estimatedCost: params.estimatedCost ?? params.context?.estimatedCost ?? input.estimatedCost,
|
|
131
|
+
context: params.context || {}
|
|
132
|
+
};
|
|
133
|
+
if (killSwitch && mode === 'enforce') {
|
|
134
|
+
const run = { id: randomUUID(), createdAt:new Date().toISOString(), tenantId:request.tenantId, request, decision:'BLOCK', risk:100, reason:killReason.value, mode, killed:true };
|
|
135
|
+
record(run); eventBus.publish('run.decided', run);
|
|
136
|
+
return rpcResult(id, { isError:true, content:[{type:'text',text:`AgentGate emergency stop: ${killReason.value}`}], _agentgate:{runId:run.id, decision:{decision:'BLOCK',risk:100,reason:killReason.value}, killed:true} });
|
|
137
|
+
}
|
|
138
|
+
const effectivePolicies = policyResolverRef ? (policyResolverRef(request) || {}) : policies;
|
|
139
|
+
const policyDecision = evaluate(request, effectivePolicies);
|
|
140
|
+
const usageCost = request.usage ? calculateCost({ model: request.model || request.usage?.model, usage: request.usage }, observability.pricing.list()) : { known: false, total: 0 };
|
|
141
|
+
const estimatedCost = Number(request.estimatedCost ?? usageCost.total ?? 0) || 0;
|
|
142
|
+
const history = runStore ? runStore.list() : [...runs];
|
|
143
|
+
const costDecision = estimatedCost > 0 ? observability.costControl.evaluate({ tenantId: request.tenantId, agent: request.agent, estimatedCost, history }) : { decision: COST_DECISIONS.ALLOW, reason: 'No estimated cost supplied' };
|
|
144
|
+
const decision = policyDecision.decision === 'BLOCK' || costDecision.decision === 'BLOCK'
|
|
145
|
+
? { ...policyDecision, decision: 'BLOCK', risk: Math.max(policyDecision.risk, costDecision.decision === 'BLOCK' ? 94 : 0), reason: policyDecision.decision === 'BLOCK' ? policyDecision.reason : costDecision.reason }
|
|
146
|
+
: policyDecision.decision === 'ASK' || costDecision.decision === 'ASK'
|
|
147
|
+
? { ...policyDecision, decision: 'ASK', risk: Math.max(policyDecision.risk, costDecision.decision === 'ASK' ? 72 : 0), reason: policyDecision.decision === 'ASK' ? policyDecision.reason : costDecision.reason }
|
|
148
|
+
: policyDecision;
|
|
149
|
+
const run = {
|
|
150
|
+
id: randomUUID(),
|
|
151
|
+
createdAt: new Date().toISOString(),
|
|
152
|
+
tenantId: request.tenantId,
|
|
153
|
+
request,
|
|
154
|
+
decision: decision.decision,
|
|
155
|
+
risk: decision.risk,
|
|
156
|
+
reason: decision.reason,
|
|
157
|
+
winningRule: decision.winningRule || null,
|
|
158
|
+
ruleTrace: decision.ruleTrace || [],
|
|
159
|
+
mode,
|
|
160
|
+
simulated: mode === 'observe' && decision.decision !== 'ALLOW',
|
|
161
|
+
cost: { ...usageCost, estimated: estimatedCost, budget: costDecision }
|
|
162
|
+
};
|
|
163
|
+
record(run);
|
|
164
|
+
observability.enrichRun(run);
|
|
165
|
+
runStore?.save?.();
|
|
166
|
+
telemetry.increment('runs.recorded');
|
|
167
|
+
telemetry.increment(`decisions.${decision.decision.toLowerCase()}`);
|
|
168
|
+
eventBus.publish('run.recorded', run);
|
|
169
|
+
|
|
170
|
+
if (mode === 'enforce' && decision.decision === 'BLOCK') {
|
|
171
|
+
eventBus.publish('run.decided', run);
|
|
172
|
+
return rpcResult(id, {
|
|
173
|
+
isError: true,
|
|
174
|
+
content: [{ type: 'text', text: `AgentGate blocked tool call: ${decision.reason}` }],
|
|
175
|
+
_agentgate: { runId: run.id, decision }
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
if (mode === 'enforce' && decision.decision === 'ASK') {
|
|
179
|
+
const approval = createApprovalRequest({
|
|
180
|
+
runId: run.id,
|
|
181
|
+
tenantId: request.tenantId,
|
|
182
|
+
request,
|
|
183
|
+
decision: decision.decision,
|
|
184
|
+
risk: decision.risk,
|
|
185
|
+
reason: decision.reason,
|
|
186
|
+
metadata: { tool: name, rpcRequestId: id, arguments: clone(input) }
|
|
187
|
+
}, approvals);
|
|
188
|
+
run.status = 'pending_approval';
|
|
189
|
+
eventBus.publish('approval.created', { runId: run.id, approvalId: approval.id, tenantId: run.tenantId });
|
|
190
|
+
run.approvalId = approval.id;
|
|
191
|
+
return rpcResult(id, {
|
|
192
|
+
isError: true,
|
|
193
|
+
content: [{ type: 'text', text: `AgentGate approval required: ${decision.reason}` }],
|
|
194
|
+
_agentgate: { runId: run.id, decision, approvalRequired: true, approvalId: approval.id, approval }
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const started = Date.now();
|
|
200
|
+
const value = await tool.handler(input, { request, decision, runId: run.id });
|
|
201
|
+
run.executed = true;
|
|
202
|
+
run.durationMs = Date.now() - started;
|
|
203
|
+
telemetry.observe('tool.execution', run.durationMs);
|
|
204
|
+
telemetry.increment('tools.executed');
|
|
205
|
+
observability.enrichRun(run);
|
|
206
|
+
let outputValue = value;
|
|
207
|
+
let egress = null;
|
|
208
|
+
if (egressGuard) {
|
|
209
|
+
egress = egressGuard.guard(value);
|
|
210
|
+
run.egress = { action: egress.action, findings: egress.findings, risk: egress.risk, reason: egress.reason };
|
|
211
|
+
telemetry.increment(`egress.${egress.action.toLowerCase()}`);
|
|
212
|
+
if (egress.action === 'BLOCK') {
|
|
213
|
+
run.egressBlocked = true;
|
|
214
|
+
runStore?.save?.();
|
|
215
|
+
eventBus.publish('egress.blocked', { runId: run.id, tenantId: run.tenantId, findings: egress.findings });
|
|
216
|
+
return rpcResult(id, {
|
|
217
|
+
isError: true,
|
|
218
|
+
content: [{ type: 'text', text: `AgentGate blocked data egress: ${egress.reason}` }],
|
|
219
|
+
_agentgate: { runId: run.id, decision, egress: { action: egress.action, findings: egress.findings, risk: egress.risk } }
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
outputValue = egress.value;
|
|
223
|
+
if (egress.action === 'REDACT') eventBus.publish('egress.redacted', { runId: run.id, tenantId: run.tenantId, findings: egress.findings });
|
|
224
|
+
}
|
|
225
|
+
run.valuePreview = typeof outputValue === 'string' ? outputValue.slice(0, 500) : outputValue;
|
|
226
|
+
runStore?.save?.();
|
|
227
|
+
return rpcResult(id, {
|
|
228
|
+
content: [{ type: 'text', text: typeof outputValue === 'string' ? outputValue : JSON.stringify(outputValue) }],
|
|
229
|
+
_agentgate: { runId: run.id, decision, simulated: run.simulated, ...(egress ? { egress: { action: egress.action, findings: egress.findings, risk: egress.risk } } : {}) }
|
|
230
|
+
});
|
|
231
|
+
} catch (error) {
|
|
232
|
+
run.executed = true;
|
|
233
|
+
run.error = error?.message || String(error);
|
|
234
|
+
run.durationMs = Number.isFinite(run.durationMs) ? run.durationMs : undefined;
|
|
235
|
+
observability.enrichRun(run);
|
|
236
|
+
runStore?.save?.();
|
|
237
|
+
telemetry.increment('tools.errors');
|
|
238
|
+
return rpcResult(id, {
|
|
239
|
+
isError: true,
|
|
240
|
+
content: [{ type: 'text', text: run.error }],
|
|
241
|
+
_agentgate: { runId: run.id, decision }
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
async function resolveApproval(approvalId, approved, denialReason, tenantId) {
|
|
248
|
+
const approval = approvals.get(approvalId);
|
|
249
|
+
if (tenantId && approval?.tenantId !== tenantId) return { ok: false, error: 'Approval not found' };
|
|
250
|
+
if (!approval) return { ok: false, error: 'Approval not found' };
|
|
251
|
+
if (approval.status !== APPROVAL_STATUS.PENDING) return { ok: false, error: `Approval is already ${approval.status}`, approval };
|
|
252
|
+
const run = (runStore ? runStore.get(approval.runId) : runs.find(item => item.id === approval.runId));
|
|
253
|
+
if (!run) return { ok: false, error: 'Run not found', approval };
|
|
254
|
+
if (!approved) {
|
|
255
|
+
approval.status = APPROVAL_STATUS.DENIED;
|
|
256
|
+
approval.resolvedAt = new Date().toISOString();
|
|
257
|
+
approval.resolutionReason = denialReason || 'Denied by approver';
|
|
258
|
+
run.status = 'denied';
|
|
259
|
+
run.approval = { status: approval.status, resolvedAt: approval.resolvedAt, reason: approval.resolutionReason };
|
|
260
|
+
eventBus.publish('approval.resolved', { approvalId, runId: run.id, status: approval.status, tenantId: run.tenantId });
|
|
261
|
+
approvals.save?.();
|
|
262
|
+
runStore?.save?.();
|
|
263
|
+
return { ok: true, status: 'denied', approval, run };
|
|
264
|
+
}
|
|
265
|
+
const tool = tools.get(approval.metadata.tool);
|
|
266
|
+
if (!tool) return { ok: false, error: `Tool no longer registered: ${approval.metadata.tool}`, approval };
|
|
267
|
+
approval.status = APPROVAL_STATUS.APPROVED;
|
|
268
|
+
approval.resolvedAt = new Date().toISOString();
|
|
269
|
+
run.status = 'approved';
|
|
270
|
+
try {
|
|
271
|
+
const started = Date.now();
|
|
272
|
+
const value = await tool.handler(approval.metadata.arguments || {}, { request: approval.request, decision: { decision: run.decision, risk: run.risk, reason: run.reason }, runId: run.id, approved: true, approvalId });
|
|
273
|
+
run.executed = true;
|
|
274
|
+
run.durationMs = Date.now() - started;
|
|
275
|
+
observability.enrichRun(run);
|
|
276
|
+
let outputValue = value;
|
|
277
|
+
if (egressGuard) {
|
|
278
|
+
const egress = egressGuard.guard(value);
|
|
279
|
+
run.egress = { action: egress.action, findings: egress.findings, risk: egress.risk, reason: egress.reason };
|
|
280
|
+
telemetry.increment(`egress.${egress.action.toLowerCase()}`);
|
|
281
|
+
if (egress.action === 'BLOCK') {
|
|
282
|
+
run.egressBlocked = true;
|
|
283
|
+
run.status = 'egress_blocked';
|
|
284
|
+
eventBus.publish('egress.blocked', { runId: run.id, tenantId: run.tenantId, findings: egress.findings });
|
|
285
|
+
approvals.save?.(); runStore?.save?.();
|
|
286
|
+
return { ok: false, status: 'egress_blocked', approval, run, error: egress.reason };
|
|
287
|
+
}
|
|
288
|
+
outputValue = egress.value;
|
|
289
|
+
if (egress.action === 'REDACT') eventBus.publish('egress.redacted', { runId: run.id, tenantId: run.tenantId, findings: egress.findings });
|
|
290
|
+
}
|
|
291
|
+
run.status = 'executed';
|
|
292
|
+
run.valuePreview = typeof outputValue === 'string' ? outputValue.slice(0, 500) : outputValue;
|
|
293
|
+
run.approval = { status: approval.status, resolvedAt: approval.resolvedAt, approver: 'explicit' };
|
|
294
|
+
eventBus.publish('approval.resolved', { approvalId, runId: run.id, status: approval.status, tenantId: run.tenantId });
|
|
295
|
+
approvals.save?.();
|
|
296
|
+
runStore?.save?.();
|
|
297
|
+
return { ok: true, status: 'executed', value: outputValue, approval, run };
|
|
298
|
+
} catch (error) {
|
|
299
|
+
run.executed = true;
|
|
300
|
+
run.status = 'execution_error';
|
|
301
|
+
run.error = error?.message || String(error);
|
|
302
|
+
approvals.save?.();
|
|
303
|
+
runStore?.save?.();
|
|
304
|
+
return { ok: true, status: 'execution_error', error: run.error, approval, run };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function handle(message) {
|
|
309
|
+
if (!message || message.jsonrpc !== '2.0') return rpcError(message?.id ?? null, -32600, 'Invalid Request');
|
|
310
|
+
const { id, method, params } = message;
|
|
311
|
+
if (id === undefined) return null; // notification
|
|
312
|
+
|
|
313
|
+
if (method === 'initialize') {
|
|
314
|
+
return rpcResult(id, {
|
|
315
|
+
protocolVersion: params?.protocolVersion || MCP_PROTOCOL_VERSION,
|
|
316
|
+
capabilities: { tools: {} },
|
|
317
|
+
serverInfo
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
if (method === 'ping') return rpcResult(id, {});
|
|
321
|
+
if (method === 'tools/list') {
|
|
322
|
+
return rpcResult(id, {
|
|
323
|
+
tools: [...tools.values()].map(({ name, description, inputSchema }) => ({ name, description, inputSchema }))
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
if (method === 'tools/call') return callTool(id, params);
|
|
327
|
+
if (method === 'agentgate/approvals/list') return rpcResult(id, { approvals: api.approvals(params?.status, params?.tenantId) });
|
|
328
|
+
if (method === 'agentgate/approvals/approve') return rpcResult(id, await resolveApproval(params?.approvalId, true, undefined, params?.tenantId));
|
|
329
|
+
if (method === 'agentgate/approvals/deny') return rpcResult(id, await resolveApproval(params?.approvalId, false, params?.reason, params?.tenantId));
|
|
330
|
+
return rpcError(id, -32601, `Method not found: ${method}`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return api;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function clone(value) { try { return structuredClone(value); } catch { return JSON.parse(JSON.stringify(value)); } }
|
|
337
|
+
|
|
338
|
+
export function createMCPGatewayServer(options = {}) {
|
|
339
|
+
const gateway = options.gateway || createMCPGateway(options);
|
|
340
|
+
const server = http.createServer(async (req, res) => {
|
|
341
|
+
if (req.method !== 'POST' || req.url !== (options.path || '/mcp')) {
|
|
342
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
343
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (typeof options.authenticate !== 'function' && options.localOnly !== false) {
|
|
347
|
+
const remote = String(req.socket.remoteAddress || '');
|
|
348
|
+
const loopback = remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1';
|
|
349
|
+
if (!loopback) { res.writeHead(403, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'MCP server is local-only unless authentication is configured' })); return; }
|
|
350
|
+
}
|
|
351
|
+
if (options.localOnly === false && typeof options.authenticate !== 'function') {
|
|
352
|
+
res.writeHead(401, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'Authentication is required for non-local MCP server' })); return;
|
|
353
|
+
}
|
|
354
|
+
const maxBodySize = Number(options.maxBodySize || 1024 * 1024);
|
|
355
|
+
if (typeof options.authenticate === 'function') {
|
|
356
|
+
const auth = await options.authenticate(req);
|
|
357
|
+
if (!auth?.ok) { res.writeHead(auth?.status || 401, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: auth?.error || 'Unauthorized' })); return; }
|
|
358
|
+
if (auth.tenantId) req.agentgateTenantId = auth.tenantId;
|
|
359
|
+
}
|
|
360
|
+
let body = '';
|
|
361
|
+
let size = 0;
|
|
362
|
+
req.on('data', chunk => { size += chunk.length; if (size > maxBodySize) { req.destroy(); return; } body += chunk; });
|
|
363
|
+
req.on('end', async () => {
|
|
364
|
+
if (size > maxBodySize) { res.writeHead(413, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'Payload too large' })); return; }
|
|
365
|
+
try {
|
|
366
|
+
const message = JSON.parse(body || '{}');
|
|
367
|
+
if (req.agentgateTenantId && message?.params) message.params.tenantId = req.agentgateTenantId;
|
|
368
|
+
const response = await gateway.handle(message);
|
|
369
|
+
if (response === null) {
|
|
370
|
+
res.writeHead(202);
|
|
371
|
+
res.end();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
375
|
+
res.end(JSON.stringify(response));
|
|
376
|
+
} catch (error) {
|
|
377
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
378
|
+
res.end(JSON.stringify(rpcError(null, -32700, error.message)));
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
return { server, gateway };
|
|
383
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const HIGH_RISK_NAMES = new Set(['delete','remove','drop','refund','publish','deploy','execute','run_command','shell','write_file','export_all','update_production']);
|
|
4
|
+
const SENSITIVE_WORDS = /password|secret|token|api[_-]?key|private[_-]?key|credential|cookie/i;
|
|
5
|
+
|
|
6
|
+
function stable(value) {
|
|
7
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
8
|
+
if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`;
|
|
9
|
+
return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${stable(value[k])}`).join(',')}}`;
|
|
10
|
+
}
|
|
11
|
+
export function fingerprintTool(tool = {}) {
|
|
12
|
+
const contract = { name: tool.name, description: tool.description || '', inputSchema: tool.inputSchema || { type:'object' }, annotations: tool.annotations || {} };
|
|
13
|
+
return crypto.createHash('sha256').update(stable(contract)).digest('hex');
|
|
14
|
+
}
|
|
15
|
+
export function scanMCPTools(tools = []) {
|
|
16
|
+
const findings = [];
|
|
17
|
+
const normalized = tools.map(t => ({ ...t, fingerprint: fingerprintTool(t) }));
|
|
18
|
+
for (const tool of normalized) {
|
|
19
|
+
const name = String(tool.name || '').toLowerCase();
|
|
20
|
+
const description = String(tool.description || '');
|
|
21
|
+
if (HIGH_RISK_NAMES.has(name) || /\b(delete|refund|deploy|publish|execute|shell|command)\b/i.test(name)) {
|
|
22
|
+
findings.push({ severity:'high', code:'dangerous-tool', tool:tool.name, message:'Tool exposes a potentially destructive or privileged capability.' });
|
|
23
|
+
}
|
|
24
|
+
if (/ignore (previous|all)|system prompt|do not follow|bypass|override (policy|security)/i.test(description)) {
|
|
25
|
+
findings.push({ severity:'high', code:'description-injection', tool:tool.name, message:'Tool description contains instruction-like text that may influence an agent.' });
|
|
26
|
+
}
|
|
27
|
+
if (SENSITIVE_WORDS.test(stable(tool.inputSchema))) {
|
|
28
|
+
findings.push({ severity:'medium', code:'sensitive-input', tool:tool.name, message:'Tool schema contains a field that appears to accept sensitive credentials or secrets.' });
|
|
29
|
+
}
|
|
30
|
+
if (!tool.inputSchema || tool.inputSchema.type !== 'object') {
|
|
31
|
+
findings.push({ severity:'medium', code:'weak-schema', tool:tool.name, message:'Tool input schema is missing or is not an object schema.' });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const high = findings.filter(f=>f.severity==='high').length;
|
|
35
|
+
const medium = findings.filter(f=>f.severity==='medium').length;
|
|
36
|
+
return { tools: normalized, findings, summary:{tools:normalized.length, high, medium, low:0, status:high?'REVIEW_REQUIRED':medium?'REVIEW':'PASS'} };
|
|
37
|
+
}
|
|
38
|
+
export function createToolTrustStore(initial = {}) {
|
|
39
|
+
const pins = new Map(Object.entries(initial));
|
|
40
|
+
return {
|
|
41
|
+
pin(tools=[]) { const out={}; for(const tool of tools){ const fp=fingerprintTool(tool); pins.set(tool.name,fp); out[tool.name]=fp; } return out; },
|
|
42
|
+
check(tools=[]) { const changes=[]; for(const tool of tools){ const current=fingerprintTool(tool); const previous=pins.get(tool.name); if(previous && previous!==current) changes.push({tool:tool.name,previous,current,severity:'high'}); } return {ok:changes.length===0,changes}; },
|
|
43
|
+
snapshot(){ return Object.fromEntries(pins); }
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { evaluate } from './policy-engine.js';
|
|
2
|
+
|
|
3
|
+
export function createMiddleware(options = {}) {
|
|
4
|
+
const policies = options.policies || {};
|
|
5
|
+
const onDecision = options.onDecision || (() => {});
|
|
6
|
+
return async function agentgateMiddleware(request, next) {
|
|
7
|
+
const result = evaluate(request, policies);
|
|
8
|
+
onDecision({ request, result });
|
|
9
|
+
if (result.decision === 'BLOCK') {
|
|
10
|
+
return { decision: 'BLOCK', result };
|
|
11
|
+
}
|
|
12
|
+
if (result.decision === 'ASK') {
|
|
13
|
+
return { decision: 'ASK', result };
|
|
14
|
+
}
|
|
15
|
+
return { decision: 'ALLOW', result, value: await next(request) };
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { PersistentCollectionStore } from './persistent-store.js';
|
|
3
|
+
import { createWebhookDispatcher, validateWebhookTarget } from './webhook-delivery.js';
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_SCOPES = Object.freeze(['runs:read','approvals:read','approvals:resolve','agents:read','agents:write','policies:read','policies:write','webhooks:read','webhooks:write','admin:*']);
|
|
6
|
+
const allows = (granted, needed) => granted.includes('admin:*') || granted.includes(needed) || granted.includes(needed.split(':')[0]+':*');
|
|
7
|
+
|
|
8
|
+
export class TenantRegistry {
|
|
9
|
+
constructor(options={}) { this.tenants = options.tenantStore || new PersistentCollectionStore(options.filePath || '.agentgate/tenants.json',{limit: options.limit||1000}); this.keys = options.keyStore || new PersistentCollectionStore(options.keyPath || '.agentgate/api-keys.json',{limit: options.keyLimit||5000}); }
|
|
10
|
+
create(name, metadata={}) { const id=`ten_${crypto.randomBytes(8).toString('hex')}`; const tenant={id,name:String(name),metadata,createdAt:new Date().toISOString()}; return this.tenants.add(tenant); }
|
|
11
|
+
get(id){ return this.tenants.get(id); }
|
|
12
|
+
list(){ return this.tenants.list(); }
|
|
13
|
+
issueKey(tenantId, options={}) { if(!this.get(tenantId)) throw new Error('Tenant not found'); const secret=crypto.randomBytes(24).toString('base64url'); const prefix='ag_'+crypto.randomBytes(5).toString('hex'); const key=`${prefix}.${secret}`; const hash=crypto.createHash('sha256').update(key).digest('hex'); const rec={id:`key_${crypto.randomBytes(8).toString('hex')}`,tenantId,prefix,hash,scopes:options.scopes?.filter(s=>DEFAULT_SCOPES.includes(s)||s.endsWith(':*'))||['runs:read'],createdAt:new Date().toISOString(),expiresAt:options.expiresAt||null,revokedAt:null}; this.keys.add(rec); return {...rec,secret:key}; }
|
|
14
|
+
authenticate(secret){ const hash=crypto.createHash('sha256').update(String(secret||'')).digest('hex'); const rec=this.keys.list(k=>k.hash===hash)[0]; if(!rec || rec.revokedAt || (rec.expiresAt && Date.parse(rec.expiresAt)<=Date.now())) return null; return {...rec}; }
|
|
15
|
+
revoke(id, tenantId = null){ const k=this.keys.get(id); if(!k || (tenantId && k.tenantId !== tenantId)) return null; k.revokedAt=new Date().toISOString(); this.keys.save(); return k; }
|
|
16
|
+
rotate(id, options={}, tenantId = null) { const k=this.keys.get(id); if(!k || (tenantId && k.tenantId !== tenantId)) throw new Error('API key not found'); const next=this.issueKey(k.tenantId,{...options,scopes:options.scopes||k.scopes}); this.revoke(id, k.tenantId); return next; }
|
|
17
|
+
listKeys(tenantId){ return this.keys.list(k=>k.tenantId===tenantId).map(({hash,...k})=>k); }
|
|
18
|
+
authorize(auth, tenantId, scope){ if(!auth || auth.tenantId!==tenantId) return false; return allows(auth.scopes,scope); }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class WebhookRegistry {
|
|
22
|
+
constructor(options={}) { this.store=options.store||new PersistentCollectionStore(options.filePath||'.agentgate/webhooks.json',{limit:options.limit||1000}); this.deliveries=options.deliveryStore||new PersistentCollectionStore(options.deliveryPath||'.agentgate/webhook-deliveries.json',{limit:options.deliveryLimit||5000}); this.dispatcher=options.dispatcher||createWebhookDispatcher(options); }
|
|
23
|
+
create(tenantId,input={}) { const item={id:`wh_${crypto.randomBytes(8).toString('hex')}`,tenantId,url:String(input.url||''),events:Array.isArray(input.events)&&input.events.length?input.events:['*'],active:input.active!==false,secret:input.secret||crypto.randomBytes(24).toString('hex'),createdAt:new Date().toISOString()}; if(!/^https?:\/\//.test(item.url)) throw new Error('Webhook URL must use http or https'); const host = (()=>{ try{return new URL(item.url).hostname;}catch{return '';} })(); if(['localhost','127.0.0.1','0.0.0.0','169.254.169.254'].includes(host) || /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[0-1])\.|127\.)/.test(host)) throw new Error('Webhook target blocked: private or local address'); return this.store.add(item); }
|
|
24
|
+
list(tenantId){ return this.store.list(w=>w.tenantId===tenantId).map(({secret,...w})=>w); }
|
|
25
|
+
remove(id,tenantId){ const w=this.store.get(id); if(!w||w.tenantId!==tenantId) return null; w.active=false; this.store.save(); return w; }
|
|
26
|
+
emit(tenantId,event,payload){ const hooks=this.store.list(w=>w.tenantId===tenantId&&w.active&&(w.events.includes('*')||w.events.includes(event))); return hooks.map(w=>{ const delivery={id:`whd_${crypto.randomBytes(8).toString('hex')}`,webhookId:w.id,tenantId,event,payload,status:'queued',attempts:0,createdAt:new Date().toISOString()}; this.deliveries.add(delivery); return delivery; }); }
|
|
27
|
+
deliveriesFor(tenantId){ return this.deliveries.list(d=>d.tenantId===tenantId); }
|
|
28
|
+
async deliver(deliveryId, tenantId){ const d=this.deliveries.get(deliveryId); if(!d || d.tenantId!==tenantId) return null; const w=this.store.get(d.webhookId); if(!w || w.tenantId!==tenantId || !w.active) return null; const result=await this.dispatcher(w,d); this.deliveries.update(d.id,{status:result.status,attempts:result.attempts,lastError:result.error||null,statusCode:result.statusCode||null,deliveredAt:result.status==='delivered'?new Date().toISOString():null}); return {...d,...result}; }
|
|
29
|
+
}
|