NOPE LinkedIn

Catégories:
IA

Assembler et exploiter en confiance : audit borné, multi-serveur, packaging, CI

Assembler et exploiter en confiance : audit borné, multi-serveur, packaging, CI image

Rubrique: IA Tag: IA Tag: Sécurité Tag: DevSecOps Tag: Docker Tag: CI/CD Tag: AGPL

🇬🇧 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

%%{init: {"flowchart": {"htmlLabels": false}} }%% flowchart LR subgraph compose["docker-compose"] C["coordinator :8080"] --> A["agent-server :3000 (non exposé)"] Aud[("audit.jsonl — rotation bornée, jetons only")] C -.-> Aud end A --> Dev["Équipements"] subgraph ci["CI de release (tag v*)"] T["ruff + mypy + pytest"] --> P["PyPI (OIDC)"] T --> G["GHCR : tag + latest"] end

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 :