Onboarding guide for anyone working in this repository — human or AI coding agent. Read this first.
Cloud Agent runs coding agents (Claude Code, Codex, pi, OpenCode, Kilo) in sandboxed containers against a user's repository. When the owner asks, the agent pushes a branch and opens a pull request itself, as that owner, through the platform's GitHub relay.
The extraction from the HybridInference monorepo
(HarvardMadSys/hybridInference) is complete: production cut over on
2026-08-07 and the gateway's own agent code was deleted on 2026-08-09.
The repository extraction record
records the split, its decisions, and deferred work. The extraction plan in the HybridInference repo
(docs/agents/plans/2026-08-03-cloud-agent-repo-split.md) is historical; new
work tracks in this repository's issues and pull requests.
apps/
├── backend/
│ ├── cloud_agent/ control plane (FastAPI): API, auth adapter,
│ │ scheduler, MCP and GitHub relays, storage
│ ├── cloud_agent_host/ host agent: runner, sandbox, runtimes, sandbox manager
│ ├── cloud_agent_shared/ vocabulary both images ship: capability strings,
│ │ │ protocol versions, pins, sandbox network, shared errors,
│ │ │ and the operation-profiling collector both sides run
│ │ └── git/ workspace branch names and the source-archive builders
│ ├── migrations/ Alembic migrations (agent-owned database only)
│ └── openapi.json the committed snapshot of this service's OpenAPI
└── frontend/ Next.js user UI + admin dashboard
deploy/{control-plane,host,sandbox}/
tests/{unit,servers,integration,external}/ # plus fixtures/ and support/
docs/{developer,user,agents}/ maintained guides, user notes, agent records
Not every module is worth a line here; these are the ones a newcomer needs to
find early. The control plane's database schema is one baseline revision plus
additive ones (apps/backend/migrations/versions/0001_schema_baseline.py and on), not a
long chain — the extraction record's § Schema baseline rewrite explains why, and
docs/developer/operations.md § Rebuilding the agent database is the runbook for a
database stamped with the chain that predates it.
| Module | What it owns |
|---|---|
apps/backend/cloud_agent/app.py |
The four listeners: the main API, the MCP-only one, the GitHub relay and the model relay |
apps/backend/cloud_agent/api/ |
One router module per surface — jobs, hosts, admin, session, MCP relay |
apps/backend/cloud_agent/schemas/ |
Domain-organized wire models, explicitly exported by cloud_agent.schemas |
apps/backend/cloud_agent/application/ |
Flows that run without a request: job admission and creation, native Fork materialization, residency credentials |
apps/backend/cloud_agent/bootstrap/ |
Internal assembly: the resources the listeners are built from, and the lifespan that starts and stops them |
apps/backend/cloud_agent/workers/ |
The background loops — attempt reaping, queue and goal recovery, sandbox deletion, notifications and maintenance |
apps/backend/cloud_agent/ports/, apps/backend/cloud_agent/storage/ |
Narrow capability contracts and Postgres repositories: claims, fences, residency, pull-request records |
apps/backend/cloud_agent/usage/ (runtime_usage.py, turn_report.py, admin_usage.py, sums.py) |
What agents reported about their model calls, per job and summed for the Usage pages; no cost or quota (usage) |
apps/backend/cloud_agent/accounts/agent_access.py |
The admission decision (agent.use, agent.admin) every protected request reads |
apps/backend/cloud_agent/deployment/identity_namespace.py |
Binds an empty database to the account system once, and refuses a Gateway-era one (--check reads it without writing) |
apps/backend/cloud_agent/accounts/, application/owner_termination.py |
The account system: sign-in, sessions, the waiting list, the account directory, retired-configuration refusal, and stopping a revoked account's work (guide) |
apps/backend/cloud_agent/mcp/, api/mcp_connections.py, api/mcp_relay.py |
Per-user connections, protocol hosting, authorization and the MCP relay |
apps/backend/cloud_agent/mcp/runtime.py |
Process-local MCP host, fenced session maintenance, and shutdown |
apps/backend/cloud_agent/source_control/ (github_relay_policy.py, source_control.py), api/github_relay.py |
The GitHub relay and its policy, and the GitHub/GitLab credentials behind them |
apps/backend/cloud_agent/api/model_relay.py, api/admin_operator_models.py, domain/operator_models.py, domain/model_usage.py |
The model relay, the models a deployment provides, their prices and each owner's daily budget on them |
apps/backend/cloud_agent/schemas/admin_observability.py |
The cross-user admin reading surface and its audit |
apps/backend/cloud_agent_host/service/enrollment.py |
Host registration and the persisted host credential |
apps/backend/cloud_agent_host/service/host_service.py, turn/turn_child.py |
The host service that claims, heartbeats and drains on SIGTERM, and the child process that executes one turn (runner.py is their command line) |
apps/backend/cloud_agent_host/claimed_job.py, control_plane_client.py |
Claim payloads, lease errors, and attempt-scoped worker HTTP calls |
apps/backend/cloud_agent_host/container/chat_sandbox.py, sandbox.py |
Retained group lifecycle and the Docker/Kata boundary |
apps/backend/cloud_agent_host/network/chat_sandbox_network.py |
Per-group bridge and relay lifecycle, guest network proof, and exact network release |
apps/backend/cloud_agent_host/container/chat_sandbox_models.py |
Retained-container specs, inspection/status values, and identity vocabulary |
apps/backend/cloud_agent_shared/egress.py, apps/backend/cloud_agent_host/network/network_guard.py |
The one sandbox network: its configuration and host rules, and the helper that installs and verifies them |
apps/backend/cloud_agent_host/runtimes/, cloud_agent_shared/runtime_catalog.py, shared_protocol.py |
The five Agent CLI adapters, shared discovery facts, and version pins |
Code structure maps module responsibilities and
dependency direction, including the shared profiler and frontend API/UI modules.
Neither implementation package imports the other; shared facts live in
cloud_agent_shared. Lifecycle wiring must not import HTTP routers, and callers
outside storage must not import repository implementations.
make setup-dev # uv venv + uv sync
make check # format + lint + type-check + test — must be green before every PRmake test runs everything except the external and dbtest markers.
Database-backed tests are opt-in. When a change touches storage, SQL, a
migration, or a database test, run make test-db-local (needs Docker). It uses
this machine's shared test PostgreSQL, gives the checkout fresh databases of its
own on every run, and fails if any database test was skipped; ARGS="<paths> -x" narrows it. Never run make test-db-local-down: it discards every
worktree's databases and fails their runs. make test-db runs the tier against
a PostgreSQL you provide (see ENVIRONMENT.md).
For frontend changes, use Node.js 22 and run make ci-frontend. It installs the
locked dependencies with npm ci, then checks lint, formatting, types, tests,
and build guards. make setup-dev installs backend dependencies only.
The developer documentation site is published in English and Simplified
Chinese. When a change edits a page under docs/developer/ or a root guide the
site includes (ENVIRONMENT.md, CONTRIBUTING.md, SECURITY.md,
deploy/local/README.md, deploy/release/README.md), run make docs-translate, translate the
entries it adds or marks fuzzy, and run make docs-verify. On the stable
pages (the home page, the developer guide, the code structure page, and the
SECURITY.md and CONTRIBUTING.md the site includes), CI fails while a string
has no catalog entry or a fuzzy one; on the other pages it warns. No page
catches an entry left empty, which renders in English.
Translating the docs has the workflow and
terminology.
Complexity and size only ratchet down (#771). New code stays within the limits:
backend functions within McCabe 15, 12 branches and 50 statements, backend
modules within 1,500 lines, frontend functions within complexity 15 and frontend
files within 800 lines. The backend limits cover apps/backend only, not
tests, scripts, deploy/ or migrations; the frontend limits skip
*.test.ts(x), __tests__/ and e2e/.
- A backend function over a limit today carries a
noqanaming #771. Never add one to new code. Once the function is back under,make checkdeletes thenoqa(ruff'sRUF100fix). - Oversized backend modules are listed in
tests/unit/test_file_size_budget.pyand may not grow. Lower an entry when its module shrinks. tests/unit/distribution_neutrality_allowlist.jsonlists, by reason, the tracked files that still name one particular deployment of this code, and how often.tests/unit/test_distribution_neutrality.pyfails on a new mention, and on an entry above its file's count; after removing mentions,uv run python scripts/check_distribution_neutrality.py --writelowers the entries.apps/frontend/eslint-ratchet.jsonlists the frontend files over a limit, each at its current value. ESLint applies a listed value to every function in that file, so it lets a new function there go past 15; keep new functions within 15 anyway. Lower an entry when its file improves.
If apps/frontend/node_modules is absent, install it before reporting checks
as blocked. From apps/frontend, run npm ci, npm run type-check, and
npm test; vitest and tsc come from the project's development dependencies.
- Branch off
devas<user>/<scope>/<name>. Pull requests targetdev. - Keep each pull request focused on one feature, fix, or documentation change.
- Put documentation where docs/README.md expects it.
Maintained guides go in
docs/developer/. New designs and implementation plans go indocs/agents/specs/; verification and review records (acceptance runs, reviews of a revision) go indocs/agents/reviews/. Name recordsYYYY-MM-DD-<topic>.md. Do not create other top-level folders underdocs/: tools that default elsewhere, such as the superpowers plugin'sdocs/superpowers/, must write to these folders instead. - Never commit secrets. This repository will be made public; treat every commit as already public.
- Hosts roll out before the control plane, and each side supports the previous
release only. Managed compute hosts are the exception: the new control plane
rolls them forward once it runs
(Compute resources). Code kept for an
older release is marked
COMPAT(remove-after=YYYY-MM-DD): <what makes it removable>in a comment, andtests/unit/test_compat_markers.pyfails once that date has passed: remove the code then, or move the date with the reason it must stay.
The move-only restrictions in rules 1 and 2 expired when the split completed on 2026-08-09. They remain here for reading migration pull requests; cleanups, renames, and refactors are ordinary new work now. Rule 3 remains a quality requirement, and rules 4 and 5 remain permanent service boundaries.
- Move as-is. When a task says "move" a file, change imports and paths only. No renames, no splitting large files, no reformatting, no dead-code removal, no "while I'm here" fixes. A reviewer will diff your result against a pristine copy from the source repo and expect only import lines to differ. Cleanups happen after cutover, as their own pull requests.
- If it cannot move without a logic change, stop and report. Do not improvise a redesign inside a migration task.
- Never weaken a test to make it pass. A ported test that fails is a parity break — report it. Adapting fixtures and imports is fine; changing an assertion is not.
- No HybridInference secrets here. This service never sees the
HybridInference Gateway's
API_KEY_SECRET,JWT_SECRET_KEYor database. Its own tokens use its own secrets (AGENT_CONTROL_TOKEN_SECRET,AGENT_LOCAL_SESSION_SECRET,AGENT_SETTINGS_ENCRYPTION_KEY). A mail transport credential an operator chooses to share (SMTP) is configuration, not identity. - No Gateway dependency. No Python import of
serving.*, ever, and no Gateway sign-in, admission, grant or catalog: those were removed (decision). A configuration naming them is refused at startup.
- Conversation / job / attempt / lease. A conversation (
thread_idin code, a chat or task in the UI) is made of turns, and a job is one turn of it: one user message and the agent's work on it (turn_no; every turn after the first names itsparent_job_id). An attempt is one execution of a turn on one host. Attempts carry a lease with a fencing generation — a superseded attempt's writes are rejected by the store, which is what makes retries safe and stops duplicate pull requests. Code structure has the full glossary. - Model connection. Every turn runs on a model connection: a provider
Base URL, an API key and verified model ids. Usually it is one of its
owner's own, with the owner's own key (FreeInference is one such provider,
used with the owner's key). The claim and the residency of the owner's turn
hand the key to the host; the member's CLI runs on it. A deployment may also
provide connections: an administrator saves the endpoint and the
deployment's key, and every admitted owner may run on it. That key stays in
the control plane: the member's CLI holds a residency placeholder and
reaches the provider through the model relay
(design). A turn runs at Auto (the provider's default)
unless its owner picks a reasoning level the provider's own model listing
declares for that model and API and the agent's CLI can send
(design). MCP servers are
resolved per user by the control plane and reached through its MCP-only
relay with a separate scoped credential. A chat member's CLI holds its
residency's MCP credential, which the relay resolves on every call to the
turn that residency is running, so follow-ups keep the live process.
There is no per-task dollar budget and no billing — the
provider keeps the key's quota and bill. The one local limit is on provided
connections: each owner has a budget per UTC day on a provided connection's
models, and the model relay charges each request the tokens its answer said
it used at the prices the administrator set. That is an allowance, not a
bill: nobody is invoiced, and the provider's own account of the key is what
the deployment pays. The Usage pages show the
tokens agents reported for calls attributable to a turn, labelled
runtime_reported; they are not a provider bill and may undercount it. If a task seems to need a budget field, stop — the plan's C5 section records the decision. - Host. A machine in the pool running the host agent. It registers with a one-time enrollment token, heartbeats, and claims jobs it has capacity and capabilities for. Hosts are added and removed without redeploying the control plane.
- Sandbox. A retained container and workspace per sandbox group. Each chat
has its own native session and active CLI process; Fork adds a member to the
same group and workspace. Native resume stays in that container. There is no
transcript-replay or platform-checkpoint recovery path. See the implemented
session design.
The group has one network, the same for setup and for the agent, with
nothing to select
(design).
Each group gets its own routable bridge, and before anything joins one the
host installs this deployment's nftables table (
cloud_agent_shared/egress.pyrenders it,cloud_agent_host/network/network_guard.pyinstalls and verifies it):- public IPv4 on TCP 80/443, so curl, pip, npm and Git over HTTPS work;
- the one named resolver on TCP/UDP 53;
- the MCP relay's service port on the group's own bridge, which reaches the MCP-only listener for tools;
- the model relay's service port on the group's own bridge, on a host configured with one, which reaches the model relay for provided models;
- the exact public IPv4 address and port of each SSH server its owner registered, checked and enabled for one of the group's chats;
- and nothing else: not the host, private or special-purpose addresses, other groups, other ports, UDP 443, or IPv6. No relay is a caller-selected forward proxy, and there is no proxy at all.
- A sandbox group reaches public IPv4 on TCP 80/443, the one named resolver,
its own MCP relay, the model relay where its host has one, and the exact
public IPv4 address and port of each SSH server its owner registered, checked and enabled for one of the group's
chats — and nothing else: none of the host's addresses, no
private, link-local/metadata, CGNAT, multicast or reserved range, nothing in
AGENT_EGRESS_PROTECTED_CIDRS, no other group, and no IPv6. Per-group bridges and this deployment's host nftables table enforce it on the actual packets; the table is installed and read back before any group joins its bridge, and a failed check stops the group rather than falling back to anything. There is no mode, flag or proxy that widens it, and adding one is a design change, not a configuration. - MCP tool calls are served only on the MCP-only listener; the public main API must not expose them. The MCP tunnel continues to target that listener.
- Accepted owner-key exposure. A member sandbox holds its owner's provider key for the turn it runs, and code or instructions it encounters may cause that key to leave the sandbox (see owner model connections below). It is the owner's own key and quota; it grants no MCP or platform-management authority. See the decision and remaining boundaries.
- Platform-held long-lived account and service credentials never enter the sandbox. Each
member receives its own scoped inference and MCP credentials, which a
sibling member cannot read: members have distinct UIDs, private provider
homes and tmpfs, and no credential is passed as a process argument. That
stops reading, not writing: siblings share one writable workspace,
.gitincluded, so code a member plants there — a Git hook orcore.fsmonitor, a build or test script — runs with the credentials of whichever member executes it next. Model calls use the owner's own provider key and billing, or, on a provided model, the deployment's key, which only the model relay holds (provided models below). No credential GitHub accepts enters a sandbox. A member'sGH_TOKENis its residency'sagh.placeholder, which only the GitHub relay accepts: while the residency serves a running turn it exchanges it for the owner's own GitHub user token, for the conversation's repository only, and refuses branch deletion, tags, GraphQL and repository administration. A member shares the owner's identity, not their token: commits are authored as the owner, and the owner's token, refresh token and the App's private key stay in the control plane. Nothing on the platform scans what goes through the relay; repository branch protection, GitHub's push protection and the repository's CI still apply (design). - Owner-provided environment credentials. An owner may deliberately inject
personal variables and Secrets through a frozen environment binding. Repository
code, dependencies and the agent can read them, send them through the existing
public egress, or write them into files/model context. These are user-supplied
credentials, not platform credentials. The platform encrypts stored values,
keeps Secrets write-only, filters setup/runtime phases and masks known values
in durable output. Nothing on the platform scans what an agent pushes; GitHub's
push protection and the repository's CI are the only checks on a push. These
controls do not prevent deliberate exfiltration. Same-owner Fork inherits the binding; the only cross-owner
sharing is a team's environment, below. Reserved platform/host variable names and the existing fixed
network and member credential boundaries still apply. An owner's
OPENAI_*/ANTHROPIC_*variables supplied through an environment binding never replace an agent CLI's own inference credentials; a member-privateBASH_ENVscript gives them to the commands the CLI runs. See the environment design. - Team environments (2026-10-15). A team's owner and admins may save an environment for the team, and every active member may run their own tasks, in their own sandboxes, with it. Its Secrets are therefore readable by every member who runs it: the write-only editor hides them on screen, not between members, and the dialog says so. A member's personal globals join it only at run time, so its installation is the same for everyone. Its installation snapshots serve the whole team, and a member's task or test can queue a build from a commit that member checked out, so a member can shape what the others' sandboxes start from. Members of a team are one trust domain for its environments, as for a repository they can all push to. A binding records the member's access generation, and every read that hands its values to a container (each turn's claim, terminals, actions, Fork, preparation, tests) refuses once the member has left or the team is deleted; a value already read cannot be taken back, so rotate it. See the team design.
- Owner SSH servers (2026-10-15). An owner may register their own SSH
servers and enable them per chat. The platform generates one Ed25519 key per
server, stores it encrypted, and writes it only into the enabled chat
member's private tmpfs, with the host key the owner confirmed pinned. The
agent, repository code and anything they run can read that key, use or
exfiltrate it, and run anything the server account may run; an owner revokes
it by deleting the server, which cuts live sessions and erases the stored
key, and by removing the key line from
authorized_keys. Keys are masked in the runner's durable output: a key reaches a member only when that member's own turn starts, from that turn's claim, and a push mid-turn only takes access away, so every key in a sandbox is one its running turn masks. The control plane dials a server only when its owner runs a check, under the same public-address rules, an hourly per-owner limit, and a 64 KB cap on what it reads back. See the SSH server design. - Owner model connections (2026-10-27). An owner may add their own
inference endpoint (Base URL, API key, up to sixteen model ids) and run
their own tasks on it; it is the only way anything runs, and they pay the
provider. It is
private to them: never shared with a team, never reachable through another
owner's Fork, and every claim, residency, follow-up, waiting message, goal
round and Fork re-reads it as theirs. The key is encrypted under its own
domain, write-only, and handed to a host only in the claim and residency of
its owner's turn on it; the member's CLI holds it in its environment and
activation tmpfs, never in argv, and the runner masks it in durable output.
Repository code, dependencies and the agent can read and exfiltrate it
through the existing public egress, as with owner environment credentials.
An encrypted scanning copy is still recorded when a key is bound to a
residency and kept with the sandbox group record, even after connection
deletion, but nothing reads it since the workspace snapshot scan was
removed; execution paths never select it. It remains a real provider
secret, and deletion here does not revoke it upstream. Login and Agent admission are
the account system's. Nothing is saved unverified, and there is no manual
declaration of APIs: creating a connection, and an edit that changes its
key or Base URL or adds models, verifies the models it would list inside the
saving request, on the owner's explicit action, before anything is written.
Each model gets a streaming tool round trip on each of Chat Completions,
Responses and Anthropic Messages (at most six provider requests and one of
the owner's hourly checks, all reserved before any call, connection or
not); the save also reads the provider's two model listings once for the
reasoning levels it declares (no inference, no extra check). The save commits only if every model has an agent that can use it,
storing the native outcomes (supported, unsupported or failed) per model;
otherwise nothing is written, not even a new key, and each model's result is
answered. An edit commits only while the row still has the revisions and
model list and stored facts its verification read. Verification has a
240-second overall deadline; a timeout writes nothing. A model without current facts runs on
nothing. A runtime runs on its own API when verified for
that model (Chat Completions: OpenCode, Kilo, pi; Responses: Codex;
Anthropic Messages: Claude Code). Otherwise only Codex and Claude Code may
run on a model verified for Chat Completions, through a member-private
translator: the CLI is exec'd behind a launcher that forks a
loopback service as the same member UID, bound to that one upstream, key
and model, answering only a platform-issued token scoped to the owner,
member, connection revision, client protocol and model (masked by the
runner like the key; a sibling's token is refused)
and dying with the CLI. The CLI never holds the key on that path, and the
service refuses, rather than drops, what Chat Completions cannot carry
(an image a tool returned goes to the upstream as a following user image
part). No other pair is translated, and nothing falls back to another
connection, model or API at inference time; the composer offers, and
every gate accepts, the same per-model paths. A claim and its residency must
agree on both the credential revision and the plan. See the
protocol compatibility design.
A direct plan's CLI starts behind the same launcher, its loopback service
in passthrough (2026-11-05): bound to the connection's one upstream origin,
it forwards the CLI's own requests and credentials unchanged, holds no key
and issues no token, and adds only a size record per model request on the
CLI's stderr -- the system prompt's and tool schemas' characters, never their
text -- which the host reports as a
request_prefixlifecycle event. It is not a network proxy: the member's egress, and the network rules, are the same. A sibling that reaches its port can do nothing through it that the public egress does not already allow. A CLI pointed at its vendor's own host (Claude Code onapi.anthropic.com) is not metered, so no CLI sees another host than it would; a meter that cannot start leaves its CLI pointed at the provider directly. See the request meter design. Listing a provider's model ids to fill in the form writes nothing and proves nothing; it uses the key just entered, or a saved key only at the exact Base URL it was saved with, and spends one hourly check. A verified new key or Base URL replaces every model's facts; that, disabling or deleting closes every process still running on the old state, while a rename, removing models or enabling makes no provider call and closes nothing. The control plane's verification dials only public IPv4 HTTPS under the MCP connection policy and the sandbox's protected ranges, pinned, with no redirects and no upstream body echoed. See the model connection design. - Provided models (2026-11-12). An administrator may save a provider's
Base URL, the deployment's own key and up to eight model ids, and every
admitted owner may run turns on it without any setup. The key is encrypted
under the settings root key in its own domain, is write-only, and never
leaves the control plane: it is in no claim, residency, host, sandbox,
response, log or audit row. A turn's claim marks the connection relayed
and carries no key; the member's CLI is launched at the connection's path
on its host's model tunnel and holds its residency's
amd.placeholder, which no provider accepts. Only the model relay accepts it, on a listener of its own that serves nothing else, and only while that residency is the chat's hot one, serving a running, leased, unstopped turn on an enrolled host, on that connection, at the revision its process started on. Stop, a superseded attempt, a rotated residency, a removed host, and a connection disabled, deleted or moved to another Base URL take effect on the next request, with nothing to revoke at the provider; a rotated key closes no process. The relay is not a proxy: the destination is the connection's Base URL, dialled under the rules of a connection check (public IPv4 HTTPS, pinned, no redirect, no proxy); it forwards only the inference and model-listing paths an agent CLI calls, matched whole, for a model the connection lists; and it refuses any credential that is not its own placeholder, so an owner's key never comes this way. Each owner has a daily budget on a provided connection's models together (a default on the connection, or the account's own amount; zero withholds it), and each model has prices and is open to every owner or to named accounts only. A request is charged when its answer ends: the tokens the answer said it used, at its model's prices, with the inference gateway's formula and rounding; or, when the answer named none or was cut short, an estimate of its input and of the output it had produced. A cost that could not be written is kept, counted against the budget, and written again. The budget is checked on every request and again when a turn is admitted and claimed; requests in flight reserve their estimate of it, and two caps limit how many are in flight. All three are kept per relay process. The cost is the relay's arithmetic on the administrator's prices, not the provider's bill: the provider's own budget on that key remains the limit on what the deployment spends. Accepted: an owner can relay their own running turn's requests to something outside the sandbox, within their own budget and under their own name; an owner can pass a budget by about what their requests in flight cost; a sibling member can cause a member's placeholder to be used, as with the other residency credentials; and the requests and answers of provided models pass through the control plane, which stores and logs none of their content. Nothing falls back between a provided connection and an owner's own. Saving and editing verify like an owner's connection, with the administrator's explicit action, and every change is in the admin audit trail without the key. See the design. - Public multi-tenant use requires Kata isolation, non-root, dropped capabilities, seccomp, and resource limits — plain Docker containers are for trusted internal repositories only.
- The Docker socket is the host's authority. Only the runner and the workspace
broker mount it, and
:roon that mount does not narrow the Docker API: either can start a privileged container, so a flaw in either is a host compromise. No other service may mount it; the deployment tests and CI's Compose check pin the holders.