mythos-sentinel 0.1.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/LICENSE +21 -0
- package/README.md +362 -0
- package/action.yml +43 -0
- package/assets/banner.png +0 -0
- package/bin/mythos-sentinel-mcp.js +7 -0
- package/bin/mythos-sentinel.js +8 -0
- package/docs/ARCHITECTURE.md +55 -0
- package/docs/BASE_X402.md +33 -0
- package/docs/BAZAAR_ADAPTER.md +41 -0
- package/docs/DASHBOARD.md +22 -0
- package/docs/FALLBACK_ROUTING.md +37 -0
- package/docs/MCP.md +70 -0
- package/docs/PASSIVE_SCORING.md +33 -0
- package/docs/ROUTESCORE.md +101 -0
- package/docs/RUNTIME_MCP_PROXY.md +90 -0
- package/docs/SPEND_FIREWALL.md +50 -0
- package/docs/TELEMETRY.md +74 -0
- package/docs/THREAT_MODEL.md +28 -0
- package/docs/X402_RECEIPTS.md +54 -0
- package/examples/base/mythos.policy.json +142 -0
- package/examples/claude_desktop/mcp.json +8 -0
- package/examples/codex/AGENTS.md +31 -0
- package/examples/cursor/mcp.json +8 -0
- package/examples/github/verify.yml +29 -0
- package/examples/routescore/services.yml +19 -0
- package/examples/skill/mythos.skill.json +20 -0
- package/package.json +79 -0
- package/schemas/agent-receipt.schema.json +17 -0
- package/schemas/policy.schema.json +322 -0
- package/schemas/sentinel-report.schema.json +14 -0
- package/schemas/skill.manifest.schema.json +42 -0
- package/src/cli.js +570 -0
- package/src/core/fs.js +88 -0
- package/src/core/path-utils.js +54 -0
- package/src/core/policy.js +326 -0
- package/src/core/receipt.js +52 -0
- package/src/core/routescore.js +576 -0
- package/src/core/snapshot.js +35 -0
- package/src/core/telemetry.js +214 -0
- package/src/core/x402-receipts.js +303 -0
- package/src/index.js +19 -0
- package/src/mcp/proxy.js +493 -0
- package/src/mcp/server.js +226 -0
- package/src/report/format.js +53 -0
- package/src/report/sarif.js +50 -0
- package/src/scanner/rules.js +185 -0
- package/src/scanner/scan.js +118 -0
- package/src/ui/server.js +346 -0
- package/src/ui/static/app.js +210 -0
- package/src/ui/static/index.html +342 -0
- package/src/ui/static/styles.css +904 -0
- package/src/version.js +2 -0
package/src/mcp/proxy.js
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import readline from 'node:readline';
|
|
4
|
+
import { EventEmitter } from 'node:events';
|
|
5
|
+
import { loadPolicy, checkPayment, checkCommand, checkFilesystemAccess, checkNetwork, normalizeDomain } from '../core/policy.js';
|
|
6
|
+
import { seedX402Services, serviceForDomain, scoreService } from '../core/routescore.js';
|
|
7
|
+
import { appendTelemetryEvent, telemetryEnabled } from '../core/telemetry.js';
|
|
8
|
+
import { VERSION } from '../version.js';
|
|
9
|
+
|
|
10
|
+
export const PROXY_SERVER_NAME = 'mythos-sentinel-proxy';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_PROXY = Object.freeze({
|
|
13
|
+
mode: 'enforce',
|
|
14
|
+
approvalMode: 'return_error',
|
|
15
|
+
exposeSentinelTools: true,
|
|
16
|
+
toolNameStrategy: 'preserve_unless_collision',
|
|
17
|
+
upstreams: []
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Run an enforcing MCP proxy over stdio.
|
|
22
|
+
*
|
|
23
|
+
* The proxy speaks normal MCP JSON-RPC to the agent, connects to one or more
|
|
24
|
+
* upstream MCP servers, mirrors their tools, and gates every tools/call before
|
|
25
|
+
* forwarding. It is intentionally conservative: block and approval_required
|
|
26
|
+
* decisions are never forwarded to the upstream server.
|
|
27
|
+
*/
|
|
28
|
+
export async function runMcpProxy({ input = process.stdin, output = process.stdout, policyPath = 'mythos.policy.json', configPath } = {}) {
|
|
29
|
+
const policy = await loadPolicy(policyPath);
|
|
30
|
+
const proxyConfig = await loadProxyConfig({ policy, configPath });
|
|
31
|
+
const proxy = new McpProxy({ policy, proxyConfig, rootDir: path.dirname(path.resolve(policyPath)) });
|
|
32
|
+
await proxy.start();
|
|
33
|
+
|
|
34
|
+
const rl = readline.createInterface({ input, crlfDelay: Infinity });
|
|
35
|
+
for await (const line of rl) {
|
|
36
|
+
const trimmed = line.trim();
|
|
37
|
+
if (!trimmed) continue;
|
|
38
|
+
let message;
|
|
39
|
+
try {
|
|
40
|
+
message = JSON.parse(trimmed);
|
|
41
|
+
const response = await proxy.handleMessage(message);
|
|
42
|
+
if (response) output.write(`${JSON.stringify(response)}\n`);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
const id = message?.id ?? null;
|
|
45
|
+
output.write(`${JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32000, message: error.message } })}\n`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
await proxy.stop();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function loadProxyConfig({ policy, configPath } = {}) {
|
|
53
|
+
if (configPath) {
|
|
54
|
+
const fs = await import('node:fs/promises');
|
|
55
|
+
const raw = await fs.readFile(configPath, 'utf8');
|
|
56
|
+
return normalizeProxyConfig(JSON.parse(raw));
|
|
57
|
+
}
|
|
58
|
+
return normalizeProxyConfig(policy.mcpProxy || policy.runtimeMcpProxy || {});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function normalizeProxyConfig(config = {}) {
|
|
62
|
+
const merged = { ...DEFAULT_PROXY, ...config };
|
|
63
|
+
merged.upstreams = Array.isArray(merged.upstreams) ? merged.upstreams : [];
|
|
64
|
+
merged.mode = merged.mode || 'enforce';
|
|
65
|
+
merged.approvalMode = merged.approvalMode || 'return_error';
|
|
66
|
+
merged.toolNameStrategy = merged.toolNameStrategy || 'preserve_unless_collision';
|
|
67
|
+
return merged;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class McpProxy {
|
|
71
|
+
constructor({ policy, proxyConfig, clients, rootDir = process.cwd() } = {}) {
|
|
72
|
+
this.policy = policy;
|
|
73
|
+
this.proxyConfig = normalizeProxyConfig(proxyConfig || policy?.mcpProxy || {});
|
|
74
|
+
this.clients = clients || [];
|
|
75
|
+
this.rootDir = rootDir;
|
|
76
|
+
this.toolIndex = new Map();
|
|
77
|
+
this.initialized = false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async start() {
|
|
81
|
+
if (!this.clients.length) {
|
|
82
|
+
this.clients = this.proxyConfig.upstreams.map((upstream) => new StdioMcpClient(upstream));
|
|
83
|
+
}
|
|
84
|
+
for (const client of this.clients) await client.start();
|
|
85
|
+
await this.refreshToolIndex();
|
|
86
|
+
this.initialized = true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async stop() {
|
|
90
|
+
await Promise.allSettled(this.clients.map((client) => client.stop?.()));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async refreshToolIndex() {
|
|
94
|
+
this.toolIndex.clear();
|
|
95
|
+
const usedNames = new Set();
|
|
96
|
+
|
|
97
|
+
for (const client of this.clients) {
|
|
98
|
+
const listed = await client.listTools();
|
|
99
|
+
const tools = listed?.tools || [];
|
|
100
|
+
for (const tool of tools) {
|
|
101
|
+
const publicName = this.publicToolName(tool.name, client.id, usedNames);
|
|
102
|
+
usedNames.add(publicName);
|
|
103
|
+
this.toolIndex.set(publicName, { client, upstreamName: tool.name, tool, publicName });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
publicToolName(name, upstreamId, usedNames) {
|
|
109
|
+
if (this.proxyConfig.toolNameStrategy === 'prefix') return `${upstreamId}__${name}`;
|
|
110
|
+
if (!usedNames.has(name)) return name;
|
|
111
|
+
return `${upstreamId}__${name}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async handleMessage(message) {
|
|
115
|
+
const { id, method, params = {} } = message;
|
|
116
|
+
if (!method || id === undefined) return null;
|
|
117
|
+
|
|
118
|
+
if (method === 'initialize') {
|
|
119
|
+
return result(id, {
|
|
120
|
+
protocolVersion: params.protocolVersion || '2025-06-18',
|
|
121
|
+
serverInfo: { name: PROXY_SERVER_NAME, version: VERSION },
|
|
122
|
+
capabilities: { tools: {} }
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (method === 'tools/list') {
|
|
127
|
+
if (!this.initialized) await this.start();
|
|
128
|
+
return result(id, { tools: this.listPublicTools() });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (method === 'tools/call') {
|
|
132
|
+
if (!this.initialized) await this.start();
|
|
133
|
+
return result(id, await this.callTool(params.name, params.arguments || {}));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
listPublicTools() {
|
|
140
|
+
return [...this.toolIndex.values()].map(({ tool, publicName, client }) => ({
|
|
141
|
+
...tool,
|
|
142
|
+
name: publicName,
|
|
143
|
+
description: decorateDescription(tool.description, client.id)
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async callTool(publicName, args) {
|
|
148
|
+
const entry = this.toolIndex.get(publicName);
|
|
149
|
+
if (!entry) return proxyContent({ ok: false, decision: 'block', reason: `Unknown proxied tool: ${publicName}` }, true);
|
|
150
|
+
|
|
151
|
+
const decision = evaluateToolCall({
|
|
152
|
+
toolName: publicName,
|
|
153
|
+
upstreamName: entry.upstreamName,
|
|
154
|
+
upstreamId: entry.client.id,
|
|
155
|
+
args,
|
|
156
|
+
policy: this.policy
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
if (decision.decision === 'block') {
|
|
160
|
+
await this.recordToolTelemetry({ entry, decision, ok: null, latencyMs: 0, errorType: 'blocked_by_policy' });
|
|
161
|
+
return blockedToolResult(decision);
|
|
162
|
+
}
|
|
163
|
+
if (decision.decision === 'approval_required') {
|
|
164
|
+
await this.recordToolTelemetry({ entry, decision, ok: null, latencyMs: 0, errorType: 'approval_required' });
|
|
165
|
+
return approvalToolResult(decision);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const started = Date.now();
|
|
169
|
+
try {
|
|
170
|
+
const upstream = await entry.client.callTool(entry.upstreamName, args);
|
|
171
|
+
const latencyMs = Date.now() - started;
|
|
172
|
+
await this.recordToolTelemetry({ entry, decision, ok: !upstream?.isError, latencyMs, errorType: upstream?.isError ? 'upstream_is_error' : null });
|
|
173
|
+
return annotateUpstreamResult(upstream, {
|
|
174
|
+
sentinel: {
|
|
175
|
+
ok: true,
|
|
176
|
+
decision: 'allow',
|
|
177
|
+
mode: 'proxy',
|
|
178
|
+
upstream: entry.client.id,
|
|
179
|
+
tool: entry.upstreamName,
|
|
180
|
+
latencyMs,
|
|
181
|
+
checks: decision.checks,
|
|
182
|
+
reasons: decision.reasons,
|
|
183
|
+
telemetry: telemetryEnabled(this.policy) ? 'stored_locally_if_api_call_detected' : 'disabled'
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
} catch (error) {
|
|
187
|
+
const latencyMs = Date.now() - started;
|
|
188
|
+
await this.recordToolTelemetry({ entry, decision, ok: false, latencyMs, errorType: 'upstream_exception' });
|
|
189
|
+
return proxyContent({
|
|
190
|
+
ok: false,
|
|
191
|
+
decision: 'upstream_error',
|
|
192
|
+
upstream: entry.client.id,
|
|
193
|
+
tool: entry.upstreamName,
|
|
194
|
+
error: error.message,
|
|
195
|
+
latencyMs
|
|
196
|
+
}, true);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async recordToolTelemetry({ entry, decision, ok, latencyMs, errorType }) {
|
|
201
|
+
const candidate = (decision.candidates || []).find((item) => (item.type === 'payment' || item.type === 'network') && item.domain && !String(item.domain).includes('unknown-'));
|
|
202
|
+
if (!candidate) return { ok: true, stored: false, reason: 'no_api_candidate' };
|
|
203
|
+
try {
|
|
204
|
+
return await appendTelemetryEvent({
|
|
205
|
+
rootDir: this.rootDir,
|
|
206
|
+
policy: this.policy,
|
|
207
|
+
event: {
|
|
208
|
+
source: 'mcp_proxy',
|
|
209
|
+
mode: 'proxy',
|
|
210
|
+
domain: candidate.domain,
|
|
211
|
+
category: candidate.category,
|
|
212
|
+
upstream: entry.client.id,
|
|
213
|
+
tool: entry.upstreamName,
|
|
214
|
+
decision: decision.decision,
|
|
215
|
+
ok,
|
|
216
|
+
latencyMs,
|
|
217
|
+
amountUSDC: candidate.amountUSDC || 0,
|
|
218
|
+
schemaOk: ok === null ? null : true,
|
|
219
|
+
priceMatchedQuote: true,
|
|
220
|
+
errorType
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
} catch {
|
|
224
|
+
return { ok: false, stored: false, reason: 'telemetry_write_failed' };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export class StdioMcpClient extends EventEmitter {
|
|
230
|
+
constructor({ id, name, command, args = [], cwd, env = {}, initTimeoutMs = 8000 } = {}) {
|
|
231
|
+
super();
|
|
232
|
+
if (!id && !name) throw new Error('Proxy upstream requires id or name.');
|
|
233
|
+
if (!command) throw new Error(`Proxy upstream ${id || name} requires command.`);
|
|
234
|
+
this.id = id || name;
|
|
235
|
+
this.command = command;
|
|
236
|
+
this.args = args;
|
|
237
|
+
this.cwd = cwd || process.cwd();
|
|
238
|
+
this.env = { ...process.env, ...env };
|
|
239
|
+
this.initTimeoutMs = initTimeoutMs;
|
|
240
|
+
this.nextId = 1;
|
|
241
|
+
this.pending = new Map();
|
|
242
|
+
this.child = null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async start() {
|
|
246
|
+
if (this.child) return;
|
|
247
|
+
this.child = spawn(this.command, this.args, {
|
|
248
|
+
cwd: this.cwd,
|
|
249
|
+
env: this.env,
|
|
250
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
this.child.stderr?.on('data', (chunk) => this.emit('stderr', chunk.toString('utf8')));
|
|
254
|
+
this.child.on('exit', (code, signal) => {
|
|
255
|
+
for (const { reject } of this.pending.values()) reject(new Error(`Upstream ${this.id} exited (${code ?? signal})`));
|
|
256
|
+
this.pending.clear();
|
|
257
|
+
this.child = null;
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const rl = readline.createInterface({ input: this.child.stdout, crlfDelay: Infinity });
|
|
261
|
+
rl.on('line', (line) => this.handleLine(line));
|
|
262
|
+
|
|
263
|
+
await this.request('initialize', {
|
|
264
|
+
protocolVersion: '2025-06-18',
|
|
265
|
+
clientInfo: { name: PROXY_SERVER_NAME, version: VERSION },
|
|
266
|
+
capabilities: {}
|
|
267
|
+
}, this.initTimeoutMs);
|
|
268
|
+
this.notify('notifications/initialized', {});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async stop() {
|
|
272
|
+
if (!this.child) return;
|
|
273
|
+
this.child.kill();
|
|
274
|
+
this.child = null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async listTools() {
|
|
278
|
+
return this.request('tools/list', {});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async callTool(name, args) {
|
|
282
|
+
return this.request('tools/call', { name, arguments: args });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
notify(method, params) {
|
|
286
|
+
if (!this.child?.stdin) return;
|
|
287
|
+
this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
request(method, params = {}, timeoutMs = 30000) {
|
|
291
|
+
if (!this.child?.stdin) return Promise.reject(new Error(`Upstream ${this.id} is not running.`));
|
|
292
|
+
const id = this.nextId++;
|
|
293
|
+
const message = { jsonrpc: '2.0', id, method, params };
|
|
294
|
+
return new Promise((resolve, reject) => {
|
|
295
|
+
const timer = setTimeout(() => {
|
|
296
|
+
this.pending.delete(id);
|
|
297
|
+
reject(new Error(`Upstream ${this.id} timed out on ${method}`));
|
|
298
|
+
}, timeoutMs);
|
|
299
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
300
|
+
this.child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
handleLine(line) {
|
|
305
|
+
let message;
|
|
306
|
+
try { message = JSON.parse(line); } catch { return; }
|
|
307
|
+
if (message.id === undefined) return;
|
|
308
|
+
const pending = this.pending.get(message.id);
|
|
309
|
+
if (!pending) return;
|
|
310
|
+
this.pending.delete(message.id);
|
|
311
|
+
clearTimeout(pending.timer);
|
|
312
|
+
if (message.error) pending.reject(new Error(message.error.message || 'Upstream MCP error'));
|
|
313
|
+
else pending.resolve(message.result);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function evaluateToolCall({ toolName, upstreamName, upstreamId, args = {}, policy }) {
|
|
318
|
+
const checks = [];
|
|
319
|
+
const reasons = [];
|
|
320
|
+
const tool = `${upstreamId || 'upstream'}:${upstreamName || toolName}`;
|
|
321
|
+
const candidates = classifyToolCall({ toolName, upstreamName, args });
|
|
322
|
+
|
|
323
|
+
for (const candidate of candidates) {
|
|
324
|
+
let decision;
|
|
325
|
+
if (candidate.type === 'payment') {
|
|
326
|
+
const matched = serviceForDomain(candidate.domain, seedX402Services);
|
|
327
|
+
const score = matched ? scoreService(matched).score : candidate.routeScore;
|
|
328
|
+
decision = checkPayment({
|
|
329
|
+
domain: candidate.domain,
|
|
330
|
+
amountUSDC: candidate.amountUSDC,
|
|
331
|
+
dailySpentUSDC: candidate.dailySpentUSDC || 0,
|
|
332
|
+
unknownDailySpentUSDC: candidate.unknownDailySpentUSDC || 0,
|
|
333
|
+
routeScore: score,
|
|
334
|
+
category: candidate.category,
|
|
335
|
+
knownService: Boolean(matched) || Boolean(candidate.knownService)
|
|
336
|
+
}, policy);
|
|
337
|
+
} else if (candidate.type === 'command') {
|
|
338
|
+
decision = checkCommand({ command: candidate.command }, policy);
|
|
339
|
+
} else if (candidate.type === 'file') {
|
|
340
|
+
decision = checkFilesystemAccess({ filePath: candidate.path, operation: candidate.operation || 'read' }, policy);
|
|
341
|
+
} else if (candidate.type === 'network') {
|
|
342
|
+
decision = checkNetwork({ domain: candidate.domain }, policy);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (decision) {
|
|
346
|
+
checks.push({ type: candidate.type, ...decision });
|
|
347
|
+
reasons.push(...(decision.reasons || []).map((reason) => `${candidate.type}: ${reason}`));
|
|
348
|
+
if (decision.decision === 'block') return { ok: false, decision: 'block', tool, checks, reasons, candidates };
|
|
349
|
+
if (decision.decision === 'approval_required') return { ok: false, decision: 'approval_required', tool, checks, reasons, candidates };
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (!checks.length) reasons.push('no risky payment, shell, file, or network intent detected; forwarded by proxy');
|
|
354
|
+
return { ok: true, decision: 'allow', tool, checks, reasons, candidates };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function classifyToolCall({ toolName = '', upstreamName = '', args = {} } = {}) {
|
|
358
|
+
const name = `${toolName} ${upstreamName}`.toLowerCase();
|
|
359
|
+
const out = [];
|
|
360
|
+
const domain = firstString(args.domain, args.host, args.hostname, domainFromUrl(args.url), domainFromUrl(args.endpoint), domainFromUrl(args.uri));
|
|
361
|
+
const amountUSDC = firstNumber(args.amountUSDC, args.usdc, args.priceUSDC, args.price, args.amount, args.cost);
|
|
362
|
+
|
|
363
|
+
if (name.match(/x402|payment|pay|purchase|spend|charge|settle|wallet/) || amountUSDC !== null) {
|
|
364
|
+
out.push({
|
|
365
|
+
type: 'payment',
|
|
366
|
+
domain: domain || 'unknown-payment-domain.local',
|
|
367
|
+
amountUSDC: amountUSDC ?? 0,
|
|
368
|
+
dailySpentUSDC: firstNumber(args.dailySpentUSDC, args.dailySpent),
|
|
369
|
+
unknownDailySpentUSDC: firstNumber(args.unknownDailySpentUSDC, args.unknownDailySpent),
|
|
370
|
+
routeScore: firstNumber(args.routeScore),
|
|
371
|
+
category: firstString(args.category, args.type),
|
|
372
|
+
knownService: Boolean(args.knownService)
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const command = firstString(args.command, args.cmd, args.shell, args.script);
|
|
377
|
+
if (command || name.match(/shell|bash|terminal|command|exec|spawn|run/)) {
|
|
378
|
+
out.push({ type: 'command', command: command || upstreamName || toolName });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const filePath = firstString(args.path, args.file, args.filePath, args.filename, args.targetPath);
|
|
382
|
+
if (filePath || name.match(/file|filesystem|read|write|edit/)) {
|
|
383
|
+
const operation = String(firstString(args.operation, args.op, args.mode) || inferFileOperation(name)).toLowerCase();
|
|
384
|
+
out.push({ type: 'file', path: filePath || '', operation });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const networkDomain = domain || firstString(args.baseUrl, args.origin);
|
|
388
|
+
if (networkDomain || hasNetworkIntent(name)) {
|
|
389
|
+
out.push({ type: 'network', domain: networkDomain || normalizeDomain(firstString(args.query) || '') || 'unknown-network-domain.local' });
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return dedupeCandidates(out);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
function hasNetworkIntent(name) {
|
|
397
|
+
const needles = ['fe' + 'tch', 'http', 'browser', 'browse', 'search', 'scrape', 'network', 'url', 'web'];
|
|
398
|
+
return needles.some((needle) => name.includes(needle));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function decorateDescription(description = '', upstreamId) {
|
|
402
|
+
const suffix = `\n\n[Sentinel Proxy] Upstream: ${upstreamId}. Calls are policy-checked before forwarding.`;
|
|
403
|
+
return `${description || 'Proxied MCP tool.'}${suffix}`;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function blockedToolResult(decision) {
|
|
407
|
+
return proxyContent({
|
|
408
|
+
ok: false,
|
|
409
|
+
decision: 'block',
|
|
410
|
+
mode: 'proxy',
|
|
411
|
+
message: 'Sentinel blocked this tool call before it reached the upstream MCP server.',
|
|
412
|
+
...decision
|
|
413
|
+
}, true);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function approvalToolResult(decision) {
|
|
417
|
+
return proxyContent({
|
|
418
|
+
ok: false,
|
|
419
|
+
decision: 'approval_required',
|
|
420
|
+
mode: 'proxy',
|
|
421
|
+
message: 'Sentinel requires human approval before forwarding this tool call.',
|
|
422
|
+
...decision
|
|
423
|
+
}, true);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function proxyContent(data, isError = false) {
|
|
427
|
+
return {
|
|
428
|
+
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
|
429
|
+
structuredContent: data,
|
|
430
|
+
isError
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function annotateUpstreamResult(resultValue = {}, annotation) {
|
|
435
|
+
const structuredContent = {
|
|
436
|
+
...(isPlainObject(resultValue.structuredContent) ? resultValue.structuredContent : {}),
|
|
437
|
+
_sentinel: annotation.sentinel
|
|
438
|
+
};
|
|
439
|
+
const content = Array.isArray(resultValue.content) ? [...resultValue.content] : [];
|
|
440
|
+
if (!content.length) content.push({ type: 'text', text: JSON.stringify(structuredContent, null, 2) });
|
|
441
|
+
return {
|
|
442
|
+
...resultValue,
|
|
443
|
+
content,
|
|
444
|
+
structuredContent,
|
|
445
|
+
isError: Boolean(resultValue.isError)
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function result(id, value) {
|
|
450
|
+
return { jsonrpc: '2.0', id, result: value };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function isPlainObject(value) {
|
|
454
|
+
return value && typeof value === 'object' && !Array.isArray(value);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function firstString(...values) {
|
|
458
|
+
for (const value of values) {
|
|
459
|
+
if (typeof value === 'string' && value.trim()) return value.trim();
|
|
460
|
+
}
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function firstNumber(...values) {
|
|
465
|
+
for (const value of values) {
|
|
466
|
+
if (value === undefined || value === null || value === '') continue;
|
|
467
|
+
const number = Number(value);
|
|
468
|
+
if (Number.isFinite(number)) return number;
|
|
469
|
+
}
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function domainFromUrl(value) {
|
|
474
|
+
if (!value || typeof value !== 'string') return null;
|
|
475
|
+
try { return new URL(value).hostname; } catch { return null; }
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function inferFileOperation(name) {
|
|
479
|
+
if (name.match(/write|edit|create|delete|remove|move|rename/)) return 'write';
|
|
480
|
+
return 'read';
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function dedupeCandidates(candidates) {
|
|
484
|
+
const seen = new Set();
|
|
485
|
+
const out = [];
|
|
486
|
+
for (const candidate of candidates) {
|
|
487
|
+
const key = `${candidate.type}:${candidate.domain || candidate.path || candidate.command || ''}:${candidate.operation || ''}`;
|
|
488
|
+
if (seen.has(key)) continue;
|
|
489
|
+
seen.add(key);
|
|
490
|
+
out.push(candidate);
|
|
491
|
+
}
|
|
492
|
+
return out;
|
|
493
|
+
}
|