NOPE LinkedIn

Catégories:
AI

Assemble and operate with trust: bounded audit, multi-server, packaging, CI

Assemble and operate with trust: bounded audit, multi-server, packaging, CI image

Rubrique: AI Tag: AI Tag: Security Tag: DevSecOps Tag: Docker Tag: CI/CD Tag: AGPL

🇫🇷 Version française 💻 Source code (AGPL): github.com/patlegu/cyber-agent-engine

From “it works” to “deployable by a third party”

The first four articles in this series described boundaries: tokens rather than real values (article 2), a fail-closed policy and human approval (article 3), the LLM off the equipment it drives (article 4). Each answers the question “what is the system allowed to do, to see, or to leak?” This one shifts register: it is no longer about what the system does while it runs, but about what it takes for a third party — not the author of the code, not someone who knows the internals — to run it with confidence, on their own infrastructure, without re-reading all of core/ to check that no guarantee tears at startup.

It’s a discreet but real shift. A perfectly designed fail-closed core/ is worth nothing if its runtime assembly fails silently in production, if the audit file can fill up a disk, or if no one but the author can rebuild the package with confidence. This article follows that thread: assembly at startup, bounded audit, multi-server, distribution, and the CI guardrails that keep each of these properties from silently degrading commit after commit.

Runtime assembly: create_default_app and the refusal to collide

coordinator/app.py exposes build_app(*, loop, auth_secret), which takes an already-composed GatedLoop — useful for tests, or any caller that prefers to wire its own loop by hand. But the binary’s real entry point is create_default_app(), which assembles everything from the environment:

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()

(The docstring comment reads: load_config raises if secrets/path are missing, at startup.)

Two details matter here, and both are documented in the code rather than left to the memory of whoever wrote it. First, load_config raises before any attempt at a network connection — a missing required secret or path stops the startup cold, not after thirty seconds of trying to connect to an agent server. Second, the try/finally only closes what was actually opened: opened only tracks clients whose __aenter__() succeeded, and llm.shutdown() is only called if llm.init() completed — otherwise you’d risk calling shutdown() on a half-built LLM. Nothing spectacular, but it’s exactly the kind of detail that separates an assembly you can hand to a third party from an assembly that only works “on my machine, in my usual startup order.”

The heart of the assembly — in the literal sense, agent discovery and their routing — lives in coordinator/assembly.py, in assemble_loop(config, agent_clients, llm). Its docstring states the invariant directly:

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.

(Translation: AssemblyError — no agent discovered, or agent-name collision; ManifestConformanceError — manifest↔live drift; PolicyError — malformed rule or a glob covering no known capability.)

And the code that materializes the collision is short, deliberately so:

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")

If two distinct agent servers expose an agent with the same name, the coordinator doesn’t arbitrarily pick one of the two, doesn’t silently merge their capabilities, and doesn’t queue them hoping it’ll work out: it refuses to start, with an explicit AssemblyError that names the colliding agent. It’s the same reflex as the fail-closed policy of article 3, applied one step earlier — at assembly, not at decision time. Ambiguous routing to an agent capable of blocking an IP or replaying a firewall rule is exactly the kind of ambiguity you never want resolved by an arbitrary runtime choice. The catalog and the policy, moreover, are only built after this collision-free discovery phase — the manifest↔live conformance (the sub-project C mentioned in the module) and the loading of policy.yml apply to an already-disambiguated set of agents.

Bounded audit: rotation, tokens only, fail-closed on write

core/audit/file_sink.py implements FileAuditSink, the durable audit sink wired in by 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"
        )
        ...

Without rotation (max_bytes=0, the class’s own default), the sink does unbounded appends — a deliberately simple behavior for tests or deployments where retention is handled elsewhere (external logrotate, dedicated volume). But the real assembly, in coordinator/assembly.py, never leaves these values at their class default: it passes them explicitly from the configuration —

sink=FileAuditSink(
    config.audit_file,
    max_bytes=config.audit_max_bytes,
    backup_count=config.audit_backups,
),

