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
92 changes: 91 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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 ;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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="<jeton-affiché-une-fois>"
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="<jeton-affiché-une-fois>"
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,
Expand Down Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions app/api/agent-ingest/route.ts
Original file line number Diff line number Diff line change
@@ -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,
);
}
}
139 changes: 139 additions & 0 deletions app/api/agents/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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);
}
}
58 changes: 58 additions & 0 deletions app/api/asset-graph/route.ts
Original file line number Diff line number Diff line change
@@ -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,
);
}
}
18 changes: 16 additions & 2 deletions app/api/auth/[...all]/route.ts
Original file line number Diff line number Diff line change
@@ -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<typeof toNextJsHandler>;
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);
}
Loading