Assembler et exploiter en confiance : audit borné, multi-serveur, packaging, CI
🇬🇧 English version 💻 Code source (AGPL) : github.com/patlegu/cyber-agent-engine
De « ça marche » à « déployable par un tiers »
Les quatre premiers articles de cette série ont décrit des frontières :
jetons plutôt que valeurs réelles (article 2),
politique fail-closed et approbation humaine
(article 3), LLM hors de l’équipement qu’il pilote
(article 4). Chacune répond à la question « qu’est-ce que
le système a le droit de faire, de voir, ou de laisser fuiter ? ». Celui-ci
change de registre : il ne s’agit plus de ce que le système fait quand il
tourne, mais de ce qu’il faut pour qu’un tiers — pas l’auteur du code, pas
quelqu’un qui connaît les internals — puisse le faire tourner en confiance,
chez lui, sans relire tout core/ pour vérifier qu’aucune garantie ne se
déchire au démarrage.
C’est un déplacement discret mais réel. Un core/ fail-closed parfaitement
conçu ne sert à rien si son assemblage runtime échoue silencieusement en
production, si le fichier d’audit peut saturer un disque, ou si personne
d’autre que l’auteur ne peut reconstruire le paquet en confiance. Cet article
suit ce fil : l’assemblage au démarrage, l’audit borné, le multi-serveur, la
distribution, et les garde-fous de CI qui empêchent chacune de ces propriétés
de se dégrader silencieusement au fil des commits.
Assemblage runtime : create_default_app et le refus de collision
coordinator/app.py expose build_app(*, loop, auth_secret), qui prend une
GatedLoop déjà composée — utile pour les tests, ou tout appelant qui
préfère câbler lui-même sa boucle. Mais le point d’entrée réel du binaire est
create_default_app(), qui assemble tout depuis l’environnement :
def create_default_app() -> FastAPI:
config = load_config(os.environ) # lève si secrets/chemin manquants (au démarrage)
@asynccontextmanager
async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
clients = build_agent_clients(config)
opened: list[ToolAgentClient] = []
llm = CoordinatorLLM()
llm_initialized = False
try:
for client in clients:
await client.__aenter__()
opened.append(client)
await llm.init()
llm_initialized = True
app.state.loop = await assemble_loop(config, list(clients), llm)
yield
finally:
for client in opened:
await client.__aexit__(None, None, None)
if llm_initialized:
await llm.shutdown()
Deux détails comptent ici, et les deux sont documentés dans le code plutôt
que laissés à la mémoire de qui l’a écrit. D’abord, load_config lève
avant toute tentative de connexion réseau — un secret ou un chemin
obligatoire manquant arrête le démarrage net, pas après trente secondes de
tentatives de connexion à un serveur d’agents. Ensuite, le try/finally
ne ferme que ce qui a réellement été ouvert : opened ne trace que les
clients dont __aenter__() a réussi, et llm.shutdown() n’est appelé que si
llm.init() est allé au bout — sinon on risquerait d’appeler shutdown()
sur un LLM à moitié construit. Rien de spectaculaire, mais c’est exactement
le genre de détail qui distingue un assemblage qu’on peut confier à un tiers
d’un assemblage qui fonctionne « chez moi, dans mon ordre de démarrage
habituel ».
Le cÅ“ur de l’assemblage — au sens propre, la découverte des agents et leur
routage — vit dans coordinator/assembly.py, dans assemble_loop(config, agent_clients, llm). Sa docstring pose l’invariant directement :
Raises:
AssemblyError: aucun agent découvert, ou collision de nom d'agent.
ManifestConformanceError: drift manifeste↔live.
PolicyError: règle malformée ou glob ne couvrant aucune capacité connue.
Et le code qui matérialise la collision est court, volontairement :
for client in agent_clients:
caps = await client.get_capabilities()
for name, funcs in discover_agents(caps).items():
if name in agent_to_client:
raise AssemblyError(
f"agent '{name}' exposed by multiple servers (ambiguous routing)"
)
live[name] = funcs
agent_to_client[name] = client
if not live:
raise AssemblyError("no agent discovered on the agent servers")
Si deux serveurs d’agents distincts exposent un agent du même nom, le
coordinateur ne choisit pas arbitrairement l’un des deux, ne fusionne pas
silencieusement leurs capacités, et ne les met pas en file d’attente en
espérant que ça se passera bien : il refuse de démarrer, avec une
AssemblyError explicite qui nomme l’agent en collision. C’est le même
réflexe que la politique fail-closed de l’article 3, appliqué un cran plus
tôt — à l’assemblage, pas à la décision. Un routage ambigu vers un agent
capable de bloquer une IP ou de rejouer une règle firewall est exactement le
genre d’ambiguïté qu’on ne veut jamais résoudre par un choix arbitraire au
runtime. Le catalogue et la politique ne sont d’ailleurs construits
qu’après cette phase de découverte sans collision — la conformance
manifeste↔live (le sous-projet C mentionné dans le module) et le chargement
de policy.yml s’appliquent sur un ensemble d’agents déjà désambiguïsé.
Audit borné : rotation, jetons only, fail-closed en écriture
core/audit/file_sink.py implémente FileAuditSink, le puits d’audit
durable branché par assemble_loop :
def __init__(self, path: str | Path, *, max_bytes: int = 0, backup_count: int = 0) -> None:
self._path = Path(path)
self._path.parent.mkdir(parents=True, exist_ok=True)
self._logger: logging.Logger | None = None
if max_bytes > 0:
handler = _RaisingRotatingFileHandler(
self._path, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8"
)
...
Sans rotation (max_bytes=0, le défaut de la classe elle-même), le puits
fait un append illimité — comportement volontairement simple pour les tests
ou les déploiements où la rétention est gérée ailleurs (logrotate externe,
volume dédié). Mais l’assemblage réel, dans coordinator/assembly.py, ne
laisse jamais ces valeurs à leur défaut de classe : il les passe
explicitement depuis la configuration —
sink=FileAuditSink(
config.audit_file,
max_bytes=config.audit_max_bytes,
backup_count=config.audit_backups,
),
— et coordinator/config.py fixe des défauts d’environnement concrets :
COORDINATOR_AUDIT_MAX_BYTES vaut 104857600 par défaut, soit 100 Mio, et
COORDINATOR_AUDIT_BACKUPS vaut 5. En déploiement par défaut, donc,
audit.jsonl tourne dès qu’il atteint 100 Mio, conserve cinq générations
antérieures (.1 à .5) et supprime la plus ancienne à chaque rotation :
le disque d’un coordinateur qui tourne des mois ne peut pas se remplir
indéfiniment à cause d’un flux d’audit qu’on aurait oublié de purger. C’est
la rotation par taille standard de logging.handlers.RotatingFileHandler
de la stdlib — pas une réinvention, juste un câblage explicite avec des
défauts sûrs.
Ce que le fichier contient reste, lui, exactement ce que l’article 2 a posé
comme invariant : des jetons, jamais des valeurs réelles. La docstring du
module le rappelle en tête de fichier — « Les entrées ne portent que des
jetons (invariant d’AuditEntry) — aucune valeur réelle n’atteint le
fichier » — et c’est ce même fait qui rend l’audit partageable : un
audit.jsonl archivé, rejoué, ou transmis à une équipe de sécurité externe
ne constitue jamais, en lui-même, un inventaire de l’infrastructure de
l’opérateur. L’article 2 posait cette conséquence comme un sujet à venir de
la série ; c’est cette rotation bornée, concrètement, qui la rend
opérationnelle plutôt que théorique — un journal qu’on peut faire tourner en
production sans redouter ni la fuite, ni la saturation disque.
Reste un détail que la stdlib, seule, ne donne pas : que se passe-t-il si
l’écriture elle-même échoue — disque plein, permissions retirées en cours de
route ? Le comportement par défaut de logging est best-effort :
handleError avale l’exception et écrit un message sur stderr, mais ne la
relève jamais. Pour un flux applicatif classique, c’est le bon choix ; pour
un journal d’audit dont l’existence conditionne la traçabilité d’une action
sur un pare-feu de production, ce serait exactement l’inverse de ce que le
reste de l’architecture garantit. D’où _RaisingRotatingFileHandler :
class _RaisingRotatingFileHandler(logging.handlers.RotatingFileHandler):
def handleError(self, record: logging.LogRecord) -> None:
raise # ré-lève l'exception active
Une sous-classe de trois lignes qui inverse le comportement par défaut de la
stdlib : une entrée d’audit qui ne peut pas être écrite fait échouer
l’opération plutôt que de disparaître en silence sur stderr. C’est le même
principe fail-closed que l’article 3 appliquait à la décision de politique
— ici appliqué à l’écriture disque elle-même. Un audit best-effort n’est pas
un audit ; un audit qui peut se taire sans que personne ne le sache est pire
qu’une absence d’audit, parce qu’il donne l’illusion d’une traçabilité qui
n’existe plus.
Multi-serveur : AGENT_SERVERS, et le même refus qui traverse la config
Le trust core décrit jusqu’ici suppose un unique serveur d’agents. En
pratique, un opérateur peut vouloir répartir les agents sur plusieurs
processus — isolation, montée en charge, ou simplement séparation
opnsense/wireguard/crowdsec sur des hôtes distincts. coordinator/config.py
prend ce cas en charge par une variable CSV, avec repli explicite :
def _parse_agent_servers(env: Mapping[str, str]) -> list[str]:
raw = env.get("AGENT_SERVERS", "").strip()
if raw:
return [u.strip() for u in raw.split(",") if u.strip()]
return [env.get("AGENT_SERVER_URL", "http://localhost:3000")]
Si AGENT_SERVERS est renseigné (une liste d’URL séparées par des
virgules), il prime ; sinon, le coordinateur retombe sur l’unique
AGENT_SERVER_URL historique, avec http://localhost:3000 comme défaut de
code — le même schéma « mono-serveur par défaut, multi-serveur en option »
qu’on retrouve dans build_agent_clients (coordinator/app.py), qui
n’ouvre un socket UDS que dans le cas mono-serveur et bascule en TCP pur dès
qu’il y a plusieurs URL déclarées.
Ce qui rend ce mécanisme sûr, ce n’est pas la CSV elle-même — c’est que le
refus de collision décrit plus haut s’applique identiquement, que les
agents viennent d’un seul serveur ou de plusieurs. assemble_loop itère sur
agent_clients sans distinguer leur origine : si opnsense apparaît sur
deux entrées de AGENT_SERVERS — une erreur de configuration plausible,
deux processus qui exposent par mégarde le même rôle — c’est la même
AssemblyError qui arrête le démarrage, avec le même message nommant
l’agent en collision. La topologie multi-serveur ajoute une dimension
opérationnelle sans ajouter de chemin de contournement au fail-closed
d’assemblage : plus de serveurs, ce n’est jamais plus d’ambiguïté tolérée.
Distribution : Docker/compose, licence, CI de release
Un projet qu’un tiers doit pouvoir déployer en confiance a besoin d’un
chemin de déploiement de référence, pas seulement d’un pip install. C’est
le rôle de docker-compose.yml, qui pose deux services et une règle
d’exposition réseau explicite dans son propre commentaire d’en-tête :
# Le serveur d'agents n'est PAS exposé à l'hôte ; seul le coordinateur l'est.
services:
agent-server:
build: .
command: uvicorn server:app --host 0.0.0.0 --port 3000
networks: [internal]
# ... aucune section `ports` : le port 3000 n'est jamais publié
coordinator:
build: .
command: cyber-coordinator
ports:
- "${COORDINATOR_PORT:-8080}:8080"
volumes:
- ./policy.yml:/policy/policy.yml:ro
- coordinator-data:/data
networks: [internal]
agent-server n’a aucune section ports: — il ne parle qu’au réseau Docker
interne internal, invisible depuis l’hôte. Seul coordinator publie un
port, 8080 par défaut (COORDINATOR_PORT), celui qui reçoit les requêtes
/coordinator/execute authentifiées. policy.yml est monté en lecture
seule (:ro) — le fichier qui gouverne les décisions fail-closed de
l’article 3 ne peut pas être modifié depuis l’intérieur d’un conteneur
compromis. C’est une topologie de moindre exposition qui traduit, en
Compose, la même discipline que l’article 4 décrivait pour l’API OPNsense
elle-même : le composant qui exécute réellement des actions sur
l’infrastructure ne doit jamais être directement joignable depuis
l’extérieur.
Le projet est distribué sous AGPL-3.0-or-later — la licence la plus
copyleft du marché, celle qui étend l’obligation de partage du code source
jusqu’à l’usage réseau (pas seulement la distribution binaire) : quiconque
fait tourner une version modifiée de cyber-agent-engine en service réseau
doit en publier les sources. Un choix de licence cohérent avec la thèse de
l’article — un système qu’on veut voir déployé par des tiers en confiance,
avec la garantie que les modifications restent, elles aussi, auditables.
Ce chemin de distribution est vérifié par deux workflows GitHub Actions
distincts. ci.yml tourne sur chaque push et chaque pull request, avec
trois portes qui doivent toutes passer :
- name: Ruff (maintained source surface)
run: >-
ruff check
core
coordinator/agent_call.py coordinator/proposer.py coordinator/catalog_builder.py
coordinator/config.py coordinator/session.py coordinator/loop.py
coordinator/app.py coordinator/extractor.py coordinator/assembly.py
agents/contracts.py agents/coercion.py agents/manifest.py agents/infer_wiring.py
- name: Mypy
run: mypy
- name: Pytest
run: pytest -q
release.yml reprend ces trois mêmes portes, ajoute une vérification que le
tag Git correspond bien à la version pyproject.toml, puis se déclenche sur
tout tag v* pour publier en parallèle vers deux registres :
publish-pypi:
needs: test
environment: pypi
permissions:
id-token: write
steps:
- name: Publish to PyPI (Trusted Publishing / OIDC)
uses: pypa/gh-action-pypi-publish@release/v1
publish-ghcr:
needs: test
permissions:
packages: write
contents: read
steps:
- name: Build and push (CPU image)
uses: docker/build-push-action@v6
with:
tags: |
ghcr.io/.../cyber-agent-engine:${{ github.ref_name }}
ghcr.io/.../cyber-agent-engine:latest
Deux détails de posture méritent d’être soulignés. D’abord, la publication
PyPI passe par le Trusted Publishing OIDC (environment: pypi,
permissions: id-token: write) — aucun jeton PyPI longue durée stocké en
secret GitHub, l’identité du workflow est vérifiée à chaque publication par
un échange de jetons de courte durée. Ensuite, l’image GHCR est taguée à la
fois avec le nom du tag Git et latest, mais seulement après que
publish-pypi et publish-ghcr aient tous deux dépendu (needs: test) du
job qui rejoue ruff, mypy et pytest — aucune release ne peut sortir sans
repasser les mêmes portes que la CI de développement courante.
Garde-fous : trois tests AST qui empêchent la régression silencieuse
Des règles comme « pas de secret en clair », « en-tête de licence partout »
ou « messages opérateur en anglais » sont faciles à énoncer et faciles Ã
violer par inadvertance six mois plus tard, dans un fichier qu’on n’a pas
relu. cyber-agent-engine les encode en tests qui parsent l’arbre syntaxique
plutôt que de faire confiance à la discipline humaine.
tests/test_spdx_headers.py vérifie que chaque fichier source first-party
(core/, coordinator/, agents/, clients/, plus server.py à la
racine) porte SPDX-License-Identifier: AGPL-3.0-or-later dans ses trois
premières lignes — et un second test confirme que le périmètre exclut bien
tests/ et dashboard/, pour que la règle ne dérive pas silencieusement
vers un périmètre trop large ou trop étroit.
tests/test_runtime_messages_english.py va plus loin : il parse l’AST de la
même surface first-party et repère tout littéral de chaîne accentué
français passé à raise, aux méthodes de logging (debug, info,
warning, error, critical, exception, log), Ã print, ou aux
paramètres reason=/error= d’une réponse opérateur. Les docstrings, les
prompts LLM et le vocabulaire des classifieurs ne sont jamais inspectés —
seuls les messages qui atteignent réellement un opérateur ou une trace
runtime comptent. La limite est assumée dans le docstring du test lui-même :
le français sans accent n’est pas détecté ; le test garde la régression du
cas courant, pas une preuve d’exhaustivité totale.
tests/test_lint_surface_consistency.py referme la boucle sur la CI
elle-même : la liste de chemins passés à ruff check dans ci.yml et
release.yml doit être strictement identique à [tool.mypy].files dans
pyproject.toml. Sans ce garde-fou, un chemin qu’on retire discrètement
d’un des trois endroits — en ajoutant un module au core/ sans l’ajouter Ã
la liste ruff, par exemple — resterait un silence : le code existerait, ne
serait linté par personne, et personne ne le saurait avant un incident. Le
test transforme ce silence en échec rouge de CI.
Résultat : déployable par des tiers, sécurité par défaut de bout en bout
Aucune des briques décrites dans cet article n’introduit de garantie nouvelle par rapport aux articles précédents — les jetons restent des jetons, le fail-closed reste fail-closed, le LLM reste hors de la boîte. Ce qu’elles ajoutent, c’est la certitude que ces garanties survivent au passage de « ça marche sur ma machine » à « un tiers l’a déployé, sans lire tout le code, et ça se comporte comme documenté ». Un assemblage qui refuse de démarrer plutôt que de router en ambigu, un audit qui refuse d’écrire en silence plutôt que de perdre une entrée, une image Docker où le composant qui exécute des actions n’est jamais exposé, une CI de release qui ne laisse sortir un paquet PyPI ou une image GHCR qu’après avoir rejoué les mêmes portes que le développement courant, et trois tests AST qui transforment des conventions en échecs de build plutôt qu’en espoirs : c’est cette accumulation de petits refus explicites, plus que n’importe quelle brique individuelle, qui rend le système opérable — pas seulement fonctionnel — en dehors des mains de son auteur.
Cet article fait partie de la série sur cyber-agent-engine. Le
premier article pose les cinq principes de
la frontière de confiance ; les articles suivants détaillent la
tokenisation (article 2), la politique
fail-closed (article 3) et la topologie hors-boîte
(article 4). Celui-ci montre comment ces briques
s’assemblent, se déploient et se vérifient en continu — reste un dernier
volet à venir dans cette série.
Pour aller plus loin :
- Article 2 — le LLM ne voit que des jetons — l’invariant que l’audit borné de cet article rend exploitable sans jamais devenir une fuite de données.
- Article 3 — refuser par défaut — le même réflexe fail-closed, ici appliqué à l’assemblage runtime et à l’écriture d’audit plutôt qu’à la décision de politique.