— and coordinator/config.py sets concrete environment defaults: COORDINATOR_AUDIT_MAX_BYTES defaults to 104857600, i.e. 100 MiB, and COORDINATOR_AUDIT_BACKUPS defaults to 5. In a default deployment, therefore, audit.jsonl rotates as soon as it reaches 100 MiB, keeps five prior generations (.1 through .5) and deletes the oldest on each rotation: the disk of a coordinator running for months can’t fill up indefinitely because of an audit stream someone forgot to purge. This is the standard size-based rotation of the stdlib’s logging.handlers.RotatingFileHandler — not a reinvention, just explicit wiring with safe defaults.

What the file contains, meanwhile, remains exactly the invariant article 2 established: tokens, never real values. The module’s docstring restates it at the top of the file — “Entries carry only tokens (AuditEntry invariant) — no real value ever reaches the file” — and it’s this same fact that makes the audit shareable: an audit.jsonl, archived, replayed, or handed to an external security team, never constitutes, on its own, an inventory of the operator’s infrastructure. Article 2 posed this consequence as a subject the series would come back to; it’s this bounded rotation, concretely, that makes it operational rather than theoretical — a log you can run in production without dreading either a leak or a full disk.

One detail remains that the stdlib, alone, doesn’t provide: what happens if the write itself fails — disk full, permissions pulled mid-flight? The default behavior of logging is best-effort: handleError swallows the exception and writes a message to stderr, but never re-raises it. For an ordinary application log stream, that’s the right choice; for an audit log whose very existence conditions the traceability of an action on a production firewall, it would be exactly the opposite of what the rest of the architecture guarantees. Hence _RaisingRotatingFileHandler:

class _RaisingRotatingFileHandler(logging.handlers.RotatingFileHandler):
    def handleError(self, record: logging.LogRecord) -> None:
        raise  # ré-lève l'exception active

(Comment: re-raises the active exception.)

A three-line subclass that flips the stdlib’s default behavior: an audit entry that can’t be written fails the operation instead of vanishing silently on stderr. It’s the same fail-closed principle article 3 applied to the policy decision — here applied to the disk write itself. A best-effort audit isn’t an audit; an audit that can go silent without anyone knowing is worse than no audit at all, because it gives the illusion of traceability that no longer exists.

Multi-server: AGENT_SERVERS, and the same refusal running through config

The trust core described so far assumes a single agent server. In practice, an operator may want to spread agents across multiple processes — isolation, scaling, or simply separating opnsense/wireguard/crowdsec across distinct hosts. coordinator/config.py handles this case with a CSV variable, with an explicit fallback:

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")]

If AGENT_SERVERS is set (a comma-separated list of URLs), it takes precedence; otherwise, the coordinator falls back to the legacy single AGENT_SERVER_URL, with http://localhost:3000 as the code default — the same “single-server by default, multi-server as an option” scheme found in build_agent_clients (coordinator/app.py), which only opens a UDS socket in the single-server case and switches to plain TCP as soon as several URLs are declared.

What makes this mechanism safe isn’t the CSV itself — it’s that the collision refusal described above applies identically, whether agents come from one server or several. assemble_loop iterates over agent_clients without distinguishing their origin: if opnsense shows up on two entries of AGENT_SERVERS — a plausible configuration mistake, two processes that mistakenly expose the same role — it’s the same AssemblyError that halts startup, with the same message naming the colliding agent. The multi-server topology adds an operational dimension without adding any bypass path around the assembly’s fail-closed behavior: more servers never means more tolerated ambiguity.

Distribution: Docker/compose, license, release CI

A project a third party must be able to deploy with confidence needs a reference deployment path, not just a pip install. That’s the role of docker-compose.yml, which lays out two services and an explicit network-exposure rule right in its own header comment:

# 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]

(Header comment: the agent server is NOT exposed to the host; only the coordinator is. Inline comment: no ports section — port 3000 is never published.)

agent-server has no ports: section at all — it only talks to the internal Docker network internal, invisible from the host. Only coordinator publishes a port, 8080 by default (COORDINATOR_PORT), the one that receives authenticated /coordinator/execute requests. policy.yml is mounted read-only (:ro) — the file that governs the fail-closed decisions of article 3 cannot be modified from inside a compromised container. It’s a least-exposure topology that translates into Compose the same discipline article 4 described for the OPNsense API itself: the component that actually executes actions on the infrastructure must never be directly reachable from the outside.

