From d37c921e02f5c2ad4568db63b24a19228e2123fc Mon Sep 17 00:00:00 2001 From: Dan G Date: Wed, 12 Aug 2026 17:03:51 +0200 Subject: [PATCH 1/3] Add TrustMap Agent, Asset Graph, and Runtime --- README.md | 92 +- app/api/agent-ingest/route.ts | 43 + app/api/agents/route.ts | 139 +++ app/api/asset-graph/route.ts | 58 ++ app/api/runtime/evaluate/route.ts | 81 ++ app/api/runtime/events/route.ts | 44 + app/api/runtime/policies/route.ts | 50 + app/dashboard.tsx | 20 +- app/globals.css | 339 +++++++ app/trustmap-agent.tsx | 283 ++++++ app/trustmap-runtime.tsx | 241 +++++ db/index.ts | 147 +++ db/schema.ts | 141 +++ drizzle/0003_magical_zodiak.sql | 91 ++ drizzle/meta/0003_snapshot.json | 1555 +++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + lib/agent-auth.ts | 59 ++ lib/asset-graph-store.ts | 245 +++++ lib/asset-graph.ts | 356 +++++++ lib/runtime-policy.ts | 224 +++++ lib/runtime-store.ts | 173 ++++ package.json | 3 +- tests/asset-graph.test.ts | 61 ++ tests/rendered-html.test.mjs | 22 +- tests/runtime-policy.test.ts | 67 ++ tools/agent.ts | 130 +++ 26 files changed, 4667 insertions(+), 4 deletions(-) create mode 100644 app/api/agent-ingest/route.ts create mode 100644 app/api/agents/route.ts create mode 100644 app/api/asset-graph/route.ts create mode 100644 app/api/runtime/evaluate/route.ts create mode 100644 app/api/runtime/events/route.ts create mode 100644 app/api/runtime/policies/route.ts create mode 100644 app/trustmap-agent.tsx create mode 100644 app/trustmap-runtime.tsx create mode 100644 drizzle/0003_magical_zodiak.sql create mode 100644 drizzle/meta/0003_snapshot.json create mode 100644 lib/agent-auth.ts create mode 100644 lib/asset-graph-store.ts create mode 100644 lib/asset-graph.ts create mode 100644 lib/runtime-policy.ts create mode 100644 lib/runtime-store.ts create mode 100644 tests/asset-graph.test.ts create mode 100644 tests/runtime-policy.test.ts create mode 100644 tools/agent.ts diff --git a/README.md b/README.md index 04304be..86849bc 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,12 @@ directement applicables. - **TrustMap Discover** construit la cartographie des serveurs, sources, transports et composants découverts par le collecteur local ou par import. +- **TrustMap Agent** exécute cette découverte de façon périodique sur les postes + et environnements enrôlés. Il transmet uniquement un graphe d’actifs assaini, + avec des jetons d’enrôlement hachés et révocables. +- **AI Asset Graph** relie agents, clients IA, serveurs MCP, outils observés, + transports, composants, responsables et contrôles pour rendre visibles les + chemins de confiance et les concentrations de risque. - **TrustMap Audit** applique le référentiel de sécurité, priorise les écarts, fournit les correctifs, gère les exceptions et conserve l’historique agrégé. - **TrustMap CI** simule une politique sur l’inventaire courant et génère la @@ -22,6 +28,9 @@ directement applicables. - **TrustMap Enterprise** mesure la couverture des propriétaires et des preuves, présente la posture par équipe, synchronise le registre chiffré par organisation, applique les rôles et pilote les approbations critiques. +- **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. 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, @@ -37,6 +46,14 @@ requête. - import d’un fichier JSON ou collage direct d’une configuration ; - collecteur local multiplateforme pour inventorier les configurations connues ; +- agent périodique Windows, macOS et Linux avec enrôlement révocable ; +- graphe persistant des actifs IA et de leurs relations de confiance ; +- découverte progressive des outils à partir des décisions Runtime ; +- politiques Runtime en modes observation et application ; +- 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 ; +- 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 ; - vérification MCP passive et optionnelle des endpoints HTTPS ; @@ -84,6 +101,10 @@ L’analyse statique est effectuée dans le navigateur. La découverte est effec par un collecteur local explicite : - aucune configuration importée n’est envoyée à un service distant ; +- une synchronisation manuelle ou TrustMap Agent envoie uniquement les noms + d’actifs, catégories, scores, preuves minimales et relations du graphe ; +- les chemins locaux, arguments de commande, extraits de configuration, URL, + en-têtes et valeurs sensibles sont exclus du graphe central ; - les valeurs sensibles détectées ne sont jamais affichées ; - aucun secret n’est enregistré dans le stockage du navigateur ; - une copie locale du registre d’exceptions reste dans le navigateur et le @@ -858,6 +879,71 @@ une ancienne version. Les variables attendues et leurs formes sont documentées dans `.env.example` sans aucune valeur secrète. +## TrustMap Agent et AI Asset Graph + +Un administrateur ouvre **TrustMap Agent**, donne un nom au poste puis crée un +jeton d’enrôlement. Le jeton n’est affiché qu’une fois et sa valeur hachée est la +seule information conservée dans D1. + +Sur le poste à superviser : + +```powershell +$env:TRUSTMAP_AGENT_TOKEN="" +npm run agent -- --endpoint https://app.votre-domaine.tld --name poste-daniel +``` + +Pour une collecte continue toutes les cinq minutes : + +```powershell +$env:TRUSTMAP_AGENT_TOKEN="" +npm run agent -- --endpoint https://app.votre-domaine.tld --name poste-daniel --interval 300 +``` + +Le jeton doit être injecté par le gestionnaire de secrets ou le mécanisme de +service du système. Ne le placez ni dans Git, ni dans les arguments de commande, +ni dans un fichier d’inventaire. L’option `--probe` reste explicite et ne contacte +que les endpoints MCP HTTPS selon les protections du collecteur. + +Le module **AI Asset Graph** fusionne les instantanés de l’organisation et les +outils réellement observés par Runtime. La synchronisation depuis le navigateur +reste explicite ; un rôle `Reader` peut consulter le graphe mais pas le modifier. + +## TrustMap Runtime + +`POST /api/runtime/evaluate` est le point de décision à appeler **avant** +l’exécution d’un outil. Il accepte soit la session de l’application, soit le +jeton Bearer d’un TrustMap Agent. Exemple de corps assaini : + +```json +{ + "server": "Filesystem MCP", + "tool": "write_file", + "operation": "write", + "dataClass": "confidential", + "destinationTrust": "trusted", + "risk": 65, + "containsSensitiveInput": false, + "correlationId": "appel-unique-123" +} +``` + +La réponse contient `allow`, `review`, `deny` ou `observe`. Une décision `deny` +renvoie aussi le statut HTTP `403`, afin qu’un proxy ou sidecar échoue fermé. Le +mode `observe` indique la décision qui aurait été appliquée sans bloquer l’appel. +Les arguments et résultats des outils ne doivent jamais être transmis à cette +API ; seuls les métadonnées de décision et l’identifiant de corrélation sont +journalisés. + +Les politiques initiales couvrent : + +- l’exfiltration de données restreintes vers une destination externe ou inconnue ; +- les appels dont le risque atteint le seuil critique ; +- la validation humaine des écritures, exécutions et opérations administratives ; +- l’observation des entrées sensibles pendant la phase d’apprentissage. + +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. + ## Déploiement autonome avec domaine personnalisé Le modèle `wrangler.example.jsonc` déploie MCP TrustMap sur Cloudflare Workers, @@ -898,7 +984,11 @@ modèle de production. ## Limites actuelles -- la découverte doit être lancée explicitement sur chaque poste à inventorier ; +- 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 ; - 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/api/agent-ingest/route.ts b/app/api/agent-ingest/route.ts new file mode 100644 index 0000000..dc49044 --- /dev/null +++ b/app/api/agent-ingest/route.ts @@ -0,0 +1,43 @@ +import { ensureAiSecuritySchema, getD1 } from "../../../db"; +import { authenticateAgent } from "../../../lib/agent-auth"; +import { parseAssetGraphSnapshot } from "../../../lib/asset-graph"; +import { storeAssetGraph } from "../../../lib/asset-graph-store"; + +export const dynamic = "force-dynamic"; +const MAX_REQUEST_BYTES = 2_000_000; + +function responseJson(body: unknown, status = 200): Response { + return Response.json(body, { status, headers: { "Cache-Control": "no-store" } }); +} + +export async function POST(request: Request) { + try { + const agent = await authenticateAgent(request); + if (!agent) return responseJson({ error: "Jeton d’agent invalide ou révoqué." }, 401); + const contentType = request.headers.get("Content-Type") ?? ""; + if (!contentType.toLowerCase().startsWith("application/json")) return responseJson({ error: "Content-Type JSON obligatoire." }, 415); + const raw = await request.text(); + if (new TextEncoder().encode(raw).byteLength > MAX_REQUEST_BYTES) return responseJson({ error: "Le graphe dépasse la limite autorisée." }, 413); + const snapshot = parseAssetGraphSnapshot(JSON.parse(raw) as unknown); + snapshot.source = "trustmap-agent"; + const stored = await storeAssetGraph(agent.organizationId, snapshot, agent.id); + const reportedPlatform = snapshot.agent?.platform || agent.platform; + const reportedVersion = snapshot.agent?.version || agent.version; + await ensureAiSecuritySchema(); + await getD1() + .prepare( + `UPDATE trustmap_agents + SET last_seen_at = ?, platform = ?, version = ? + WHERE id = ? AND organization_id = ? AND revoked_at IS NULL`, + ) + .bind(Date.now(), reportedPlatform, reportedVersion, agent.id, agent.organizationId) + .run(); + return responseJson({ accepted: true, stored }); + } catch (error) { + const invalid = error instanceof SyntaxError || (error instanceof Error && /graph|node|edge/.test(error.message)); + return responseJson( + { error: invalid ? "L’inventaire assaini est invalide." : "L’inventaire n’a pas pu être reçu." }, + invalid ? 400 : 500, + ); + } +} diff --git a/app/api/agents/route.ts b/app/api/agents/route.ts new file mode 100644 index 0000000..f1f7c10 --- /dev/null +++ b/app/api/agents/route.ts @@ -0,0 +1,139 @@ +import { ensureAiSecuritySchema, getD1 } from "../../../db"; +import { createAgentToken, hashAgentToken } from "../../../lib/agent-auth"; +import { getAuthContext } from "../../../lib/auth/server"; + +export const dynamic = "force-dynamic"; + +function responseJson(body: unknown, status = 200): Response { + return Response.json(body, { status, headers: { "Cache-Control": "no-store" } }); +} + +function sameOrigin(request: Request): boolean { + const origin = request.headers.get("Origin"); + if (!origin) return false; + try { + return new URL(origin).origin === new URL(request.url).origin; + } catch { + return false; + } +} + +function clean(value: unknown, maxLength: number): string { + return typeof value === "string" ? value.trim().replace(/\s+/g, " ").slice(0, maxLength) : ""; +} + +export async function GET(request: Request) { + try { + const actor = await getAuthContext(request.headers); + if (!actor) return responseJson({ error: "Authentification requise." }, 401); + await ensureAiSecuritySchema(); + const result = await getD1() + .prepare( + `SELECT id, name, platform, version, token_prefix, created_at, last_seen_at, revoked_at + FROM trustmap_agents + WHERE organization_id = ? + ORDER BY created_at DESC + LIMIT 100`, + ) + .bind(actor.organizationId) + .all<{ + id: string; + name: string; + platform: string; + version: string; + token_prefix: string; + created_at: number; + last_seen_at: number | null; + revoked_at: number | null; + }>(); + return responseJson({ + agents: result.results.map((row) => ({ + id: row.id, + name: row.name, + platform: row.platform, + version: row.version, + tokenPrefix: row.token_prefix, + createdAt: new Date(row.created_at).toISOString(), + lastSeenAt: row.last_seen_at ? new Date(row.last_seen_at).toISOString() : null, + revokedAt: row.revoked_at ? new Date(row.revoked_at).toISOString() : null, + })), + }); + } catch { + return responseJson({ error: "Les agents n’ont pas pu être chargés." }, 500); + } +} + +export async function POST(request: Request) { + try { + if (!sameOrigin(request)) return responseJson({ error: "Origine de requête refusée." }, 403); + const actor = await getAuthContext(request.headers); + if (!actor) return responseJson({ error: "Authentification requise." }, 401); + if (actor.role !== "admin") return responseJson({ error: "Seul un administrateur peut enrôler un agent." }, 403); + const raw = await request.text(); + if (new TextEncoder().encode(raw).byteLength > 8_192) return responseJson({ error: "Requête trop volumineuse." }, 413); + const value = JSON.parse(raw) as Record; + const name = clean(value.name, 100); + const platform = clean(value.platform, 40) || "unknown"; + const version = clean(value.version, 32) || "1.0.0"; + if (!name) return responseJson({ error: "Le nom de l’agent est obligatoire." }, 400); + + await ensureAiSecuritySchema(); + const id = crypto.randomUUID(); + const token = createAgentToken(); + const tokenHash = await hashAgentToken(token); + const createdAt = Date.now(); + await getD1() + .prepare( + `INSERT INTO trustmap_agents + (id, organization_id, name, platform, version, token_hash, token_prefix, created_by_hash, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + id, + actor.organizationId, + name, + platform, + version, + tokenHash, + `${token.slice(0, 16)}…`, + actor.actorHash, + createdAt, + ) + .run(); + return responseJson( + { + agent: { id, name, platform, version, createdAt: new Date(createdAt).toISOString() }, + token, + warning: "Ce jeton ne sera plus affiché. Stockez-le dans TRUSTMAP_AGENT_TOKEN.", + }, + 201, + ); + } catch (error) { + return responseJson( + { error: error instanceof SyntaxError ? "La requête est invalide." : "L’agent n’a pas pu être enrôlé." }, + error instanceof SyntaxError ? 400 : 500, + ); + } +} + +export async function DELETE(request: Request) { + try { + if (!sameOrigin(request)) return responseJson({ error: "Origine de requête refusée." }, 403); + const actor = await getAuthContext(request.headers); + if (!actor) return responseJson({ error: "Authentification requise." }, 401); + if (actor.role !== "admin") return responseJson({ error: "Seul un administrateur peut révoquer un agent." }, 403); + const id = new URL(request.url).searchParams.get("id")?.trim() ?? ""; + if (!/^[0-9a-f-]{36}$/i.test(id)) return responseJson({ error: "Identifiant d’agent invalide." }, 400); + await ensureAiSecuritySchema(); + const result = await getD1() + .prepare( + `UPDATE trustmap_agents SET revoked_at = ? + WHERE id = ? AND organization_id = ? AND revoked_at IS NULL`, + ) + .bind(Date.now(), id, actor.organizationId) + .run(); + return responseJson({ revoked: (result.meta.changes ?? 0) === 1 }); + } catch { + return responseJson({ error: "L’agent n’a pas pu être révoqué." }, 500); + } +} diff --git a/app/api/asset-graph/route.ts b/app/api/asset-graph/route.ts new file mode 100644 index 0000000..e528ca8 --- /dev/null +++ b/app/api/asset-graph/route.ts @@ -0,0 +1,58 @@ +import { getAuthContext } from "../../../lib/auth/server"; +import { + parseAssetGraphSnapshot, + summarizeAssetGraph, +} from "../../../lib/asset-graph"; +import { readAssetGraph, storeAssetGraph } from "../../../lib/asset-graph-store"; + +export const dynamic = "force-dynamic"; +const MAX_REQUEST_BYTES = 2_000_000; + +function responseJson(body: unknown, status = 200): Response { + return Response.json(body, { status, headers: { "Cache-Control": "no-store" } }); +} + +function sameOrigin(request: Request): boolean { + const origin = request.headers.get("Origin"); + if (!origin) return false; + try { + return new URL(origin).origin === new URL(request.url).origin; + } catch { + return false; + } +} + +export async function GET(request: Request) { + try { + const actor = await getAuthContext(request.headers); + if (!actor) return responseJson({ error: "Authentification requise." }, 401); + const graph = await readAssetGraph(actor.organizationId); + return responseJson({ + ...graph, + summary: summarizeAssetGraph(graph.nodes, graph.edges), + }); + } catch { + return responseJson({ error: "Le graphe d’actifs n’a pas pu être chargé." }, 500); + } +} + +export async function POST(request: Request) { + try { + if (!sameOrigin(request)) return responseJson({ error: "Origine de requête refusée." }, 403); + const actor = await getAuthContext(request.headers); + if (!actor) return responseJson({ error: "Authentification requise." }, 401); + if (actor.role === "reader") return responseJson({ error: "Le rôle Reader ne peut pas synchroniser le graphe." }, 403); + const raw = await request.text(); + if (new TextEncoder().encode(raw).byteLength > MAX_REQUEST_BYTES) return responseJson({ error: "Le graphe dépasse la limite autorisée." }, 413); + const snapshot = parseAssetGraphSnapshot(JSON.parse(raw) as unknown); + snapshot.source = "browser"; + const stored = await storeAssetGraph(actor.organizationId, snapshot, null); + return responseJson({ stored }, 201); + } catch (error) { + const invalid = error instanceof SyntaxError || (error instanceof Error && error.message.startsWith("invalid")); + return responseJson( + { error: invalid ? "Le graphe d’actifs est invalide." : "Le graphe n’a pas pu être synchronisé." }, + invalid ? 400 : 500, + ); + } +} diff --git a/app/api/runtime/evaluate/route.ts b/app/api/runtime/evaluate/route.ts new file mode 100644 index 0000000..a05d54b --- /dev/null +++ b/app/api/runtime/evaluate/route.ts @@ -0,0 +1,81 @@ +import { authenticateAgent } from "../../../../lib/agent-auth"; +import { + createAssetEdgeExternalId, + createAssetExternalId, +} from "../../../../lib/asset-graph"; +import { upsertRuntimeAsset, upsertRuntimeEdge } from "../../../../lib/asset-graph-store"; +import { getAuthContext } from "../../../../lib/auth/server"; +import { + evaluateRuntimeRequest, + parseRuntimeEvaluationRequest, +} from "../../../../lib/runtime-policy"; +import { + appendRuntimeEvent, + readRuntimePolicies, +} from "../../../../lib/runtime-store"; + +export const dynamic = "force-dynamic"; +const MAX_REQUEST_BYTES = 32_768; + +function responseJson(body: unknown, status = 200): Response { + return Response.json(body, { status, headers: { "Cache-Control": "no-store" } }); +} + +function sameOrigin(request: Request): boolean { + const origin = request.headers.get("Origin"); + if (!origin) return false; + try { + return new URL(origin).origin === new URL(request.url).origin; + } catch { + return false; + } +} + +async function sha256(value: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +export async function POST(request: Request) { + try { + const actor = await getAuthContext(request.headers); + const agent = actor ? null : await authenticateAgent(request); + if (!actor && !agent) return responseJson({ error: "Session ou jeton d’agent requis." }, 401); + if (actor && !sameOrigin(request)) return responseJson({ error: "Origine de requête refusée." }, 403); + if (actor?.role === "reader") return responseJson({ error: "Le rôle Reader ne peut pas créer de décision Runtime." }, 403); + const raw = await request.text(); + if (new TextEncoder().encode(raw).byteLength > MAX_REQUEST_BYTES) return responseJson({ error: "La décision Runtime dépasse la limite autorisée." }, 413); + const input = parseRuntimeEvaluationRequest(JSON.parse(raw) as unknown); + const organizationId = actor?.organizationId ?? agent!.organizationId; + const agentId = agent?.id ?? input.agentId ?? null; + const policies = await readRuntimePolicies(organizationId); + const evaluation = evaluateRuntimeRequest(policies, { + ...input, + ...(agent ? { agentId: agent.id, agentName: agent.name } : {}), + }); + const actorHash = actor?.actorHash ?? await sha256(`mcp-trustmap:agent:${agent!.id}`); + const event = await appendRuntimeEvent(organizationId, actorHash, agentId, input, evaluation); + + const serverKey = createAssetExternalId("mcp-server", input.server); + const toolKey = createAssetExternalId("tool", `${input.server}:${input.tool}`); + await Promise.all([ + upsertRuntimeAsset(organizationId, "mcp-server", serverKey, input.server, input.risk, agentId), + upsertRuntimeAsset(organizationId, "tool", toolKey, input.tool, input.risk, agentId), + upsertRuntimeEdge( + organizationId, + createAssetEdgeExternalId(serverKey, "exposes", toolKey), + serverKey, + toolKey, + agentId, + ), + ]); + + return responseJson({ evaluation, event }, evaluation.decision === "deny" ? 403 : 200); + } catch (error) { + const invalid = error instanceof SyntaxError || (error instanceof Error && error.message === "invalid-runtime-request"); + return responseJson( + { error: invalid ? "La demande de décision Runtime est invalide." : "La décision Runtime n’a pas pu être calculée." }, + invalid ? 400 : 500, + ); + } +} diff --git a/app/api/runtime/events/route.ts b/app/api/runtime/events/route.ts new file mode 100644 index 0000000..563dd40 --- /dev/null +++ b/app/api/runtime/events/route.ts @@ -0,0 +1,44 @@ +import { ensureAiSecuritySchema, getD1 } from "../../../../db"; +import { getAuthContext } from "../../../../lib/auth/server"; +import { readRuntimeEvents } from "../../../../lib/runtime-store"; + +export const dynamic = "force-dynamic"; + +function responseJson(body: unknown, status = 200): Response { + return Response.json(body, { status, headers: { "Cache-Control": "no-store" } }); +} + +function sameOrigin(request: Request): boolean { + const origin = request.headers.get("Origin"); + if (!origin) return false; + try { + return new URL(origin).origin === new URL(request.url).origin; + } catch { + return false; + } +} + +export async function GET(request: Request) { + try { + const actor = await getAuthContext(request.headers); + if (!actor) return responseJson({ error: "Authentification requise." }, 401); + const limit = Number(new URL(request.url).searchParams.get("limit") ?? "100"); + return responseJson({ events: await readRuntimeEvents(actor.organizationId, limit) }); + } catch { + return responseJson({ error: "Les événements Runtime n’ont pas pu être chargés." }, 500); + } +} + +export async function DELETE(request: Request) { + try { + if (!sameOrigin(request)) return responseJson({ error: "Origine de requête refusée." }, 403); + const actor = await getAuthContext(request.headers); + if (!actor) return responseJson({ error: "Authentification requise." }, 401); + if (actor.role !== "admin") return responseJson({ error: "Seul un administrateur peut effacer le journal Runtime." }, 403); + await ensureAiSecuritySchema(); + await getD1().prepare("DELETE FROM runtime_events WHERE organization_id = ?").bind(actor.organizationId).run(); + return responseJson({ deleted: true }); + } catch { + return responseJson({ error: "Le journal Runtime n’a pas pu être effacé." }, 500); + } +} diff --git a/app/api/runtime/policies/route.ts b/app/api/runtime/policies/route.ts new file mode 100644 index 0000000..0edf5a2 --- /dev/null +++ b/app/api/runtime/policies/route.ts @@ -0,0 +1,50 @@ +import { getAuthContext } from "../../../../lib/auth/server"; +import { parseRuntimePolicies } from "../../../../lib/runtime-policy"; +import { readRuntimePolicies, replaceRuntimePolicies } from "../../../../lib/runtime-store"; + +export const dynamic = "force-dynamic"; + +function responseJson(body: unknown, status = 200): Response { + return Response.json(body, { status, headers: { "Cache-Control": "no-store" } }); +} + +function sameOrigin(request: Request): boolean { + const origin = request.headers.get("Origin"); + if (!origin) return false; + try { + return new URL(origin).origin === new URL(request.url).origin; + } catch { + return false; + } +} + +export async function GET(request: Request) { + try { + const actor = await getAuthContext(request.headers); + if (!actor) return responseJson({ error: "Authentification requise." }, 401); + return responseJson({ policies: await readRuntimePolicies(actor.organizationId) }); + } catch { + return responseJson({ error: "Les politiques Runtime n’ont pas pu être chargées." }, 500); + } +} + +export async function PUT(request: Request) { + try { + if (!sameOrigin(request)) return responseJson({ error: "Origine de requête refusée." }, 403); + const actor = await getAuthContext(request.headers); + if (!actor) return responseJson({ error: "Authentification requise." }, 401); + if (actor.role !== "admin") return responseJson({ error: "Seul un administrateur peut modifier les politiques Runtime." }, 403); + const raw = await request.text(); + if (new TextEncoder().encode(raw).byteLength > 100_000) return responseJson({ error: "Le pack de politiques est trop volumineux." }, 413); + const value = JSON.parse(raw) as Record; + const policies = parseRuntimePolicies(value.policies); + await replaceRuntimePolicies(actor.organizationId, policies); + return responseJson({ policies }); + } catch (error) { + const invalid = error instanceof SyntaxError || (error instanceof Error && error.message.includes("polic")); + return responseJson( + { error: invalid ? "Le pack de politiques Runtime est invalide." : "Les politiques n’ont pas pu être enregistrées." }, + invalid ? 400 : 500, + ); + } +} diff --git a/app/dashboard.tsx b/app/dashboard.tsx index 319d0b0..af059fa 100644 --- a/app/dashboard.tsx +++ b/app/dashboard.tsx @@ -45,14 +45,16 @@ import { } from "../lib/audit-history"; import { AuditHistoryView } from "./audit-history-view"; import { TrustMapDiscover } from "./trustmap-discover"; +import { TrustMapAgent } from "./trustmap-agent"; import { TrustMapCi } from "./trustmap-ci"; +import { TrustMapRuntime } from "./trustmap-runtime"; import { TrustMapEnterprise, type SharedExceptionSyncState, } from "./trustmap-enterprise"; import { mergeRiskExceptions } from "../lib/trustmap-governance"; -type View = "discover" | "audit" | "ci" | "enterprise"; +type View = "discover" | "agent" | "audit" | "ci" | "runtime" | "enterprise"; type AuditView = "overview" | "servers" | "rules" | "history"; export type DashboardIdentity = { @@ -1182,8 +1184,10 @@ export default function Dashboard({ const navItems: { id: View; label: string; icon: string }[] = [ { id: "discover", label: "TrustMap Discover", icon: "◎" }, + { id: "agent", label: "TrustMap Agent", icon: "◫" }, { id: "audit", label: "TrustMap Audit", icon: "✓" }, { id: "ci", label: "TrustMap CI", icon: "⌘" }, + { id: "runtime", label: "TrustMap Runtime", icon: "◉" }, { id: "enterprise", label: "TrustMap Enterprise", icon: "◇" }, ]; @@ -1259,8 +1263,10 @@ export default function Dashboard({

{view === "discover" && "TrustMap Discover"} + {view === "agent" && "TrustMap Agent & AI Asset Graph"} {view === "audit" && "TrustMap Audit"} {view === "ci" && "TrustMap CI"} + {view === "runtime" && "TrustMap Runtime"} {view === "enterprise" && "TrustMap Enterprise"}

@@ -1333,6 +1339,14 @@ export default function Dashboard({ /> )} + {view === "agent" && ( + + )} + {view === "audit" && (