diff --git a/README.md b/README.md index 86849bc..fecbb18 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,9 @@ directement applicables. - **TrustMap Runtime** fournit un point de décision avant l’appel d’un outil : autorisation, blocage ou validation humaine. Les politiques multi-tenant sont appliquées côté serveur et chaque décision alimente un journal d’exécution. +- **TrustMap Runtime Gateway** protège les serveurs MCP `stdio` de Claude Desktop + et des autres clients : l’appel n’atteint le serveur qu’après une décision + Runtime, avec un comportement fermé par défaut en cas de panne. Les vues Enterprise reflètent uniquement les données réellement chargées. MCP TrustMap fournit sa propre identité : inscription, vérification de l’adresse, @@ -53,6 +56,8 @@ requête. - blocage des appels critiques, prévention de l’exfiltration et validation humaine des actions sensibles ; - API de décision Runtime utilisable par un sidecar, un proxy ou un agent ; +- passerelle locale `stdio` qui applique réellement ces décisions avant + `tools/call`, sans transmettre les arguments ou résultats au service central ; - journal des décisions isolé par organisation ; - découverte de Claude Desktop, Cursor, VS Code, Windsurf et des workspaces ; - masquage des secrets avant la création de l’inventaire ; @@ -694,6 +699,7 @@ public/ | `npm run db:migrate:remote` | Applique les migrations à la base D1 de production | | `npm run deploy:standalone` | Construit et déploie le Worker autonome | | `npm run collect` | Produit un inventaire local assaini | +| `npm run gateway -- --endpoint --server -- ` | Protège un serveur MCP `stdio` avec les politiques Runtime | | `npm run audit:ci -- --path ` | Bloque la CI sur les constats critiques ou élevés | | `npm run collect:sbom` | Produit l’inventaire et le SBOM CycloneDX | | `npm run collect:security` | Ajoute le probe, OSV, la provenance npm et le SBOM | @@ -944,6 +950,36 @@ Les politiques initiales couvrent : Seul un `Admin` peut modifier les politiques. Un `Auditor` peut exécuter une simulation et consulter les décisions ; un `Reader` dispose d’un accès en lecture. +### TrustMap Runtime Gateway pour Claude Desktop + +La passerelle locale transforme le point de décision en contrôle effectif. Elle +relaye le protocole JSON-RPC `stdio`, mais intercepte chaque `tools/call` avant +qu’il atteigne le serveur. Seuls le nom du serveur, le nom de l’outil, le type +d’opération, la classe de données, le niveau de confiance et le score de risque +sont envoyés à MCP TrustMap. Les arguments et résultats restent locaux. + +Créez d’abord un jeton dans **TrustMap Agent**, puis lancez un serveur protégé : + +```powershell +$env:TRUSTMAP_AGENT_TOKEN="" +npm run gateway -- --endpoint https://app.votre-domaine.tld --server "Filesystem MCP" -- npx -y @modelcontextprotocol/server-filesystem C:\dossier-autorisé +``` + +Dans la configuration Claude Desktop, remplacez la commande directe du serveur +par `npm run gateway` et placez ses paramètres avant le séparateur `--`. Le jeton +doit provenir de l’environnement du processus Claude ou d’un gestionnaire de +secrets ; la passerelle le retire explicitement de l’environnement du serveur +MCP enfant. + +Par défaut : + +- `deny` et `review` bloquent l’appel avant exécution ; +- une opération de lecture reçoit un risque de 25, une écriture 60, une + exécution 85 et une administration 90 ; +- une panne, un dépassement de délai ou une réponse invalide bloque l’appel ; +- `--fail-open` et `--allow-review` sont des dérogations explicites à réserver à + une phase d’observation contrôlée. + ## Déploiement autonome avec domaine personnalisé Le modèle `wrangler.example.jsonc` déploie MCP TrustMap sur Cloudflare Workers, @@ -986,9 +1022,9 @@ modèle de production. - la découverte reste explicite sans TrustMap Agent ; l’Agent doit être installé comme service par l’équipe cliente pour une collecte périodique ; -- TrustMap Runtime fournit le point de décision et le journal, mais ne peut pas - bloquer un appel si le client, sidecar ou proxy n’interroge pas l’API avant - l’exécution ; +- TrustMap Runtime Gateway protège les serveurs `stdio` configurés derrière lui, + mais les transports HTTP distants nécessitent encore un proxy ou sidecar qui + interroge l’API avant l’exécution ; - par sécurité, le collecteur n’exécute pas les serveurs `stdio` et ne confirme donc pas leur comportement à l’exécution ; - le probe distant valide la négociation, pas les permissions effectives de diff --git a/app/globals.css b/app/globals.css index 03baadc..8ad50fa 100644 --- a/app/globals.css +++ b/app/globals.css @@ -4975,6 +4975,17 @@ button.table-row:hover { .evaluation-result strong { display: block; margin: 5px 0; } .evaluation-result p, .evaluation-result small { font-size: 10px; line-height: 1.45; margin: 0; } +.runtime-gateway-card { + background: linear-gradient(135deg, rgba(21, 122, 85, 0.08), rgba(38, 91, 196, 0.05)); + border-color: rgba(21, 122, 85, 0.22); + margin-bottom: 14px; +} +.runtime-gateway-card > p { color: var(--muted); font-size: 11px; line-height: 1.6; max-width: 860px; } +.gateway-mode { background: var(--green-soft); border-radius: 99px; color: var(--green); font-size: 8px; font-weight: 800; letter-spacing: .08em; padding: 5px 8px; } +.gateway-command { background: #111b18; border-radius: 10px; color: #d8f5e8; font-size: 10px; line-height: 1.55; margin: 14px 0; overflow-x: auto; padding: 14px; white-space: pre-wrap; word-break: break-word; } +.gateway-actions { align-items: center; display: flex; gap: 12px; } +.gateway-actions small { color: var(--muted); line-height: 1.45; } + .runtime-events-card { margin-bottom: 22px; } .runtime-event-table { overflow-x: auto; } .runtime-event-row { @@ -5008,6 +5019,7 @@ button.table-row:hover { .agent-row { grid-template-columns: auto 1fr auto; } .agent-row .text-button { grid-column: 2 / -1; justify-self: start; } .runtime-state { min-width: 0; } + .gateway-actions { align-items: flex-start; flex-direction: column; } } @media (max-width: 560px) { diff --git a/app/trustmap-runtime.tsx b/app/trustmap-runtime.tsx index 3c100a1..00fe6dd 100644 --- a/app/trustmap-runtime.tsx +++ b/app/trustmap-runtime.tsx @@ -51,6 +51,7 @@ export function TrustMapRuntime({ const [destinationTrust, setDestinationTrust] = useState<"trusted" | "external" | "unknown">("trusted"); const [risk, setRisk] = useState(65); const [containsSensitiveInput, setContainsSensitiveInput] = useState(false); + const [gatewayOrigin, setGatewayOrigin] = useState("https://app.votre-domaine.tld"); const load = async () => { setLoading(true); @@ -75,7 +76,10 @@ export function TrustMapRuntime({ }; useEffect(() => { - const timer = window.setTimeout(() => void load(), 0); + const timer = window.setTimeout(() => { + setGatewayOrigin(window.location.origin); + void load(); + }, 0); return () => window.clearTimeout(timer); }, []); @@ -86,6 +90,17 @@ export function TrustMapRuntime({ tools: new Set(events.map((event) => `${event.server}:${event.tool}`)).size, }), [events]); + const gatewayCommand = `$env:TRUSTMAP_AGENT_TOKEN=""\nnpm run gateway -- --endpoint ${gatewayOrigin} --server "Filesystem MCP" -- npx -y @modelcontextprotocol/server-filesystem C:\\dossier-autorise`; + + const copyGatewayCommand = async () => { + try { + await navigator.clipboard.writeText(gatewayCommand); + onNotify("Commande de passerelle copiée"); + } catch { + onNotify("Copie automatique indisponible"); + } + }; + const patchPolicy = (id: string, patch: Partial) => { setPolicies((current) => current.map((policy) => policy.id === id ? { ...policy, ...patch } : policy)); setDirty(true); @@ -220,6 +235,21 @@ export function TrustMapRuntime({ +
+
+
PROTECTION LOCALE

