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/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts
index f08a1d1..2f9e736 100644
--- a/app/api/auth/[...all]/route.ts
+++ b/app/api/auth/[...all]/route.ts
@@ -1,6 +1,20 @@
import { toNextJsHandler } from "better-auth/next-js";
-import { auth } from "../../../../lib/auth/server";
+import { getAuth } from "../../../../lib/auth/server";
export const dynamic = "force-dynamic";
-export const { GET, POST } = toNextJsHandler(auth);
+type AuthHandlers = ReturnType;
+let handlers: AuthHandlers | undefined;
+
+function getHandlers(): AuthHandlers {
+ handlers ??= toNextJsHandler(getAuth());
+ return handlers;
+}
+
+export async function GET(request: Request) {
+ return getHandlers().GET(request);
+}
+
+export async function POST(request: Request) {
+ return getHandlers().POST(request);
+}
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" && (