The project is distributed under AGPL-3.0-or-later — the most copyleft license on the market, the one that extends the source-sharing obligation to network usage (not just binary distribution): anyone running a modified version of cyber-agent-engine as a network service must publish its sources. A license choice consistent with the article’s thesis — a system meant to be deployed by third parties with confidence, with the guarantee that modifications, too, remain auditable.

This distribution path is verified by two distinct GitHub Actions workflows. ci.yml runs on every push and every pull request, with three gates that must all pass:

- 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 reuses these same three gates, adds a check that the Git tag matches the pyproject.toml version, then triggers on every v* tag to publish in parallel to two registries:

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          

Two posture details deserve highlighting. First, the PyPI publish goes through OIDC Trusted Publishing (environment: pypi, permissions: id-token: write) — no long-lived PyPI token stored as a GitHub secret, the workflow’s identity is verified on every publish through a short-lived token exchange. Second, the GHCR image is tagged with both the Git tag name and latest, but only after both publish-pypi and publish-ghcr have depended (needs: test) on the job that reruns ruff, mypy, and pytest — no release can ship without passing through the same gates as ordinary development CI.

Guardrails: three AST tests that block silent regression

Rules like “no secrets in plain text,” “license header everywhere,” or “operator messages in English” are easy to state and easy to violate by accident six months later, in a file no one revisited. cyber-agent-engine encodes them as tests that parse the syntax tree rather than trusting human discipline.

tests/test_spdx_headers.py checks that every first-party source file (core/, coordinator/, agents/, clients/, plus server.py at the root) carries SPDX-License-Identifier: AGPL-3.0-or-later in its first three lines — and a second test confirms the scope correctly excludes tests/ and dashboard/, so the rule doesn’t silently drift toward too broad or too narrow a perimeter.

tests/test_runtime_messages_english.py goes further: it parses the AST of the same first-party surface and flags any accented French string literal passed to raise, to logging methods (debug, info, warning, error, critical, exception, log), to print, or to the reason=/error= parameters of an operator response. Docstrings, LLM prompts, and classifier vocabulary are never inspected — only messages that actually reach an operator or a runtime trace count. The limit is acknowledged in the test’s own docstring: French without accents isn’t detected; the test guards against the common-case regression, not a proof of total exhaustiveness.

tests/test_lint_surface_consistency.py closes the loop on the CI itself: the list of paths passed to ruff check in ci.yml and release.yml must be strictly identical to [tool.mypy].files in pyproject.toml. Without this guardrail, a path quietly dropped from one of the three places — adding a module to core/ without adding it to the ruff list, for instance — would stay a silence: the code would exist, would be linted by no one, and no one would know before an incident. The test turns that silence into a red CI failure.

Result: deployable by third parties, secure by default end to end

%%{init: {"flowchart": {"htmlLabels": false}} }%% subgraph compose["docker-compose"] C["coordinator :8080"] --> A["agent-server :3000 (not exposed)"] Aud[("audit.jsonl — bounded rotation, tokens only")] C -.-> Aud end A --> Dev["Devices"] subgraph ci["Release CI (tag v*)"] T["ruff + mypy + pytest"] --> P["PyPI (OIDC)"] T --> G["GHCR: tag + latest"] end

None of the building blocks described in this article introduces any new guarantee compared to the previous articles — tokens remain tokens, fail-closed remains fail-closed, the LLM remains off the box. What they add is the certainty that these guarantees survive the passage from “it works on my machine” to “a third party deployed it, without reading all the code, and it behaves as documented.” An assembly that refuses to start rather than route ambiguously, an audit that refuses to write silently rather than lose an entry, a Docker image where the component that executes actions is never exposed, a release CI that only lets a PyPI package or a GHCR image out after replaying the same gates as ordinary development, and three AST tests that turn conventions into build failures rather than hopes: it’s this accumulation of small explicit refusals, more than any single building block, that makes the system operable — not just functional — outside its author’s hands.


This article is part of the cyber-agent-engine series. The first article lays out the five principles of the trust boundary; the following articles detail tokenization (article 2), fail-closed policy (article 3) and the off-box topology (article 4). This one shows how these building blocks assemble, deploy, and get continuously verified — one last installment remains to come in this series.

To go further: