Skip to main content
Glama
MCP Vault

MCP Vault

by stephen94125
README.md
# 🔐 MCP Vault

> **Gestion sĂ©curisĂ©e des secrets pour agents IA — OpenBao embedded**

> 🇬🇧 [English version](README.en.md)

MCP Vault est un serveur [MCP](https://modelcontextprotocol.io/) qui fournit un coffre-fort de secrets pour les agents IA et les missions. Il embarque [OpenBao](https://openbao.org/) (fork open-source de HashiCorp Vault, Linux Foundation) comme moteur de chiffrement.

**Pensez 1Password, mais pour vos agents IA.**

### 📾 Console d'administration

|               Dashboard                |          Vaults & Secrets           |          Audit & Alertes           |
| :------------------------------------: | :---------------------------------: | :--------------------------------: |
| ![Dashboard](screenshoots/screen1.png) | ![Vaults](screenshoots/screen2.png) | ![Audit](screenshoots/screen3.png) |

---

## 📖 Documentation

| Document                                                | Description                                                                                                                                                                  |
| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [**ARCHITECTURE.md**](DESIGN/mcp-vault/ARCHITECTURE.md) | SpĂ©cification complĂšte — vision, architecture ASGI 6 couches, vaults, SSH CA, policies MCP (6 exemples prĂȘts Ă  l'emploi), sĂ©curitĂ© des clĂ©s unseal (3 facteurs), roadmap HSM |
| [**TECHNICAL.md**](DESIGN/mcp-vault/TECHNICAL.md)       | Documentation technique — modules source (incl. PKI v0.5.1), stack ASGI 6 couches, Docker, tests, dĂ©pendances, roadmap                                                     |
| [**SECURITY_AUDIT.md**](DESIGN/mcp-vault/SECURITY_AUDIT.md) | Rapport d'audit de sĂ©curitĂ© consolidĂ© — 60 findings V2.1, 28 corrigĂ©s, 13 rĂ©siduels documentĂ©s                                                                         |
| [**scripts/README.md**](scripts/README.md)              | Guide CLI complet — groupes de commandes, exemples, confort d'utilisation                                                                                                       |
| [**tests/README.md**](tests/README.md)                  | Guide d'exĂ©cution des tests — 4 niveaux, ~600 tests, commandes pour auditeurs                                                                                                |
| [**TEST_CATALOG.md**](tests/TEST_CATALOG.md)            | Catalogue des tests e2e — 15 catĂ©gories, 349 assertions, objectif de chaque section (pour auditeurs)                                                                        |

---

## ⚡ DĂ©marrage rapide

```bash
# 1. Cloner et configurer
cp .env.example .env
# Adapter les credentials S3 dans .env

# 2. Build et démarrer
docker compose build
docker compose up -d

# 3. Vérifier (depuis le conteneur)
docker compose exec mcp-vault python scripts/mcp_cli.py health

# 4. Tester (349 tests e2e)
docker compose exec mcp-vault python tests/test_e2e.py
```

### Lifecycle automatique

Au démarrage, MCP Vault :
1. Charge les tokens depuis S3
2. Restaure les donnĂ©es OpenBao (volume Docker en prioritĂ© ; sinon S3 — un Ă©chec S3 ambigu, ex. rĂ©seau, refuse le dĂ©marrage plutĂŽt que d'initialiser un coffre vide par-dessus une sauvegarde distante valide)
3. Démarre OpenBao, l'initialise (1Úre fois) et le déverrouille
4. **ClĂ©s unseal** : chiffrĂ©es (AES-256-GCM) sur S3, jamais en clair sur disque — uniquement en mĂ©moire
5. Active le sync S3 pĂ©riodique (60s, **conditionnel** : un PUT n'est effectuĂ© que si l'Ă©tat a changĂ© depuis le dernier upload rĂ©ussi — cf. `s3_sync.py`)

À l'arrĂȘt (`docker compose stop`) :
1. Scelle OpenBao 🔒
2. Upload final vers S3 đŸ“€
3. ArrĂȘte le processus — clĂ©s effacĂ©es de la mĂ©moire

---

## đŸ› ïž Outils MCP (39)

### System (2)

| Outil           | Description                                        |
| --------------- | -------------------------------------------------- |
| `system_health` | État de santĂ© (OpenBao + S3)                       |
| `system_about`  | Informations service (version, outils, plateforme) |

> 💡 **Introspection** : l'endpoint `/admin/api/whoami` et la commande CLI `whoami` permettent de vĂ©rifier l'identitĂ© et les permissions du token courant.

### Vaults — coffres de secrets (5)

| Outil                                  | Perm  | Description                                             |
| -------------------------------------- | ----- | ------------------------------------------------------- |
| `vault_create(vault_id, description?)` | write | Crée un vault (mount KV v2) + métadonnées (owner, date) |
| `vault_list()`                         | read  | Liste les vaults accessibles (filtrés par token)        |
| `vault_info(vault_id)`                 | read  | Détails d'un vault (métadonnées, secrets_count, owner)  |
| `vault_update(vault_id, description)`  | write | Met Ă  jour la description d'un vault                    |
| `vault_delete(vault_id, confirm)`      | admin | Supprime un vault et tous ses secrets ⚠              |

### Secrets (6)

| Outil                                       | Perm  | Description                                    |
| ------------------------------------------- | ----- | ---------------------------------------------- |
| `secret_write(vault_id, path, data, type?)` | write | Écrit un secret typĂ©                           |
| `secret_read(vault_id, path, version?)`     | read  | Lit un secret (derniÚre version ou spécifique) |
| `secret_list(vault_id, path?)`              | read  | Liste les clés d'un vault                      |
| `secret_delete(vault_id, path)`             | write | Supprime un secret et toutes ses versions      |
| `secret_types()`                            | read  | Liste les 14 types de secrets                  |
| `secret_generate_password(length?, ...)`    | read  | GénÚre un mot de passe CSPRNG                  |

### SSH Certificate Authority (5)

Chaque vault possĂšde sa **propre CA SSH isolĂ©e** — les CA sont cryptographiquement diffĂ©rentes entre vaults. Un certificat signĂ© par la CA d'un vault ne fonctionne PAS sur les serveurs configurĂ©s pour un autre vault.

| Outil                                                | Perm  | Description                                                 |
| ---------------------------------------------------- | ----- | ----------------------------------------------------------- |
| `ssh_ca_setup(vault_id, role, allowed_users?, ttl?)` | write | Configure une CA SSH + rĂŽle dans un vault                   |
| `ssh_sign_key(vault_id, role, public_key, ttl?)`     | read  | Signe une clĂ© publique → certificat Ă©phĂ©mĂšre                |
| `ssh_ca_public_key(vault_id)`                        | read  | Clé publique CA (pour `TrustedUserCAKeys` sur les serveurs) |
| `ssh_ca_list_roles(vault_id)`                        | read  | Liste les rÎles SSH CA configurés dans un vault             |
| `ssh_ca_role_info(vault_id, role)`                   | read  | Détails d'un rÎle (TTL, allowed_users, extensions)          |

### AccÚs SSH JIT opérateur (2) *(v0.9.0)*

| Outil | Perm | Description |
| --- | --- | --- |
| `ssh_operator_access_profiles()` | read | Liste les profils fermés utilisables par le bearer nominatif courant |
| `ssh_request_operator_access(profile_id, public_key, reason)` | write | Émet un certificat court liĂ© Ă  une clĂ© publique prĂ©-enrĂŽlĂ©e |

Le demandeur ne choisit jamais le coffre CA, le rĂŽle, le principal, la cible
ou le TTL. L'identité de l'opérateur repose sur un bearer token nominatif
scopĂ© par une policy dĂ©diĂ©e (mĂȘme mĂ©canisme que le reste du systĂšme), plus
une clé publique SSH standard (`ssh-ed25519`/`ecdsa-sha2-nistp256`)
pré-enrÎlée par empreinte. Ce parcours humain exceptionnel reste distinct du
breaking-glass externe et du parcours agentique.
Les profils peuvent ĂȘtre fournis par `SSH_OPERATOR_PROFILES_JSON` ou, pour un
renderer `.env` strict, par son équivalent Base64 URL-safe
`SSH_OPERATOR_PROFILES_B64` ; les deux sources sont mutuellement exclusives.

### Policies MCP — contrîle d'accùs granulaire (4)

Les policies permettent de restreindre finement les outils accessibles par token, avec support des **wildcards** (`system_*`, `ssh_*`...) et des **rùgles par vault** (`prod-*` → lecture seule).

| Outil                                                                                | Perm  | Description                                         |
| ------------------------------------------------------------------------------------ | ----- | --------------------------------------------------- |
| `policy_create(policy_id, description?, allowed_tools?, denied_tools?, path_rules?)` | admin | Crée une policy avec rÚgles d'accÚs                 |
| `policy_list()`                                                                      | admin | Liste les policies avec compteurs                   |
| `policy_get(policy_id)`                                                              | admin | Détails complets (allowed/denied tools, path_rules) |
| `policy_delete(policy_id, confirm)`                                                  | admin | Supprime une policy ⚠                            |

> 📋 6 policies prĂȘtes Ă  l'emploi documentĂ©es dans [ARCHITECTURE.md §6.4.1](DESIGN/mcp-vault/ARCHITECTURE.md) : `readonly`, `operator-ssh-jit`, `developer`, `prod-reader-dev-writer`, `ci-cd-agent`, `security-auditor`
>
> 🔒 **Fail-close sur panne S3** *(v0.8.3, #86)* : aprùs expiration du cache (5 min),
> une panne ou une corruption S3 détectée fait refuser explicitement les décisions
> de policy, plutĂŽt que de continuer Ă  servir silencieusement d'anciennes rĂšgles.

### Token Management (1)

| Outil                                                          | Perm  | Description                                              |
| -------------------------------------------------------------- | ----- | -------------------------------------------------------- |
| `token_update(hash_prefix, policy_id?, permissions?, vaults?)` | admin | Modifier un token existant (policy, permissions, vaults) |

> **Purge des tokens rĂ©voquĂ©s** *(v0.7.0 — opĂ©ration **admin**, exposĂ©e en REST / CLI / SPA ; ce n'est PAS un outil MCP)* : supprime dĂ©finitivement les tokens rĂ©voquĂ©s depuis plus de N jours (rĂ©tention, dĂ©faut 30). **Fail-close** (jamais un token actif ni un token expirĂ© non rĂ©voquĂ©), **dry-run + confirmation**, rollback si S3 indisponible, chaque purge **auditĂ©e**. Via `POST /admin/api/tokens/purge`, la commande CLI `token purge-revoked`, ou le bouton « đŸ§č Purger rĂ©voquĂ©s » de la console `/admin`.
>
> 🔒 **Validation stricte** *(v0.8.3, #86)* : `permissions`/`allowed_resources` sont
> dĂ©sormais validĂ©s strictement Ă  la crĂ©ation, la modification et le chargement —
> jamais permissifs par défaut sur une entrée malformée (corrige une élévation de
> privilĂšge possible via une valeur du mauvais type).

### PKI interne — CA + ACME (8) *(v0.5.0)*

CA souveraine pour l'écosystÚme : les WAF Caddy s'enrÎlent via ACME exactement comme avec Let's Encrypt, mais sur une CA interne isolée du réseau public. Utilisable en lab (`*.lesur.lan`) et en prod air-gapped.

| Outil | Perm | Description |
| --- | --- | --- |
| `pki_ca_setup(lab_mode, allowed_domains, leaf_ttl)` | admin | Initialise CA racine + intermédiaire + rÎle ACME |
| `pki_ca_public_key()` | read | CA racine PEM, empreinte SHA-256, URL stable |
| `pki_ca_list_roles()` | read | Liste les rÎles d'émission |
| `pki_ca_role_info(role_name)` | read | Détails rÎle (domaines, TTL, flags TLS) |
| `pki_list_certs(limit?, offset?)` | admin | Inventaire paginé des certificats émis |
| `pki_issue_cert(common_name, ttl?, alt_names?, ip_sans?)` | admin | Émission manuelle d'un certificat (hors ACME) — clĂ© privĂ©e one-shot |
| `pki_revoke_cert(serial_number)` | admin | Révocation + mise à jour CRL |
| `pki_ca_rotate_intermediate(keep_old_issuer?, overlap_ttl?)` | admin | Rotation CA intermédiaire sans coupure |

> Endpoints publics (non-auth, standard ACME/PKI) : `/acme/directory`, `/pki/ca/root.pem`, `/pki/ca/chain.pem`, `/pki/ca/crl.pem`
>
> **`PKI_BASE_URL`** (optionnel) : URL de base pour les CDPs et le cluster path OpenBao ACME. Vide = dĂ©duit de `MCP_ALLOWED_HOSTS`. Override test Docker : `http://mcp-vault:8030`. Doit ĂȘtre `http(s)://`.

### JIT Wrap Broker + consommation mĂ©diĂ©e — C18 (5) *(v0.4.13 / v0.6.x)*

Contrat pour le `CredentialBrokerService` de mcp-mission : livraison de credentials single-use via response wrapping OpenBao (cubbyhole), registry write-ahead sur S3 pour la compensation des orphelins, et validation anti-confused-deputy (C18).

> ⚠ **`ttl_seconds` borne la fenĂȘtre pour OBTENIR le secret, PAS la durĂ©e de vie du secret OBTENU** *(#156)*. L'enveloppe est Ă  usage unique et meurt Ă  son TTL (bornĂ© ici entre **60 s et 3600 s** ; `mcp-mission` se plafonne Ă  300 s, ce qui est **son** choix et non notre plafond). MCP Vault **n'impose aucune expiration** au secret KV ni au credential qu'il contient, et **sa validitĂ© rĂ©elle dĂ©pend de son Ă©metteur** — pas de nous.
> ⚠ Et ni l'un ni l'autre de nos deux verbes ne suffit Ă  l'invalider : `secret_write` **ajoute une version** (la prĂ©cĂ©dente reste lisible par son numĂ©ro), et `secret_delete` retire les versions **du coffre**, pas le mot de passe de la machine ni la clĂ© chez son fournisseur. **Seule une rĂ©vocation ou une rotation coordonnĂ©e chez l'Ă©metteur invalide effectivement un credential externe.**
> ⇒ Si une mission meurt aprĂšs un dĂ©ballage, **la durĂ©e d'exposition n'est bornĂ©e par personne**, et le remĂšde n'est pas dans cet outil.
> Ce que nous **ne** gardons pas : aucune copie de la valeur dĂ©ballĂ©e. Elle est rendue Ă  l'appelant sans ĂȘtre stockĂ©e, le champ `data` est retirĂ© de la piste d'audit, et aucun journal ne la porte. L'entrĂ©e de registre conserve l'accessor, `vault_id` et `secret_path` — **jamais la valeur**.

| Outil | Perm | Description |
| --- | --- | --- |
| `secret_wrap(vault_id, secret_path, mission_id, operation_id, ttl_seconds?, tenant_id?, expected_aud?)` | wrap | CrĂ©e un wrap token single-use (write-ahead registry). ⚠ **`tenant_id` OBLIGATOIRE si `ENFORCE_MISSION_TOKEN_VALIDATION=true`** — le serveur ne peut pas le dĂ©duire, et une provision au binding incomplet serait refusĂ©e Ă  la consommation *(#78)*. `expected_aud` absente (ou blanche) est complĂ©tĂ©e depuis la configuration ; une audience dĂ©signant une **autre** instance est refusĂ©e (`binding_mismatch`) car le wrap serait inconsommable ; configuration durcie incohĂ©rente (JWKS vide, audience non rĂ©soluble) → `misconfigured` |
| `secret_revoke_wrap(lease_id)` | wrap | RĂ©vocation idempotente, limitĂ©e au pĂ©rimĂštre de l'appelant ; registre non initialisĂ© → `error/registry_unavailable` *(#120)*. ⚠ Un accessor introuvable **dans un registre disponible** rend `ok/not_found` : c'est un **succĂšs d'appel**, pas une rĂ©vocation — aucun appel OpenBao n'a Ă©tĂ© tentĂ© (voir la table de recrĂ©ation plus bas) |
| `secret_wrap_lookup(operation_id, mission_id)` | wrap | Retrouve & **rĂ©voque** les wraps du couple (compensation orphelins #74), limitĂ© au pĂ©rimĂštre de l'appelant. ⚠ `mission_id` REQUIS : un operation_id n'est pas unique entre missions, et sans lui la compensation d'un orphelin rĂ©voquait la provision vivante d'une AUTRE mission |
| `secret_wrap_status(operation_id, mission_id)` | wrap | Consulte l'Ă©tat d'un wrap **sans le rĂ©voquer** — lecture seule, instantanĂ© best-effort (#77), limitĂ© au pĂ©rimĂštre de l'appelant. ⚠ `mission_id` REQUIS : sans lui, l'Ă©tat des provisions d'une autre mission Ă©tait divulguĂ© |
| `secret_consume(wrap_token, operation_id, mission_token)` | admin | Valide JWT ES256/JWKS (contrat PEP complet depuis *(#86)* : exp/iat/iss/aud/mission_id/jti/scope/tenant_id + `component_id`), vĂ©rifie binding complet (mission_id, tenant_id, aud) — en mode durci les DEUX champs sont exigĂ©s *(#78)*, unwrap OpenBao (C18). Contrat d'erreurs non rĂ©essayables : `wrap_unusable`, `consume_outcome_unknown`, `empty_secret` *(#78)* |

> *(#115)* Permission `wrap` (ou `admin`) : un jeton `wrap` non-admin doit porter `allowed_resources` non vide **et** une policy explicite (`allowed_tools` nommĂ©s, `path_rule` avec `allowed_paths` non vides — Ă©valuation stricte). Un jeton wrap-only (sans read/write/admin) est confinĂ© Ă  ces 4 outils (MCP) et refusĂ© sur tout `/admin/api/*`. ⚠ Downgrade ≀ 0.9.2 : rĂ©voquer/purger les jetons `wrap` d'abord (sinon `tokens.json` rejetĂ© en bloc, cf. CHANGELOG).
> Activer la validation C18 avec `ENFORCE_MISSION_TOKEN_VALIDATION=true`. Par dĂ©faut (false) : un jeton **invalide** est journalisĂ© puis non opposĂ© — zĂ©ro impact standalone sans mcp-mission.
> ⚠ *RectifiĂ© en v0.16.3 (#154) : « continue » Ă©tait trompeur.* Sans enforcement, un Ă©chec de validation laisse `mission_id` vide, donc la clĂ© composite ne correspond Ă  rien et la consommation est **refusĂ©e** au registre. Et surtout, ce qui exprime **notre propre incapacitĂ©** — service de clĂ©s injoignable, audience non rĂ©solue, validateur non initialisĂ© — est refusĂ© **dans les deux postures**, en `backend_unavailable` ou `misconfigured` : `enforce` arbitre si un jeton invalide est fatal, il n'arbitre pas si nous disons la vĂ©ritĂ© sur le motif du refus.
> `tenant_id` et `expected_aud` dans `secret_wrap` alimentent le binding C18 complet cÎté `secret_consume` *(v0.6.8)*.
> ⚠ *(#86)* DĂšs que `ENFORCE_MISSION_TOKEN_VALIDATION=true` (seul, ou via le PEP `/mcp` ci-dessous), `MISSION_JWKS_URL`, `MCP_INSTANCE_ID`/`MISSION_TOKEN_AUD` **et** `MISSION_STATUS_URL` deviennent obligatoires (fail-fast au boot) — sans quoi une mission abortĂ©e conserverait l'accĂšs jusqu'Ă  expiration du `mission_token`.
> ⚠ *(#86)* Le validateur applique dĂ©sormais EXACTEMENT le mĂȘme contrat que le PEP `/mcp` ci-dessous (mĂȘmes claims requis, mĂȘme vĂ©rification `component_id`) — un JWT authentique mais destinĂ© Ă  une autre instance vault est rejetĂ© par les deux points d'application, pas seulement le premier.

#### Matrice d'effet externe — ce qu'un code d'erreur permet de DÉDUIRE *(v0.14.0)*

**Tous les codes métier arrivent en ENVELOPPE, `isError = false`.** Un appelant qui
ne regarde que `isError` les prend pour des succĂšs. **Lire `status`, puis
`error_type`** — dans cet ordre, car certains refus n'ont pas encore de code
(voir la note en fin de section).

`isError = true` ne signale que deux choses : une **non-conformité au schéma**
(paramùtre requis absent **ou** de mauvais type — le corps de l'outil n'a pas
tourné, donc aucune écriture, aucun appel OpenBao, aucune révocation), ou un
défaut non prévu. Un paramÚtre conforme au schéma mais **refusé sémantiquement**
revient en enveloppe.

**`secret_wrap`** — le nom du code ne suffit pas : une ressource peut exister.

| Code | Appel OpenBao | Ressource Ă  compenser |
| --- | --- | --- |
| `invalid_input`, `backend_unavailable`, `operation_pending` | **absent** | non, pour cet appel |
| `operation_failed` *(v0.13.0)* | **absent** pour cet appel | ⚠ **possiblement, d'une tentative ANTÉRIEURE** — sans accessor, donc non compensable |
| `operation_revocable` *(v0.13.0)* | **absent** pour cet appel | ⚠ **possiblement, d'une provision ANTÉRIEURE** — mais elle porte un accessor, donc elle **est** compensable |
| `operation_terminated` *(v0.14.0)* | **absent** pour cet appel | non — provision antĂ©rieure tenue pour terminĂ©e, rien Ă  compenser |
| `registry_inconsistent` *(v0.14.0)* | **absent** pour cet appel | ⚠ **inconnu** — entrĂ©e illisible, rien ne peut en ĂȘtre dĂ©duit |
| `registry_unavailable` | **absent** | non |
| `wrap_created_revoked` | **wrap créé** | non — rĂ©vocation confirmĂ©e |
| `wrap_created_orphaned` | **wrap créé** | ⚠ **possiblement** — active jusqu'au TTL, **non compensable** |
| `not_found`, `backend_error` | **Ă©mis** | ⚠ **inconnu** — ne pas rejouer la mĂȘme clĂ© |

⚠ `backend_error` peut suivre un appel qu'OpenBao a reçu (dĂ©lai dĂ©passĂ©, coupure) ;
`not_found` est déduit d'un motif cherché dans le texte de l'exception, donc non
prouvé. Dans les deux cas l'intention passe `failed` **sans accessor**.

**La clĂ© est alors BLOQUÉE DÉFINITIVEMENT** — un rejeu rend `operation_failed`,
sans aucun appel OpenBao. *(Jusqu'en v0.12.1 elle redevenait rejouable, et le
rejeu créait un second wrap pendant que le premier pouvait vivre.)* Si la
persistance du marquage échoue, l'entrée revient `pending` et la clé est bloquée
sous `operation_pending` : le blocage est le mĂȘme, seul le code diffĂšre.
**Reprise = nouvel `operation_id`, dans tous les cas.**

> ⚠ **v0.14.0 — UNE CLÉ EST À USAGE UNIQUE, SANS CONDITION.** La moindre entrĂ©e
> pour le couple `(operation_id, mission_id)` bloque `secret_wrap`, quel que soit
> son état, **entrée corrompue comprise** : une entrée illisible est une raison de
> refuser, jamais d'autoriser. **Plus rien ne libÚre une clé.**
>
> En v0.13.0, `revoked`, `consumed` et `unusable` la rouvraient — pour prĂ©server
> la reprise « révoquer puis recréer » du broker. Celle-ci a été retirée de sa
> conception, et le recensement des porteurs de jeton `wrap` en production n'en a
> trouvé qu'un seul. Cette libération était aussi le dernier chemin par lequel un
> jeton **étranger** présenté à `secret_consume` pouvait marquer une entrée
> `unusable` — et libĂ©rer une clĂ© dont le wrap rĂ©el vivait encore.
>
> ⚠ **La garde ne porte QUE sur la crĂ©ation.** `secret_revoke_wrap`,
> `secret_wrap_status` et `secret_wrap_lookup` restent ouverts sur une clé
> engagée : sans cela l'appelant serait enfermé avec des ressources qu'il ne
> pourrait plus ni voir ni couper. Un test compte les appelants de la garde et
> échoue s'il en apparaßt un second.
>
> ⚠ **Deux contournements ne sont PAS fermĂ©s** : la perte du fichier de registre
> (registre vide = toutes les clés redeviennent vierges) et la course entre deux
> instances, faute de CAS/ETag *(#51)*. Ce ne sont pas des libérations métier,
> mais l'unicité n'est pas absolue pour autant.
>
> ⚠ **Ce premier contournement Ă©tait plus large que cette phrase ne le disait,
> jusqu'à v0.15.0 incluse** *(#149, corrigé en v0.16.0)*. Il ne fallait pas perdre
> le fichier : **il suffisait d'une erreur de stockage mal lue.** Le registre
> concluait « objet absent » dĂšs que le texte de l'erreur *contenait* « 404 » —
> un identifiant de requĂȘte, un port, un 404 Ă©mis par un intermĂ©diaire — puis
> repartait vide **en se déclarant fiable**, si bien que le refus fail-close ne
> se déclenchait pas et que la sonde ne signalait rien. **Une seconde porte
> menait au mĂȘme effet sans aucune erreur** : une lecture qui rĂ©ussissait en
> renvoyant un JSON valide mais sans la clé `wraps` (un `{}`, un corps tronqué)
> était prise pour un registre vide. Désormais, seule une absence authentifiée par
> le champ d'erreur structuré (`NoSuchKey`) vide le registre, et le document est
> validé avant toute mutation d'état ; tout le reste refuse la création. C'est la
> rĂšgle que les trois magasins d'autorisation appliquent depuis #69.

> Une intention `failed` (ou `pending`) sans accessor rend `found_unattached` sur `secret_wrap_lookup` : aucune rĂ©vocation n'est possible, seul le TTL borne la ressource Ă©ventuelle. ⚠ Ce verdict ne dit **pas** qu'une ressource existe — il dit que nous ne pouvons pas l'exclure. *(v0.12.1 : ce cas rĂ©pondait `already_revoked`, affirmant une rĂ©vocation inexistante.)*

**Les quatre autres outils**, sur les codes d'indisponibilité :

| Outil | Effet externe | Conduite |
| --- | --- | --- |
| `secret_revoke_wrap` — `ok` + `registry_persisted: false` *(v0.14.1)* | **rĂ©vocation FAITE**, mais non inscrite au registre | rejouer la compensation plus tard — voir l'encadrĂ© ci-dessous |
| `secret_revoke_wrap` — `registry_unavailable`, `backend_unavailable` | **absent**, aucune rĂ©vocation tentĂ©e | rejeu sĂ»r **et nĂ©cessaire** |
| `secret_revoke_wrap` — `backend_error` | appel **Ă©mis**, effet **inconnu** | rejeu sĂ»r (rĂ©vocation idempotente) |
| `secret_wrap_lookup` — `backend_unavailable` | **absent** : les deux gardes fail-close prĂ©cĂšdent toute rĂ©vocation | attente puis rejeu, sĂ»r |
| `secret_wrap_lookup` — `partial_revocation` | **inconnu** sur les entrĂ©es non comptĂ©es (`count_revoked` dit ce qui a abouti) | rejeu sĂ»r, mais il ne rĂ©soudra pas les entrĂ©es figĂ©es |
| `secret_wrap_status` — `backend_unavailable` | **absent** : registre absent ou dernier rafraĂźchissement S3 Ă©chouĂ© | rejeu toujours sĂ»r |

> ⚠ **UN EFFET CONFIRMÉ PEUT NE PAS SURVIVRE À UN REDÉMARRAGE** *(limite
> structurelle, #140 — **arbitrĂ©e**, cf. #123)*. Quand une rĂ©vocation rĂ©ussit
> cÎté coffre mais que l'écriture du registre échoue, le fait n'est inscrit
> **nulle part de durable** : au redémarrage, l'entrée **peut réapparaßtre non
> révoquée**, dans un état que nous venons d'annoncer révoqué à l'appelant.
>
> *« Peut » et non « va »* : une exception S3 ne prouve pas que l'écriture n'a
> pas abouti — un dĂ©lai d'attente peut suivre une Ă©criture dĂ©jĂ  acceptĂ©e — et le
> registre est en **dernier-écrivain-gagne sans verrou**, une autre instance a pu
> écrire entre-temps. L'appelant ne peut donc pas déduire l'état durable de
> l'échec ; il ne peut que le tenir pour **indéterminé**.
>
> **Ce n'est pas rĂ©parable avec un seul support durable** — inscrire le fait
> exigerait un support disponible au moment prĂ©cis oĂč le support habituel ne
> l'est pas. Le tenir supposerait un **second support** : un journal local,
> rejoué en réconciliation **avant** de resservir le registre. Le volume local
> existe dĂ©jĂ  dans le dĂ©ploiement ; le mĂ©canisme, non — et il serait **local Ă 
> l'instance**, invisible d'une autre et perdu avec l'hĂŽte.
>
> ⚠ **DÉCISION (#123) : ce second support ne sera pas construit.** La limite est
> **assumée et publiée**, elle n'est pas en attente d'un correctif.
> **Ne dimensionnez pas votre contrat en supposant qu'elle disparaĂźtra.**
>
> Ce que nous faisons Ă  la place, depuis **v0.14.1** : nous ne rendons pas le
> fait durable, mais nous **le disons** — la rĂ©ponse porte alors
> `registry_persisted: false` et un `warning`. Le champ est **absent** sur le
> chemin nominal.
>
> ⚠ **Le signal a la MÊME forme sur les DEUX verbes de rĂ©vocation** —
> `secret_revoke_wrap` et `secret_wrap_lookup` : `registry_persisted: false`,
> `count_not_persisted` (le nombre d'entrées effectivement muées et non
> inscrites) et un `warning`. Sur la compensation il est porté **aussi par les
> sorties `partial_revocation` et `consume_terminal`** — l'appelant y gĂšre dĂ©jĂ 
> un incident, c'est le pire moment pour lui taire une révocation non consignée.
>
> ⇒ **Conduite** : traiter un retour portant `registry_persisted: false` comme
> une rĂ©vocation **rĂ©elle mais non consignĂ©e** — ne pas clore son propre
> registre dessus, et repasser la compensation plus tard. La mĂȘme limite touche
> la rĂ©vocation d'urgence de `secret_wrap`, oĂč l'entrĂ©e reste `pending` et oĂč
> `secret_wrap_lookup` répondra `found_unattached` pour une ressource que nous
> **tenons pour** morte — le serveur traite un retour OpenBao sans exception
> comme une révocation confirmée, il ne relit rien pour l'attester.

> `secret_wrap_status` ne mute ni OpenBao ni le registre (cache et journal d'audit, eux, bougent). Il ne distingue **pas** le transitoire du durable : instantanĂ© best-effort. ⚠ *CorrigĂ© en v0.15.0* — une panne S3 survenant pendant la fenĂȘtre de cache n'Ă©tait alors **pas dĂ©tectĂ©e du tout** ; le rafraĂźchisseur de fond la constate dĂ©sormais Ă  sa prochaine tentative (mi-TTL, puis toutes les 10 s), et `status` bascule en `backend_unavailable`. La dĂ©tection n'est donc pas instantanĂ©e, mais elle a lieu.

#### Ce que le registre PROUVE — et ce qu'il ne prouve pas

🔮 **`secret_wrap_status` rend ce que le REGISTRE CROIT, pas un constat OpenBao.**
Aucun client OpenBao n'est appelé. Chaque réponse est une **croyance datée** :

- **`active` ne prouve pas qu'un wrap vit** — il a pu expirer, ĂȘtre consommĂ© ou
  révoqué chez OpenBao sans qu'aucune transition de registre ne soit écrite.
- **`consuming` n'atteste pas qu'un déballage soit parti** : l'état est persisté
  **avant** l'appel d'unwrap.
- **`consumed`, `revoked`, `unusable` ne parlent que d'UNE entrée**, jamais du
  couple : plusieurs entrĂ©es peuvent coexister pour un mĂȘme
  `(operation_id, mission_id)` — historiquement, parce que le verrou de clĂ© ne
  regardait que `pending` jusqu'en v0.12.1. ⚠ Un Ă©tat terminal sur l'une ne dit
  **rien** des autres : une entrée `unusable` peut coexister avec une `active`.
- **`revoked`** est aussi posé quand OpenBao répond 400/404 : c'est de
  l'idempotence, pas une preuve du sort du jeton.

⚠ **`ambiguous` de `secret_wrap_status` et `ambiguous` de `secret_wrap_lookup` ne
sont pas le mĂȘme mot.** Le status est **pur** ; le lookup est **destructif** —
face à plusieurs entrées il révoque les actives. **Ne jamais appeler
`secret_wrap_lookup` pour comprendre un `ambiguous`** : il détruirait ce qu'on
cherche à diagnostiquer. Aucun verbe ne détaille un `ambiguous` (ni cardinalité,
ni état individuel) : c'est un refus terminal, sans investigation.

**Quand peut-on recréer aprÚs `secret_revoke_wrap` ?**

| Réponse | Recréer ? |
| --- | --- |
| `ok/revoked`, `ok/already_revoked` | ✅ rĂ©vocation Ă©mise et acceptĂ©e, ou jeton dĂ©clarĂ© invalide/absent par OpenBao |
| `ok/not_found` | ❌ **aucun appel OpenBao tentĂ©** — accessor inconnu du registre ou hors pĂ©rimĂštre |
| `backend_error`, `backend_unavailable`, `registry_unavailable` | ❌ rien d'attestĂ© |

**`secret_consume`** — « aucun appel Ă©mis » ne veut **pas** dire « jeton intact » :
sur ces chemins nous ne regardons pas le jeton, qui peut avoir expiré ou avoir été
consommé en parallÚle.

| Code | Cette invocation a prĂ©sentĂ© le wrap ? | État du jeton |
| --- | --- | --- |
| `invalid_input`, `misconfigured`, `jwt_invalid`, `mission_inactive` | non | **non constaté** |
| `registry_unavailable`, `backend_unavailable` | non | **non constaté** |
| `permission_denied` *(v0.12.1 — remplace une enveloppe sans code)* | non | **non constatĂ©** |
| `binding_mismatch`, `not_found`, `already_consuming` | non | **non constaté** |
| `binding_incomplete` | non | inconsommable en mode durci → **reprovisionner** |
| `already_consumed` | non | dépensé ou révoqué |
| `wrap_unusable` | selon le chemin | **mort** |
| `consume_outcome_unknown` | **possible** | à considérer comme **perdu** |
| `empty_secret` | **oui** | **brûlé** |

⚠ LĂ  oĂč l'Ă©tat est **« non constatĂ© »**, dĂ©truire l'accĂšs de son propre fait est
une perte sĂšche. Mais une nouvelle tentative **n'est pas une sonde** : si la cause
du refus est levée, elle **peut consommer** le wrap.
⚠ `backend_error` n'est **pas atteignable** sur `secret_consume` (toute exception
d'unwrap tombe en `wrap_unusable` ou `consume_outcome_unknown`), et `wrap_expired`
**n'existe pas** — il a figurĂ© Ă  tort dans une version antĂ©rieure de ce contrat.
⚠ `already_consumed` regroupe `consumed` **et** `revoked` ; `secret_wrap_status`
ne désambiguïse que s'il n'existe **qu'une** entrée, sinon il rend `ambiguous`.

> **Refus sans `error_type`** — la taxonomie fermĂ©e (`unauthenticated`,
> `permission_denied`, `access_denied`, `invalid_input`, `invalid_token`,
> `policy_store_unavailable`) couvre les gardes d'autorisation partagées. Des
> refus de **validation** propres à des outils hors périmÚtre wrapping
> (`secret_list`, surfaces `/admin/api`) rendent encore une enveloppe sans code.
> **Tester `status` avant `error_type`.**

### PEP mission JWT — porte `/mcp` *(v0.8.0, #47 + #69)*

Second point d'application (PEP) du `mission_token` JWT ES256 de mcp-mission, **à l'entrée `/mcp`** (le premier étant `secret_consume`/C18, à la consommation). Piloté par `MCP_AUTH_MODE` :

| Mode | Comportement `/mcp` |
| --- | --- |
| `bearer` *(dĂ©faut)* | Bearer opaque **obligatoire** : une requĂȘte sans jeton, ou avec un jeton invalide, reçoit `401` au middleware (#116). |
| `bearer-anonymous` | ⚠ **Mode d'exception** — ancien comportement de `bearer` : un appelant sans identitĂ© atteint les outils. Repli temporaire uniquement ; `CRITICAL` au dĂ©marrage. |
| `jwt` | `mission_token` JWT ES256 **obligatoire** (bearer opaque refusé). Requiert aussi `ENFORCE_MISSION_TOKEN_VALIDATION=true` et `MISSION_STATUS_URL` (fail-fast, #86). |
| `dual-stack` | JWT valide **OU** bearer opaque valide (migration). MĂȘmes exigences que `jwt` ci-dessus. |

En `jwt`/`dual-stack`, le middleware **refuse activement** : `401` (token absent/opaque/JWT invalide), `403` (`aud`/`component_id` ≠ instance, mission inactive), `503` (JWKS indisponible — fail-close). Un JWT invalide ne retombe **jamais** sur le bearer. Le token est vĂ©rifiĂ© contre le contrat rĂ©el mcp-mission (`aud` contient `MCP_INSTANCE_ID`, `component_id[MCP_COMPONENT_KIND] == MCP_INSTANCE_ID`, `iss`, `exp` leeway 0, `iat` anti-skew). La bootstrap key admin reste acceptĂ©e (break-glass).

> Une identité mission valide est **authentifiée et liée à l'instance**, puis mcp-vault (**PDP local**) lui résout un **périmÚtre vault provisionné localement**. Sans octroi, **aucun accÚs** (deny-by-default) ; les outils d'infra (`system_*`, inventaire PKI) et l'admin-plane (`vault_create/delete`, `ssh_*`) lui sont refusés. L'endpoint admin `POST /admin/api/auth/jwks/reload` force le rechargement du JWKS (révocation urgente de `kid`).

#### Octroi de pĂ©rimĂštre vault — `MissionBindingStore` *(#69)*

Le `mission_token` ne porte **aucune** autorisation vault : l'octroi est **provisionné localement**, indexé par `tenant_id` (claim stable), et stocké un **fichier par instance** sur S3.

- Un octroi (« binding ») = `{tenant_id, allowed_resources:[coffres
], permissions, policy_id?, enabled, expires_at?}`.
- **Permissions strictes** : `read` ou `read,write` (jamais `write` seul, jamais `admin`). **Data-plane uniquement** : lire/Ă©crire des secrets dans les coffres autorisĂ©s — **jamais** crĂ©er/dĂ©truire un coffre ni gĂ©rer une CA SSH.
- **Fail-close** : binding absent/dĂ©sactivĂ©/expirĂ© → aucun accĂšs. **Registre indisponible/corrompu → `503`** (refus observable, jamais un deny silencieux ni un Ă©crasement destructeur).
- **Administration** (bearer/bootstrap admin uniquement — jamais via mission JWT) :

```bash
# Octroyer un périmÚtre lecture à un client mission
mcp-cli mission-binding create acme-corp --vaults prod-app --permissions read
# Lecture + écriture sur plusieurs coffres
mcp-cli mission-binding create acme-corp --vaults prod-app,staging --permissions read,write
mcp-cli mission-binding list
mcp-cli mission-binding get acme-corp
mcp-cli mission-binding delete acme-corp
mcp-cli mission-binding purge --older-than 30 --dry-run   # bindings expirés
```

> **GranularitĂ© tenant** : toutes les missions actives d'un mĂȘme client (`tenant_id`) partagent le pĂ©rimĂštre octroyĂ©. Le grain fin par mission existe dĂ©jĂ  Ă  la consommation (`secret_consume`/C18).

### Audit (1)

| Outil                                                                      | Perm  | Description                                                             |
| -------------------------------------------------------------------------- | ----- | ----------------------------------------------------------------------- |
| `audit_log(limit?, client?, vault_id?, tool?, category?, status?, since?)` | admin | Journal d'audit filtrable (ring buffer 5000 entrées + JSONL persistant) |

> **TraçabilitĂ© du cycle de vie des accĂšs** *(v0.7.0 — conformitĂ© SecNumCloud/HDS)* : toute mutation du plan de contrĂŽle — crĂ©ation / mise Ă  jour / rĂ©vocation / purge de token, crĂ©ation / suppression de policy — est journalisĂ©e, via l'**API REST admin** comme via les **outils MCP**. Les Ă©checs de persistance des opĂ©rations de **retrait** (rĂ©vocation, purge, suppression de policy — celles qui laissent un accĂšs actif si elles Ă©chouent) sont Ă©galement tracĂ©s. La trace vit dans `audit-mcp.jsonl`, **physiquement indĂ©pendant de `_system/tokens.json`** → elle survit Ă  une purge. Le token brut n'apparaĂźt jamais dans le dĂ©tail d'audit.

<details>
<summary>💡 Workflow SSH CA typique (ex: infrastructure LLMaaS)</summary>

```python
# 1. Setup initial (ONE-TIME) — crĂ©er vault + rĂŽles SSH
vault_create("llmaas-infra", description="SSH CA LLMaaS")
ssh_ca_setup("llmaas-infra", "adminct", allowed_users="adminct", ttl="1h")
ssh_ca_setup("llmaas-infra", "agentic", allowed_users="agentic,iaagentic", ttl="30m")

# 2. Déployer la CA sur les serveurs (ONE-TIME)
result = ssh_ca_public_key("llmaas-infra")
# → Mettre result["public_key"] dans /etc/ssh/trusted-user-ca-keys.pem

# 3. Usage quotidien — signer une clĂ© publique
cert = ssh_sign_key("llmaas-infra", "adminct", public_key="ssh-ed25519 AAAA...", ttl="1h")
# → cert["signed_key"] = certificat signĂ©, valide 1h
# → OpenSSH l'utilise automatiquement si placĂ© Ă  cĂŽtĂ© de la clĂ© privĂ©e
```

</details>

---

## 🔑 Types de secrets (style 1Password)

| Type            | IcĂŽne | Champs requis            | Usage                |
| --------------- | ----- | ------------------------ | -------------------- |
| `login`         | 🔑   | username, password       | Identifiants web/app |
| `password`      | 🔒   | password                 | Mot de passe simple  |
| `secure_note`   | 📝   | content                  | Notes sĂ©curisĂ©es     |
| `api_key`       | 🔌   | key                      | ClĂ©s API             |
| `ssh_key`       | đŸ—ïž | private_key              | Paires de clĂ©s SSH   |
| `database`      | đŸ—„ïž | host, username, password | Connexions BDD       |
| `server`        | đŸ–„ïž | host, username           | AccĂšs serveur        |
| `certificate`   | 📜   | certificate, private_key | Certificats TLS/SSL  |
| `env_file`      | 📄   | content                  | Fichiers .env        |
| `credit_card`   | 💳   | number, expiry, cvv      | Cartes bancaires     |
| `identity`      | đŸ‘€   | name                     | IdentitĂ©s            |
| `wifi`          | đŸ“¶   | ssid, password           | RĂ©seaux Wi-Fi        |
| `crypto_wallet` | ₿     | *(tout optionnel)*       | Wallets crypto       |
| `custom`        | ⚙  | *(champs libres)*        | Tout le reste        |

Chaque secret supporte : `tags`, `favorite`, versioning KV v2 automatique.

---

## 🔒 Authentification

> ⚠ **Seul le header `Authorization: Bearer <token>` est acceptĂ©.** L'authentification par query string (`?token=`) a Ă©tĂ© supprimĂ©e pour des raisons de sĂ©curitĂ© (v0.3.1).

```
Authorization: Bearer <token>
```

| Permission | Lecture | Écriture | Admin |
| ---------- | ------- | -------- | ----- |
| `read`     | ✅      | ❌       | ❌    |
| `write`    | ✅      | ✅       | ❌    |
| `admin`    | ✅      | ✅       | ✅    |

**3 couches d'isolation** :
1. **Vault-level** : `allowed_resources=[]` → owner-based (seuls les vaults créés par le token), ou liste explicite
2. **Tool-level** : policies avec `allowed_tools`/`denied_tools` (wildcards fnmatch)
3. **Path-level** : `allowed_paths` dans les `path_rules` → contrîle par secret individuel

---

## đŸ–„ïž CLI

MCP Vault inclut un CLI complet avec Click + Rich :

```bash
# Commandes scriptables
python scripts/mcp_cli.py health
python scripts/mcp_cli.py about
python scripts/mcp_cli.py whoami                       # Identité du token courant
python scripts/mcp_cli.py vault list
python scripts/mcp_cli.py vault create serveurs-prod -d "Clés SSH prod"
python scripts/mcp_cli.py secret write serveurs-prod web/github -d '{"username":"me","password":"s3cr3t"}' -t login
python scripts/mcp_cli.py secret read serveurs-prod web/github
python scripts/mcp_cli.py secret password -l 32
python scripts/mcp_cli.py token create agent-sre --vaults prod --policy readonly
python scripts/mcp_cli.py token list
python scripts/mcp_cli.py token purge-revoked --older-than 30 --dry-run    # aperçu (rien supprimé)
python scripts/mcp_cli.py token purge-revoked --older-than 30 --yes        # purge effective
python scripts/mcp_cli.py policy create no-ssh -d "Pas de SSH" --denied "ssh_*"
python scripts/mcp_cli.py policy create team-x --allowed "secret_*" --path-rules '[{"vault_pattern":"shared-*","allowed_paths":["shared/*"]}]'
python scripts/mcp_cli.py audit --status denied --limit 10

# AccÚs SSH JIT opérateur
python scripts/mcp_cli.py ssh profiles
python scripts/mcp_cli.py ssh request bastion-prod \
  --key ~/.ssh/id_ed25519.pub --reason "INC-230 maintenance bastion"

# PKI interne (v0.5.0)
python scripts/mcp_cli.py pki setup --lab --domains '*.lesur.lan,lesur.lan'
python scripts/mcp_cli.py pki ca-key
python scripts/mcp_cli.py pki certs
python scripts/mcp_cli.py pki revoke 12:34:ab:cd:ef:12:34:56
```

> L'aide `--help` de chaque commande explique le modÚle de sécurité à 3 couches et guide l'utilisateur.

Voir [scripts/README.md](scripts/README.md) pour la documentation complĂšte du CLI.

---

## ⚙ Variables d'environnement

Copier `.env.example` → `.env` et adapter. **`.env.example` est le contrat de
configuration de référence** : il déclare exactement les **45** variables
consommées par `Settings` ([`src/mcp_vault/config.py`](src/mcp_vault/config.py)),
ni plus ni moins, et il est vérifié par
[`tests/test_env_example_contract.py`](tests/test_env_example_contract.py).

> ⚠ **`extra="forbid"`** : une clĂ© absente de ce contrat, portant une valeur non
> vide dans votre `.env`, **fait échouer le démarrage**. Ne composez jamais un
> `.env` depuis une autre source que `.env.example`.
>
> **Marqueur `REQUIRED`** : une variable dont la valeur d'exemple est `REQUIRED`
> n'a **aucun défaut utilisable**. Le marqueur échoue volontairement la
> validation — le service refuse de dĂ©marrer tant qu'un dĂ©ploiement ne l'a pas
> substitué par une vraie valeur.

| Groupe | Variables | Obligatoire |
|--------|-----------|-------------|
| **Serveur** | `MCP_SERVER_NAME`, `MCP_SERVER_HOST`, `MCP_SERVER_PORT`, `MCP_SERVER_DEBUG`, `MCP_ALLOWED_HOSTS`, `MCP_ALLOWED_ORIGINS` | Non — tous ont un dĂ©faut. Mais derriĂšre un reverse-proxy, `MCP_ALLOWED_HOSTS` doit lister les FQDN publics, sinon le SDK rejette le header `Host` en HTTP 421 (issue #3) |
| **WAF** | `WAF_PORT` | Non — dĂ©faut `8085` |
| **Auth** | `ADMIN_BOOTSTRAP_KEY` | **Oui — seule variable sans dĂ©faut utilisable.** Chiffre les clĂ©s unseal et sert de credential admin de secours. Minimum 32 caractĂšres, ≄ 3 classes de caractĂšres ; dĂ©marrage refusĂ© sinon |
| **SSH JIT opĂ©rateur** *(v0.9.0)* | `SSH_OPERATOR_PROFILES_JSON` ou `SSH_OPERATOR_PROFILES_B64`, plus les 7 bornes `SSH_OPERATOR_JIT_*` | Non — vide = dĂ©sactivĂ© ; aucun secret dans le profil |
| **S3** | `S3_ENDPOINT_URL`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_BUCKET_NAME`, `S3_REGION_NAME` | **Oui pour un dĂ©ploiement fonctionnel.** Le processus dĂ©marre sans S3, mais en **mode dĂ©gradĂ©** : les stores restent en mĂ©moire (tokens perdus Ă  chaque redĂ©marrage) et, sur un volume vierge, la tentative de restauration Ă©choue de façon *ambiguĂ«*, ce qui empĂȘche OpenBao de dĂ©marrer (fail-close volontaire, cf. `lifecycle.py`) |
| **Storage sync** | `VAULT_S3_PREFIX`, `VAULT_S3_SYNC_INTERVAL` | Non |
| **OpenBao** | `OPENBAO_ADDR`, `OPENBAO_SHARES`, `OPENBAO_THRESHOLD`, `OPENBAO_DATA_DIR`, `OPENBAO_CONFIG_DIR` | Non |
| **PKI** *(v0.5.x)* | `PKI_BASE_URL` | Non — vide = dĂ©duit du premier FQDN ; override de l'URL ACME en test Docker |
| **Mission JWT** *(v0.6.0)* | `ENFORCE_MISSION_TOKEN_VALIDATION`, `MISSION_JWKS_URL`, `MISSION_TOKEN_AUD`, `MISSION_JWKS_CACHE_TTL`, `MISSION_JWKS_MAX_REFRESH_PER_MIN`, `MISSION_TOKEN_LEEWAY_SECONDS`, `MISSION_STATUS_URL`, `MISSION_STATUS_CACHE_TTL` | Non — standalone sans mcp-mission |
| **PEP mission JWT** *(v0.8.0)* | `MCP_AUTH_MODE` (`bearer`/`bearer-anonymous`/`jwt`/`dual-stack`), `MCP_INSTANCE_ID`, `MCP_COMPONENT_KIND` | ⚠ **Oui depuis #116** — le dĂ©faut `bearer` exige dĂ©sormais un jeton valide (401 sinon). Repli : `bearer-anonymous` (mode d'exception). `jwt`/`dual-stack` exigent `MISSION_JWKS_URL`, `MCP_INSTANCE_ID`, `MISSION_STATUS_URL` et `ENFORCE_MISSION_TOKEN_VALIDATION` Ă  `true` (fail-fast au boot, #86) |

> **Tokens sensibles du CLI — ce ne sont PAS des variables du serveur.**
> `VAULT_WRAP_TOKEN` et `VAULT_MISSION_TOKEN` sont lus uniquement par le CLI
> (`scripts/cli/`) et ne figurent pas dans `Settings` : les placer dans `.env`
> ferait échouer le démarrage du serveur. Ils changent à chaque opération (usage
> unique, TTL court) — les passer inline devant la commande, jamais dans un
> fichier, sinon ils fuitent dans l'historique de votre shell systĂšme :
> ```bash
> VAULT_WRAP_TOKEN=hvs.CAES... mcp-vault secret consume op-123
> ```

Voir `.env.example` pour la documentation détaillée de chaque variable.

---

## đŸ—ïž Architecture

> 📐 **Documentation complĂšte** : le dossier [`DESIGN/mcp-vault/`](DESIGN/mcp-vault/) contient la spĂ©cification dĂ©taillĂ©e ([ARCHITECTURE.md](DESIGN/mcp-vault/ARCHITECTURE.md) — vision, sĂ©curitĂ©, SSH CA, policies, roadmap HSM) et la documentation technique ([TECHNICAL.md](DESIGN/mcp-vault/TECHNICAL.md) — modules, Docker, tests, dĂ©pendances).

```
Internet → WAF (Caddy + Coraza :8085) → MCP Vault (Python :8030) → OpenBao (:8200 localhost)
                  ↕ OWASP CRS v4                  ↕
              Protection L7                 S3 Dell ECS (persistance)
```

### WAF — Caddy + Coraza (OWASP CRS v4)

Le WAF protĂšge l'API contre les attaques L7 (injections SQL, XSS, LFI, RCE, SSRF) :
- **Caddy v2.11.2** compilĂ© avec **coraza-caddy v2.5.0** (coraza v3.7.0) via `xcaddy` — montĂ©e depuis v2.2.0/coraza 3.3.3 en v0.9.2, requise par `SecRequestBodyJsonDepthLimit` (cf. #107)
- **23 fichiers de rÚgles OWASP CoreRuleSet v4.7.0** chargés (décompte vérifiable : `grep -c '^Include /opt/crs/rules/' waf/coraza.conf`)
- Mode **Blocking sur TOUS les endpoints** (health, `/mcp`, `/admin/api`)
- **Corps JSON-RPC rĂ©ellement parsĂ©** sur `POST /mcp` (`ctl:requestBodyProcessor=JSON`, v0.9.2). Sans ce rĂ©glage, Coraza traitait le **corps entier comme une variable unique** : toute chaĂźne d'une liste de phrases CRS prĂ©sente n'importe oĂč — y compris dans une **valeur de secret** — dĂ©clenchait un blocage, et aucun ciblage fin d'exclusion n'Ă©tait possible
- **Exclusions de RÈGLES** (`ctl:ruleRemoveById`) dĂ©clarĂ©es **avant l'Include CRS** — pattern « exclusions before CRS », requis pour neutraliser les rĂšgles Ă©valuĂ©es en phase:1. Faux positifs couverts : Unicode français 920540, noms PowerShell 932120, distribution CA/CRL `.pem` 920440, Content-Type JOSE ACME 920420
- **Exclusions de CIBLES** (`SecRuleUpdateTargetById`) dĂ©clarĂ©es **aprĂšs l'Include CRS** — sĂ©mantique inverse : cette directive modifie la *dĂ©finition* de la rĂšgle, qui doit donc ĂȘtre dĂ©jĂ  chargĂ©e. Couvre le faux positif 930120 sur `.env` (v0.9.2) : les **noms** d'arguments sous l'enveloppe MCP `params._meta` et les **valeurs** de la charge opaque de `secret_write` (seul outil Ă  en recevoir une — transmise Ă  OpenBao, qui la chiffre au repos). Le reste demeure inspectĂ© — chemin, nom de coffre, Ă©tiquettes, nom d'outil, et toutes les rĂšgles de path traversal
- **Profondeur JSON bornĂ©e Ă  32** sur `POST /mcp` (`SecRequestBodyJsonDepthLimit`, v0.9.2). Sans elle, un corps de 15 Ko d'imbrication — JSON valide, 2 octets par niveau — faisait tomber le WAF en OOM. La limite **seule** crĂ©ait toutefois une Ă©vasion silencieuse (au-delĂ  du seuil, plus aucune inspection) : la rĂšgle **10011** refuse donc explicitement (400) tout corps dĂ©clarĂ© non analysable par `REQBODY_ERROR`. ⚠ **Limite de compatibilitĂ©** : un secret dont `data` dĂ©passe ~28 niveaux d'imbrication interne est refusĂ© — les types de secrets du produit sont des dictionnaires plats, aucun usage connu n'y touche
- **Gardes anti-Ă©vasion 10009/10010** (v0.9.2). Ces exclusions sont des directives *globales* dont le sĂ©lecteur est un **nom** d'argument, donc une chaĂźne choisie par le client : `ARGS`/`ARGS_NAMES` agrĂšgent aussi query string et formulaire. Avant ces gardes, `GET /health?json.params.arguments.data.x=.env` passait (200) lĂ  oĂč `GET /health?legit=.env` Ă©tait refusĂ© (403) — soit une neutralisation globale de 930120 par simple choix du nom de paramĂštre. Les gardes **refusent** dĂ©sormais tout nom prĂ©fixĂ© `json.` hors du corps JSON MCP analysĂ©, discriminĂ© par un drapeau serveur non forgeable. La propriĂ©tĂ© est imposĂ©e, pas supposĂ©e
- **Headers de sécurité** : CSP, X-Frame-Options DENY, X-XSS-Protection, nosniff
- Méthodes autorisées adaptées au protocole MCP : GET, POST, DELETE, PUT, PATCH

### Stack ASGI (6 couches)
```
PkiMiddleware → AdminMiddleware → HealthCheckMiddleware → AuthMiddleware → LoggingMiddleware → MCPServer
```

`PkiMiddleware` (v0.5.0) est la couche la plus externe — intercepte `/acme/*` et `/pki/ca/*.pem` avant l'auth (endpoints publics par design PKI/ACME).

Depuis la v0.20.0, le serveur utilise `mcp==2.1.1` et accepte Ă  la fois le
protocole moderne `2026-07-28` et les clients legacy jusqu'Ă  `2025-11-25`.
Le transport HTTP est **stateless** dans les deux Ăšres : il n'exige plus
d'affinité de session. MCP Vault reste néanmoins **mono-instance** : OpenBao
embarqué, la synchronisation S3 last-write-wins et les caches d'autorisation ne
permettent pas encore un déploiement multi-réplica sûr. Le SDK annonce automatiquement la
capability `subscriptions/listen`, mais MCP Vault ne publie ni ressource ni
événement et ne configure aucun store de reprise ; cette capability est donc
inerte et doit ĂȘtre rĂ©auditĂ©e Ă  chaque montĂ©e de version du SDK.

AprÚs une fermeture de connexion sans réponse terminale, un client ne doit pas
réessayer automatiquement une mutation : son effet peut déjà avoir eu lieu.
Cela vaut notamment pour `secret_wrap_lookup`, qui révoque des wraps malgré son
nom de consultation.

### Le point de décision lit la mémoire, jamais le réseau *(v0.15.0, #123)*

Un appel S3 bloquant ne gelait pas une requĂȘte : il gelait **le service entier**,
sonde de santĂ© comprise — 488 s mesurĂ©es en production (#110). La cause est
toujours la mĂȘme : boto3 est synchrone, et un appel synchrone dans la boucle
asyncio monopolise l'unique thread qui sert toutes les requĂȘtes.

Le lot 1 (v0.10.1) a borné la durée de ces appels, le lot 2 (#122) en a sorti
cinq de la boucle. Ce lot ferme les derniers, et ce sont les plus fréquents : les
**magasins d'autorisation**, interrogĂ©s Ă  chaque requĂȘte authentifiĂ©e.

> **Plus aucun appel S3 ne part d'un point de décision.** Les gardes
> (`check_policy`, `check_path_policy`, `check_wrap_*`, `get_by_hash`,
> `resolve`) lisent un **instantanĂ© publiĂ© en mĂ©moire**. Des tĂąches de fond — une
> par magasin — le renouvellent **hors boucle**, Ă  la moitiĂ© du TTL, et toutes
> les 10 s aprĂšs une panne.

✅ **Le dernier appel rĂ©seau synchrone du chemin d'authentification est fermĂ©**
*(v0.16.1, #148)*. Le rafraĂźchissement du cache JWKS (`mission_jwt.py`,
`httpx.get` synchrone) était déclenché depuis la validation d'un jeton mission,
appelĂ©e **sans `await`** dans une coroutine. MĂȘme classe de dĂ©faut que #110, sur
un chemin aussi chaud. Il était **déclaré ici comme résidu** depuis v0.15.0 ;
il ne l'est plus.

Mesuré en production le 17/08 avant correction : **117,2 ms** de gel du service
entier par rafraĂźchissement, au plus une fois par TTL de 60 s. Trois chemins y
menaient, **tous des coroutines**, et les trois sont déportés hors boucle par
`run_blocking` :

| Site | Ce qui l'active |
| --- | --- |
| PEP transport (`AuthMiddleware`) | `MCP_AUTH_MODE ∈ {jwt, dual-stack}` |
| **`secret_consume`** | ⚠ la simple **prĂ©sence** de `MISSION_JWKS_URL` — c'est le chemin du dĂ©ballage de chaque enveloppe de credentials |
| `POST /admin/api/auth/jwks/reload` | appel opĂ©rateur — le plus lent, il force le fetch |

> ⚠ **`JWKSCache` n'est PAS réécrit.** Il est dĂ©jĂ  thread-safe (`threading.Lock`) :
> plusieurs threads d'exécuteur s'y sérialisent, **un seul** fait le fetch, les
> autres trouvent l'instantané frais. Son **fail-close** (un cache périmé n'est
> jamais servi), son **backoff** et son **throttle « kid inconnu »** sont
> inchangĂ©s — les dĂ©placer aurait transformĂ© un gel en martĂšlement de l'endpoint
> de `mcp-mission`.

> ⚠ **`MISSION_JWKS_CACHE_TTL` n'est pas un rĂ©glage de latence.** Une clĂ© rĂ©voquĂ©e
> disparaĂźt du JWKS de `mcp-mission` : ce TTL est donc la **fenĂȘtre de propagation
> d'une révocation de clé de signature**. Il vaut **60 s**, cinq fois plus strict
> que le `max-age=300` qu'ils publient, et **il ne doit pas ĂȘtre allongĂ©** pour
> espacer les appels.

| Ce qui change | Ce qui ne change pas |
| --- | --- |
| Le chargement quitte la boucle | Le **fail-close** de #86/#69 : sur le Policy Store, le Mission Binding Store et le registre wrap, un instantané périmé au-delà de son TTL ne sert plus aucune décision |
| Les mutations aussi (`acreate`, `adelete`, `arevoke`, 
) | La **forme**, les **codes d'erreur** et les **signatures** des outils existants. Deux évolutions bornées : `system_health` gagne `stores` / `stores_stale`, et le **texte** du `warning` de révocation énonce désormais l'incertitude au lieu d'un futur certain |
| Une opération wrap est atomique vis-à-vis d'un rechargement | La posture du Token Store au point de décision : `available` y reste **diagnostique** et un instantané périmé continue techniquement d'authentifier. Depuis v0.20.1, la readiness passe néanmoins à `503` pour signaler que cette décision n'est plus garantie |

**Deux horloges, pas une.** Le code historique n'en portait qu'une, qui servait Ă 
la fois à dater l'instantané et à brider les re-tentatives. Une panne les mettait
en désaccord : repousser la date pour ne pas marteler S3 rajeunissait aussi,
mĂ©caniquement, un instantanĂ© jamais rechargĂ© — un magasin en panne restait donc
« frais » indéfiniment. Elles sont désormais séparées, et en temps **monotone** :
un recul d'horloge murale ne peut plus rajeunir un état périmé.

**Copy-on-write** sur les trois magasins d'autorisation : une mutation construit
un candidat, le persiste, et ne le publie qu'aprĂšs confirmation du PUT. Une garde
synchrone ne peut donc plus observer une policy créée mais non persistée, ni une
suppression que l'Ă©chec du PUT annulera. Les rollbacks disparaissent — donc aussi
le risque d'en oublier un.

> ⚠ **Le registre wrap n'a dĂ©libĂ©rĂ©ment PAS de copy-on-write.** Aucun lecteur
> synchrone ne l'interroge, et la publication-aprÚs-PUT y détruirait un acquis de
> v0.14.1 : `mark_entries_revoked` conserve **volontairement** en mémoire une
> révocation confirmée mais non inscrite, parce que la mémoire y dit la vérité et
> que c'est le support durable qui est en retard.

**Observabilité.** `system_health` porte désormais la fraßcheur de chaque magasin
(`stores`, et `stores_stale` s'il y a lieu). Sans cela, un rafraĂźchisseur mort ne
se verrait qu'au premier refus : le service répondrait normalement, puis
refuserait tout d'un coup.

Le registre wrap y ajoute sa **volumétrie** *(v0.16.0, #146)* : `entries`, le
nombre d'entrĂ©es, et `last_write_bytes`, le poids du dernier corps Ă©crit — `null`
tant qu'aucune écriture n'a eu lieu depuis le démarrage. Le registre n'a ni purge
ni écriture incrémentale : il est réécrit **intégralement** à chaque transition,
et nous n'avions aucun chiffre pour dimensionner ce coût.

> ⚠ **`last_write_bytes` est relevĂ© Ă  l'Ă©criture, jamais calculĂ© par la sonde.**
> Sérialiser le registre à chaque appel de supervision ferait de la sonde une
> cause du problÚme qu'elle mesure. `entries`, lui, est un simple décompte en
> mémoire.

> `/health` et `/ready` lisent désormais la fraßcheur **en mémoire** : un magasin
> configuré jamais chargé ou périmé rend le service `503 unavailable`, car les
> dĂ©cisions d'autorisation ne peuvent plus ĂȘtre garanties. Ces sondes ne font
> aucun appel S3 ; les rafraßchisseurs existants peuvent les rétablir sans
> redémarrage. `/healthz` reste une liveness indépendante. `system_health` reste
> plus strict et vérifie en plus la connectivité S3 courante.

Ce fail-close de **readiness** est uniforme, y compris pour le Token Store : il
ne bloque pas lui-mĂȘme une requĂȘte MCP dĂ©jĂ  routĂ©e, mais demande Ă 
l'orchestrateur de retirer l'instance. Le produit étant mono-instance, une panne
S3 assez longue pour périmer tous les magasins provoque donc une indisponibilité
totale cÎté load balancer. C'est le contrat explicitement retenu en v0.20.1 :
une dĂ©cision d'autorisation non garantie ne doit pas ĂȘtre annoncĂ©e disponible.

⚠ **Limite inchangĂ©e** : rien ici ne ferme la race d'Ă©criture multi-instance
(#13/#51). Le dernier écrivain gagne toujours, et un rafraßchissement de fond
Ă©largit mĂȘme la fenĂȘtre pendant laquelle deux instances peuvent diverger sans le
savoir.

### Sondes de santé *(v0.11.0, #103)*

Deux questions distinctes, deux contrats :

| Endpoint | Question | Sain | Dégradé |
| --- | --- | --- | --- |
| `/healthz` | *liveness* — le processus rĂ©pond-il ? | `200` `alive` | **`200` `alive`** |
| `/health`, `/ready` | *disponibilitĂ©* — le coffre peut-il servir ? | `200` `healthy` | **`503`** |

`status` appartient Ă  une Ă©numĂ©ration fermĂ©e, une par question : `healthy` \| `sealed` \| `unavailable` pour la disponibilitĂ©, `alive` pour la liveness. Un coffre scellĂ© est donc identifiable de l'extĂ©rieur, sans qu'aucun dĂ©tail de dĂ©pendance ne soit exposĂ© — le diagnostic reste dans les journaux et sur `/admin/api/health`, qui exige un bearer valide.

Le `HEALTHCHECK` du conteneur vise `/health` : un coffre indisponible apparaĂźt dĂ©sormais `unhealthy`. Branchez une Ă©ventuelle sonde de *liveness* ou un autoheal sur `/healthz`, jamais sur `/health` — redĂ©marrer un coffre **scellĂ©** ne le descelle pas, et redĂ©marrer ne rĂ©tablit pas S3.

AprÚs un démarrage non abouti, l'état est **latché** : le signal ne repasse pas
au vert tout seul, un redémarrage est requis. Cette rÚgle ne concerne pas un
magasin temporairement périmé aprÚs un démarrage abouti : son refresher peut
rétablir `/health` sans nouvelle génération.

### Lifecycle OpenBao
```
STARTUP:  S3 download (3 Ă©tats : restaurĂ© / absence confirmĂ©e / Ă©chec ambigu —
          un Ă©chec ambigu refuse le dĂ©marrage) → bao server → init/unseal → periodic sync
RUNTIME:  secrets via hvac → sync S3 toutes les 60s, SEULEMENT si l'Ă©tat a changĂ©
SHUTDOWN: seal → S3 upload final (sautĂ© si le dĂ©marrage n'a pas abouti, pour ne pas Ă©craser une sauvegarde valide) → stop process
CRASH:    Docker volume local → redĂ©marrage immĂ©diat
```

### Sécurité des clés unseal

Les clés unseal d'OpenBao sont protégées par **séparation physique à 3 facteurs** :

| Facteur                                 | Stockage                  | Compromis seul = insuffisant       |
| --------------------------------------- | ------------------------- | ---------------------------------- |
| **Données chiffrées** (barrier OpenBao) | Volume Docker + S3        | Illisibles sans unseal key         |
| **Clés unseal** (chiffrées AES-256-GCM) | S3 uniquement             | Indéchiffrables sans bootstrap key |
| **ADMIN_BOOTSTRAP_KEY**                 | Variable d'env uniquement | Inutile sans les clés chiffrées    |

**Invariants** : les clĂ©s unseal ne sont **jamais** en clair sur disque — uniquement en mĂ©moire pendant le runtime. Un crash efface automatiquement les clĂ©s.

**Roadmap sécurité** :

| Version              | Approche                                                                                                            |
| -------------------- | ------------------------------------------------------------------------------------------------------------------- |
| **v0.8.0** (actuel)  | Clés sur S3 chiffrées AES-256-GCM+AAD, mémoire seule au runtime |
| **v1.0**             | Transit Auto-Unseal via OpenBao dédié (KMS Cloud Temple)                                                            |
| **v2.0**             | **Connexion HSM** (Hardware Security Module) Cloud Temple — les clĂ©s ne quittent jamais le module matĂ©riel certifiĂ© |

> 📖 Voir [DESIGN/mcp-vault/ARCHITECTURE.md](DESIGN/mcp-vault/ARCHITECTURE.md) §8 et §11 pour les dĂ©tails complets.

---

## 📋 Tests (~1500 unitaires mockĂ©s + 349 e2e rĂ©els)

> 📖 Voir [tests/README.md](tests/README.md) pour le guide complet d'exĂ©cution.

```bash
# 1. Tests CLI — parsing + affichage (197 tests, SANS serveur)
python tests/test_cli_all.py

# 2. Tests CLI LIVE — cycle complet (79 tests, serveur rĂ©el)
MCP_URL=http://localhost:8085 MCP_TOKEN=<key> python tests/test_cli_live.py

# 3. Tests e2e MCP (349 tests, dans Docker)
docker compose exec mcp-vault python tests/test_e2e.py

# 4. Tests crypto (18 tests, SANS serveur — AES-256-GCM + AAD + validation entropie)
python tests/test_crypto.py

# Un seul groupe CLI
python tests/test_cli_all.py --only policy

# Un seul groupe e2e
docker compose exec mcp-vault python tests/test_e2e.py --test enforcement
```

### Couverture e2e (349 tests, 15 catégories)

| Catégorie              | Tests  | Description                                                                        |
| ---------------------- | ------ | ---------------------------------------------------------------------------------- |
| SystĂšme                | 7      | health, about, services, tools_count (37)                                          |
| Vault CRUD             | 28     | create + métadonnées, list, info + owner, update, delete, confirm, erreurs         |
| Secrets CRUD           | 24     | 10 types écrits, read/list/delete, validation                                      |
| Versioning             | 8      | v1→v2→v3, read latest, read spĂ©cifique                                             |
| Passwords              | 14     | longueurs, options, exclusions, CSPRNG                                             |
| Isolation              | 7      | secrets cloisonnés entre vaults                                                    |
| Erreurs                | 10     | edge cases, vault inexistant, type invalide, protection `_vault_meta`              |
| S3 Sync                | 3      | archive tar.gz sur S3                                                              |
| SSH CA                 | 33     | setup, rĂŽles multiples, signature ed25519, list/info roles, isolation CA, cleanup  |
| Types                  | 14     | 14 types vérifiés individuellement                                                 |
| Admin API              | 15     | health, whoami, generate-password, logs, unicité CSPRNG                            |
| Policies MCP           | 43     | CRUD, validation, wildcards, path_rules, doublons, erreurs, Admin API REST         |
| **Policy Enforcement** | **37** | check_policy, token_update, denied/allowed, changement policy, Admin API           |
| **Audit Log**          | **31** | audit_log MCP, filtres (category/tool/status/since/limit), stats, Admin API /audit |
| **WAF Security**       | **62** | LFI, SQLi, XSS, RCE, scanners → 403 ; faux positif 930120 ; anti-Ă©vasion `json.*` ; bornes taille/profondeur JSON ; grammaire Content-Type ; non-rĂ©gression requĂȘtes lĂ©gitimes |

---

## 📁 Structure du projet

```
mcp-vault/
├── .env.example              # Configuration (copier en .env)
├── docker-compose.yml        # WAF + MCP Vault + volumes
├── Dockerfile                # Multi-stage (OpenBao 2.5.1 + Python 3.12)
├── requirements.txt          # DĂ©pendances Python
├── requirements.lock         # DĂ©pendances pinnĂ©es (versions exactes)
├── VERSION                   # version courante du service
├── DESIGN/mcp-vault/
│   ├── ARCHITECTURE.md       # SpĂ©cification dĂ©taillĂ©e (v0.15.0)
│   ├── TECHNICAL.md          # Documentation technique (v0.15.0)
│   └── SECURITY_AUDIT.md     # Rapport d'audit consolidĂ© (60 findings V2.1)
├── scripts/
│   ├── mcp_cli.py            # CLI entry point
│   ├── README.md             # Documentation CLI
│   └── cli/                  # Module CLI (Click + Rich)
│       ├── __init__.py       # Config (.env, BASE_URL, TOKEN)
│       ├── client.py         # MCPClient (Streamable HTTP)
│       ├── commands.py       # Groupes Click
│       └── display.py        # Affichage Rich
├── src/mcp_vault/
│   ├── config.py             # Configuration pydantic-settings
│   ├── server.py             # MCPServer + 39 outils MCP + lifecycle + audit
│   ├── lifecycle.py          # Orchestrateur startup/shutdown
│   ├── s3_client.py          # Client S3 hybride SigV2/SigV4
│   ├── s3_sync.py            # Sync file backend ↔ S3
│   ├── auth/                 # Bearer tokens, check_access, ContextVar
│   ├── admin/                # Console web /admin + API REST
│   ├── openbao/              # Process manager, HCL config, lifecycle
│   ├── vault/                # Spaces, secrets, SSH CA, types
│   └── static/               # Console admin SPA (paritĂ© CLI 100%)
│       ├── admin.html        # HTML structure + 7 modals
│       ├── css/admin.css     # Design Cloud Temple (dark theme)
│       ├── js/               # 9 modules JS (config, api, app, dashboard, vaults, tokens, policies, activity, pki)
│       └── img/              # logo-cloudtemple.svg
├── tests/
│   ├── README.md             # Guide d'exĂ©cution des tests (auditeurs)
│   ├── TEST_CATALOG.md       # Catalogue des tests pour auditeurs
│   ├── test_cli_all.py       # 197 tests CLI parsing (sans serveur)
│   ├── test_cli_live.py      # 79 tests CLI live (serveur rĂ©el)
│   ├── test_e2e.py           # 349 tests MCP e2e (15 catĂ©gories)
│   ├── test_crypto.py        # 18 tests AES-256-GCM + AAD
│   ├── test_service.py       # 78 tests bas niveau
│   ├── test_integration.py   # Tests pytest
│   └── cli/                  # Tests CLI dĂ©coupĂ©s par groupe (7 fichiers)
└── waf/                      # WAF Caddy + Coraza (OWASP CRS v4)
    ├── Dockerfile            # Multi-stage (xcaddy + CRS v4.7.0)
    ├── Caddyfile             # Reverse proxy + coraza_waf
    └── coraza.conf           # Config Coraza + exceptions MCP
```

---

## 🌐 Écosystùme MCP Cloud Temple

| Serveur          | RĂŽle                              | Port  |
| ---------------- | --------------------------------- | ----- |
| **MCP Tools**    | BoĂźte Ă  outils (SSH, HTTP, shell) | :8010 |
| **Live Memory**  | Mémoire de travail partagée       | :8002 |
| **Graph Memory** | Mémoire long terme (graphe)       | :8080 |
| **MCP Vault**    | 🔐 Coffre-fort à secrets         | :8030 |
| **MCP Agent**    | Runtime d'agents autonomes        | :8040 |
| **MCP Mission**  | Orchestrateur de missions         | :8020 |

---

**Licence** : Apache 2.0 | **Auteur** : Cloud Temple | **Version** : 0.20.1