Assemble and operate with trust: bounded audit, multi-server, packaging, CI
🇫🇷 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
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:
- Article 2 — the LLM sees only tokens — the invariant that this article’s bounded audit makes exploitable without ever becoming a data leak.
- Article 3 — deny by default — the same fail-closed reflex, here applied to runtime assembly and audit writes rather than to the policy decision.