TrustMap Runtime Gateway

+ FAIL CLOSED +
+

+ Placez cette passerelle devant un serveur MCP stdio de Claude Desktop ou d’un autre client. Chaque appel d’outil est autorisé par Runtime avant d’atteindre le serveur ; les arguments, résultats et jetons ne quittent jamais le poste. +

+
{gatewayCommand}
+
+ + Les décisions « bloquer » et « validation humaine » arrêtent l’appel. Une panne du point de décision le bloque également par défaut. +
+
+
JOURNAL D’EXÉCUTION

Décisions récentes

diff --git a/lib/runtime-gateway.ts b/lib/runtime-gateway.ts new file mode 100644 index 0000000..021a81c --- /dev/null +++ b/lib/runtime-gateway.ts @@ -0,0 +1,223 @@ +import type { + RuntimeDataClass, + RuntimeDecision, + RuntimeEvaluation, + RuntimeEvaluationRequest, + RuntimeOperation, +} from "./runtime-policy.ts"; + +export const MAX_GATEWAY_MESSAGE_BYTES = 1_048_576; + +export type RuntimeGatewayOptions = { + endpoint: string; + token: string; + server: string; + agentName: string; + dataClass: RuntimeDataClass; + destinationTrust: RuntimeEvaluationRequest["destinationTrust"]; + containsSensitiveInput: boolean; + risk?: number; + failOpen: boolean; + allowReview: boolean; + timeoutMs: number; +}; + +export type RuntimeGatewayResult = { + action: "forward" | "block" | "ignore"; + response?: Record; + evaluation?: RuntimeEvaluation; + correlationId?: string; + reason?: string; +}; + +type JsonRpcRequest = { + jsonrpc?: unknown; + id?: unknown; + method?: unknown; + params?: unknown; +}; + +type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise; + +const operationPatterns: Array<[RuntimeOperation, RegExp]> = [ + ["admin", /(?:^|[_-])(admin|permission|grant|revoke|credential|config|setting)(?:$|[_-])/i], + ["execute", /(?:^|[_-])(execute|exec|run|shell|command|deploy|install|start|stop|restart)(?:$|[_-])/i], + ["write", /(?:^|[_-])(write|create|update|delete|remove|move|copy|rename|patch|edit|upload|send|post|put)(?:$|[_-])/i], + ["read", /(?:^|[_-])(read|get|list|search|find|fetch|query|download|view|inspect|scan|audit)(?:$|[_-])/i], +]; + +const operationRisk: Record = { + read: 25, + write: 60, + execute: 85, + admin: 90, + unknown: 55, +}; + +function compact(value: string, maxLength: number): string { + return value.trim().replace(/\s+/g, " ").slice(0, maxLength); +} + +function isJsonRpcId(value: unknown): value is string | number | null { + return value === null || typeof value === "string" || (typeof value === "number" && Number.isFinite(value)); +} + +function gatewayError( + request: JsonRpcRequest, + code: number, + message: string, + data: Record, +): Record | undefined { + if (!("id" in request) || !isJsonRpcId(request.id)) return undefined; + return { + jsonrpc: "2.0", + id: request.id, + error: { code, message, data }, + }; +} + +function unavailableResult( + request: JsonRpcRequest, + correlationId: string, + options: RuntimeGatewayOptions, + reason: string, + mayFailOpen: boolean, +): RuntimeGatewayResult { + if (options.failOpen && mayFailOpen) { + return { action: "forward", correlationId, reason }; + } + return { + action: "block", + correlationId, + reason, + response: gatewayError(request, -32003, "Décision MCP TrustMap indisponible.", { + decision: "deny", + correlationId, + reason, + }), + }; +} + +export function inferRuntimeOperation(tool: string): RuntimeOperation { + const normalized = compact(tool, 160); + return operationPatterns.find(([, pattern]) => pattern.test(normalized))?.[0] ?? "unknown"; +} + +export function inferRuntimeRisk(operation: RuntimeOperation): number { + return operationRisk[operation]; +} + +export function sanitizeGatewayEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const sanitized = { ...environment }; + delete sanitized.TRUSTMAP_AGENT_TOKEN; + return sanitized; +} + +export async function authorizeRuntimeGatewayMessage( + line: string, + options: RuntimeGatewayOptions, + fetchImpl: FetchLike = fetch, +): Promise { + if (!line.trim()) return { action: "ignore" }; + if (new TextEncoder().encode(line).byteLength > MAX_GATEWAY_MESSAGE_BYTES) { + return { + action: "block", + reason: "Message JSON-RPC supérieur à la limite de 1 Mio.", + response: { + jsonrpc: "2.0", + id: null, + error: { code: -32004, message: "Message MCP trop volumineux." }, + }, + }; + } + + let request: JsonRpcRequest; + try { + request = JSON.parse(line) as JsonRpcRequest; + } catch { + return { action: "forward" }; + } + if (!request || typeof request !== "object" || request.method !== "tools/call") { + return { action: "forward" }; + } + const params = request.params; + if (!params || typeof params !== "object" || Array.isArray(params)) return { action: "forward" }; + const toolValue = (params as Record).name; + if (typeof toolValue !== "string" || !toolValue.trim()) return { action: "forward" }; + + const tool = compact(toolValue, 160); + const operation = inferRuntimeOperation(tool); + const correlationId = crypto.randomUUID(); + const payload: RuntimeEvaluationRequest = { + agentName: compact(options.agentName, 120), + server: compact(options.server, 160), + tool, + operation, + dataClass: options.dataClass, + destinationTrust: options.destinationTrust, + risk: options.risk ?? inferRuntimeRisk(operation), + containsSensitiveInput: options.containsSensitiveInput, + correlationId, + }; + + let response: Response; + try { + response = await fetchImpl(new URL("/api/runtime/evaluate", options.endpoint), { + method: "POST", + headers: { + Authorization: `Bearer ${options.token}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(options.timeoutMs), + }); + } catch { + return unavailableResult(request, correlationId, options, "Le point de décision ne répond pas.", true); + } + + let body: { evaluation?: RuntimeEvaluation; error?: string } = {}; + try { + body = await response.json() as typeof body; + } catch { + return unavailableResult( + request, + correlationId, + options, + "Le point de décision a renvoyé une réponse invalide.", + response.status >= 500, + ); + } + const evaluation = body.evaluation; + const decisions = new Set(["allow", "review", "deny", "observe"]); + if (!evaluation || !decisions.has(evaluation.decision)) { + return unavailableResult( + request, + correlationId, + options, + body.error || `Décision refusée (${response.status}).`, + response.status >= 500, + ); + } + + const blocked = evaluation.decision === "deny" || (evaluation.decision === "review" && !options.allowReview); + if (!blocked) return { action: "forward", evaluation, correlationId }; + const review = evaluation.decision === "review"; + return { + action: "block", + evaluation, + correlationId, + reason: evaluation.reason, + response: gatewayError( + request, + review ? -32002 : -32001, + review ? "Validation humaine requise par MCP TrustMap." : "Appel bloqué par MCP TrustMap.", + { + decision: evaluation.decision, + correlationId, + matchedPolicyId: evaluation.matchedPolicyId, + reason: evaluation.reason, + }, + ), + }; +} diff --git a/package.json b/package.json index 977b503..cbbed1b 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,14 @@ "start": "vinext start", "collect": "node --experimental-strip-types tools/collector.ts", "agent": "node --experimental-strip-types tools/agent.ts", + "gateway": "node --experimental-strip-types tools/gateway.ts", "audit:ci": "node --experimental-strip-types tools/collector.ts --no-default-paths --fail-on high --require-servers", "generate:admission": "node --experimental-strip-types tools/admission.ts", "collect:sbom": "node --experimental-strip-types tools/collector.ts --sbom", "collect:security": "node --experimental-strip-types tools/collector.ts --probe --osv --provenance --sbom", "validate:admission": "node --experimental-strip-types tools/validate-admission.ts", "test": "npm run build && npm run test:unit && npm run test:rendered", - "test:unit": "node --experimental-strip-types --test tests/asset-graph.test.ts tests/audit-engine.test.ts tests/audit-history.test.ts tests/ci-gate.test.ts tests/collector.test.ts tests/enterprise-authorization.test.ts tests/enterprise-sync.test.ts tests/finding-exceptions.test.ts tests/kubernetes-admission.test.ts tests/kubernetes-admission-validation.test.ts tests/lockfiles.test.ts tests/oci-provenance.test.ts tests/osv.test.ts tests/pdf-report.test.ts tests/provenance.test.ts tests/runtime-policy.test.ts tests/standalone-auth.test.ts tests/supply-chain.test.ts tests/trustmap-governance.test.ts tests/trustmap-modules.test.ts tests/workspaces.test.ts", + "test:unit": "node --experimental-strip-types --test tests/asset-graph.test.ts tests/audit-engine.test.ts tests/audit-history.test.ts tests/ci-gate.test.ts tests/collector.test.ts tests/enterprise-authorization.test.ts tests/enterprise-sync.test.ts tests/finding-exceptions.test.ts tests/kubernetes-admission.test.ts tests/kubernetes-admission-validation.test.ts tests/lockfiles.test.ts tests/oci-provenance.test.ts tests/osv.test.ts tests/pdf-report.test.ts tests/provenance.test.ts tests/runtime-gateway.test.ts tests/runtime-policy.test.ts tests/standalone-auth.test.ts tests/supply-chain.test.ts tests/trustmap-governance.test.ts tests/trustmap-modules.test.ts tests/workspaces.test.ts", "test:rendered": "node --test tests/rendered-html.test.mjs", "lint": "eslint . --ignore-pattern dist --ignore-pattern .next --ignore-pattern .vite", "db:generate": "drizzle-kit generate", diff --git a/tests/runtime-gateway.test.ts b/tests/runtime-gateway.test.ts new file mode 100644 index 0000000..b76d815 --- /dev/null +++ b/tests/runtime-gateway.test.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + authorizeRuntimeGatewayMessage, + inferRuntimeOperation, + inferRuntimeRisk, + sanitizeGatewayEnvironment, + type RuntimeGatewayOptions, +} from "../lib/runtime-gateway.ts"; + +const options: RuntimeGatewayOptions = { + endpoint: "https://trustmap.example.com", + token: "secret-agent-token", + server: "Filesystem MCP", + agentName: "poste-test", + dataClass: "internal", + destinationTrust: "unknown", + containsSensitiveInput: false, + failOpen: false, + allowReview: false, + timeoutMs: 1_000, +}; + +function decisionFetch(decision: "allow" | "review" | "deny" | "observe") { + return async (_input: string | URL | Request, init?: RequestInit) => { + const payload = JSON.parse(String(init?.body)) as Record; + assert.equal(payload.server, "Filesystem MCP"); + assert.equal(payload.tool, "write_file"); + assert.equal("arguments" in payload, false); + assert.equal("token" in payload, false); + assert.equal(new Headers(init?.headers).get("Authorization"), "Bearer secret-agent-token"); + return Response.json({ + evaluation: { + decision, + enforced: decision !== "observe", + matchedPolicyId: decision === "allow" ? null : "TM-RUN-TEST", + matchedPolicyName: "Test", + reason: `Décision ${decision}`, + }, + }, { status: decision === "deny" ? 403 : 200 }); + }; +} + +test("classifies tool operations and assigns conservative risk", () => { + assert.equal(inferRuntimeOperation("read_file"), "read"); + assert.equal(inferRuntimeOperation("write_file"), "write"); + assert.equal(inferRuntimeOperation("shell_execute"), "execute"); + assert.equal(inferRuntimeOperation("grant_permission"), "admin"); + assert.equal(inferRuntimeOperation("custom_tool"), "unknown"); + assert.equal(inferRuntimeRisk("execute"), 85); +}); + +test("forwards non-tool JSON-RPC messages without contacting Runtime", async () => { + let contacted = false; + const result = await authorizeRuntimeGatewayMessage( + JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }), + options, + async () => { contacted = true; return Response.json({}); }, + ); + assert.equal(result.action, "forward"); + assert.equal(contacted, false); +}); + +test("forwards an allowed tool call using metadata only", async () => { + const line = JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "write_file", arguments: { content: "private" } } }); + const result = await authorizeRuntimeGatewayMessage(line, options, decisionFetch("allow")); + assert.equal(result.action, "forward"); + assert.equal(result.evaluation?.decision, "allow"); +}); + +test("blocks deny and review decisions locally", async () => { + const line = JSON.stringify({ jsonrpc: "2.0", id: "call-3", method: "tools/call", params: { name: "write_file", arguments: { secret: "never-forwarded" } } }); + const denied = await authorizeRuntimeGatewayMessage(line, options, decisionFetch("deny")); + const review = await authorizeRuntimeGatewayMessage(line, options, decisionFetch("review")); + assert.equal(denied.action, "block"); + assert.equal((denied.response?.error as { code: number }).code, -32001); + assert.equal(review.action, "block"); + assert.equal((review.response?.error as { code: number }).code, -32002); + assert.equal(JSON.stringify(denied.response).includes("never-forwarded"), false); +}); + +test("fails closed on an unavailable decision point unless explicitly opened", async () => { + const line = JSON.stringify({ jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: "write_file" } }); + const unavailable = async () => { throw new Error("offline"); }; + const closed = await authorizeRuntimeGatewayMessage(line, options, unavailable); + const opened = await authorizeRuntimeGatewayMessage(line, { ...options, failOpen: true }, unavailable); + assert.equal(closed.action, "block"); + assert.equal((closed.response?.error as { code: number }).code, -32003); + assert.equal(opened.action, "forward"); +}); + +test("never passes the enrollment token to the protected MCP process", () => { + const environment = sanitizeGatewayEnvironment({ TRUSTMAP_AGENT_TOKEN: "top-secret", PATH: "bin" }); + assert.equal(environment.TRUSTMAP_AGENT_TOKEN, undefined); + assert.equal(environment.PATH, "bin"); +}); diff --git a/tools/gateway.ts b/tools/gateway.ts new file mode 100644 index 0000000..c48ed3f --- /dev/null +++ b/tools/gateway.ts @@ -0,0 +1,159 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { hostname } from "node:os"; +import { createInterface } from "node:readline"; +import { + authorizeRuntimeGatewayMessage, + sanitizeGatewayEnvironment, + type RuntimeGatewayOptions, +} from "../lib/runtime-gateway.ts"; +import type { RuntimeDataClass, RuntimeEvaluationRequest } from "../lib/runtime-policy.ts"; + +type GatewayCommand = RuntimeGatewayOptions & { + command: string; + commandArguments: string[]; +}; + +function help(): string { + return `TrustMap Runtime Gateway + +Intercepte les appels tools/call d'un serveur MCP stdio et consulte les +politiques MCP TrustMap avant de les transmettre. + +Usage: + $env:TRUSTMAP_AGENT_TOKEN="" + npm run gateway -- --endpoint https://trustmap.example.com --server "Filesystem MCP" -- npx -y @modelcontextprotocol/server-filesystem C:\\dossier + +Options: + --endpoint URL publique de MCP TrustMap + --server Nom stable du serveur MCP + --agent-name Identité du poste (défaut : nom de la machine) + --data-class public, internal, confidential ou restricted + --destination-trust trusted, external ou unknown (défaut : unknown) + --risk <0-100> Risque fixe ; sinon calculé d'après le nom de l'outil + --sensitive-input Marque tous les appels comme contenant une entrée sensible + --timeout Délai du point de décision, de 100 à 30000 (défaut : 3000) + --allow-review Transmet les décisions review ; bloquées par défaut + --fail-open Transmet uniquement si le service est indisponible ; fermé par défaut + --help Affiche cette aide + +Le séparateur -- est obligatoire avant la commande du serveur MCP. Le jeton +n'est jamais transmis au processus enfant, ni écrit dans les journaux.`; +} + +function valueAfter(args: string[], index: number, label: string): string { + const value = args[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${label} exige une valeur.`); + return value; +} + +function parseArguments(args: string[]): GatewayCommand | null { + if (args.includes("--help")) { + process.stdout.write(`${help()}\n`); + return null; + } + const separator = args.indexOf("--"); + if (separator < 0 || separator === args.length - 1) { + throw new Error("Ajoutez -- puis la commande du serveur MCP à protéger."); + } + const gatewayArgs = args.slice(0, separator); + const commandArguments = args.slice(separator + 1); + let endpoint = process.env.TRUSTMAP_ENDPOINT?.trim() ?? ""; + let server = ""; + let agentName = process.env.TRUSTMAP_AGENT_NAME?.trim() || hostname(); + let dataClass: RuntimeDataClass = "internal"; + let destinationTrust: RuntimeEvaluationRequest["destinationTrust"] = "unknown"; + let risk: number | undefined; + let containsSensitiveInput = false; + let timeoutMs = 3_000; + let allowReview = false; + let failOpen = false; + + for (let index = 0; index < gatewayArgs.length; index += 1) { + const argument = gatewayArgs[index]; + if (argument === "--endpoint") endpoint = valueAfter(gatewayArgs, index++, argument); + else if (argument === "--server") server = valueAfter(gatewayArgs, index++, argument); + else if (argument === "--agent-name") agentName = valueAfter(gatewayArgs, index++, argument); + else if (argument === "--data-class") { + const value = valueAfter(gatewayArgs, index++, argument); + if (!["public", "internal", "confidential", "restricted"].includes(value)) throw new Error("--data-class est invalide."); + dataClass = value as RuntimeDataClass; + } else if (argument === "--destination-trust") { + const value = valueAfter(gatewayArgs, index++, argument); + if (!["trusted", "external", "unknown"].includes(value)) throw new Error("--destination-trust est invalide."); + destinationTrust = value as typeof destinationTrust; + } else if (argument === "--risk") { + risk = Number(valueAfter(gatewayArgs, index++, argument)); + if (!Number.isInteger(risk) || risk < 0 || risk > 100) throw new Error("--risk doit être compris entre 0 et 100."); + } else if (argument === "--timeout") { + timeoutMs = Number(valueAfter(gatewayArgs, index++, argument)); + if (!Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 30_000) throw new Error("--timeout doit être compris entre 100 et 30000 ms."); + } else if (argument === "--sensitive-input") containsSensitiveInput = true; + else if (argument === "--allow-review") allowReview = true; + else if (argument === "--fail-open") failOpen = true; + else throw new Error(`Option inconnue : ${argument}`); + } + + const token = process.env.TRUSTMAP_AGENT_TOKEN?.trim() ?? ""; + if (!token) throw new Error("TRUSTMAP_AGENT_TOKEN est obligatoire."); + if (!endpoint) throw new Error("--endpoint ou TRUSTMAP_ENDPOINT est obligatoire."); + if (!server.trim()) throw new Error("--server est obligatoire."); + const endpointUrl = new URL(endpoint); + if (endpointUrl.protocol !== "https:" && !["localhost", "127.0.0.1", "::1"].includes(endpointUrl.hostname)) { + throw new Error("L'endpoint doit utiliser HTTPS, sauf pour le développement local."); + } + + return { + endpoint: endpointUrl.origin, + token, + server: server.trim().slice(0, 160), + agentName: agentName.trim().slice(0, 120), + dataClass, + destinationTrust, + containsSensitiveInput, + risk, + timeoutMs, + allowReview, + failOpen, + command: commandArguments[0], + commandArguments: commandArguments.slice(1), + }; +} + +async function main() { + const options = parseArguments(process.argv.slice(2)); + if (!options) return; + const child = spawn(options.command, options.commandArguments, { + stdio: ["pipe", "pipe", "pipe"], + shell: false, + windowsHide: true, + env: sanitizeGatewayEnvironment(process.env), + }); + const closePromise = new Promise((resolveExit) => child.once("close", (code) => resolveExit(code ?? 0))); + child.stdout.pipe(process.stdout, { end: false }); + child.stderr.pipe(process.stderr, { end: false }); + child.once("error", (error) => process.stderr.write(`Serveur MCP impossible à démarrer : ${error.message}\n`)); + + const input = createInterface({ input: process.stdin, crlfDelay: Infinity }); + child.once("close", () => input.close()); + for await (const line of input) { + const result = await authorizeRuntimeGatewayMessage(line, options); + if (result.action === "forward") { + if (!child.stdin.destroyed) child.stdin.write(`${line}\n`); + continue; + } + if (result.response) process.stdout.write(`${JSON.stringify(result.response)}\n`); + if (result.action === "block") { + process.stderr.write(`MCP TrustMap · appel bloqué · corrélation ${result.correlationId ?? "indisponible"}\n`); + } + } + if (!child.stdin.destroyed) child.stdin.end(); + const exitCode = await closePromise; + if (exitCode !== 0) process.exitCode = exitCode; +} + +void main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : "Échec de la passerelle Runtime."}\n`); + process.exitCode = 1; +});