Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 ;
Expand Down Expand Up @@ -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 <url> --server <nom> -- <commande MCP>` | Protège un serveur MCP `stdio` avec les politiques Runtime |
| `npm run audit:ci -- --path <fichier>` | 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 |
Expand Down Expand Up @@ -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="<jeton-affiché-une-fois>"
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,
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
32 changes: 31 additions & 1 deletion app/trustmap-runtime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
}, []);

Expand All @@ -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="<jeton-agent>"\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<RuntimePolicy>) => {
setPolicies((current) => current.map((policy) => policy.id === id ? { ...policy, ...patch } : policy));
setDirty(true);
Expand Down Expand Up @@ -220,6 +235,21 @@ export function TrustMapRuntime({
</article>
</div>

<article className="module-card runtime-gateway-card">
<div className="module-card-head">
<div><span className="section-kicker">PROTECTION LOCALE</span><h3>TrustMap Runtime Gateway</h3></div>
<span className="gateway-mode">FAIL CLOSED</span>
</div>
<p>
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.
</p>
<pre className="gateway-command"><code>{gatewayCommand}</code></pre>
<div className="gateway-actions">
<button className="button secondary compact" type="button" onClick={copyGatewayCommand}>Copier la commande</button>
<small>Les décisions « bloquer » et « validation humaine » arrêtent l’appel. Une panne du point de décision le bloque également par défaut.</small>
</div>
</article>

<article className="module-card runtime-events-card">
<div className="module-card-head"><div><span className="section-kicker">JOURNAL D’EXÉCUTION</span><h3>Décisions récentes</h3></div><button className="text-button" onClick={() => void load()}>Actualiser</button></div>
<div className="runtime-event-table">
Expand Down
223 changes: 223 additions & 0 deletions lib/runtime-gateway.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<Response>;

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<RuntimeOperation, number> = {
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<string, unknown>,
): Record<string, unknown> | 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<RuntimeGatewayResult> {
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<string, unknown>).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<RuntimeDecision>(["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,
},
),
};
}
Loading