Standalone security release. Replaces the network-facing agent's broad Docker authority with a small, typed executor that holds the Docker socket and exposes only the operations Composer actually performs.
Independent of the fleet-management plan. This hardening needs none of the remote-settings / snapshot-v2 / alerts / profiles work and must ship, canary, and roll back on its own track — ahead of, and decoupled from, any remote mutation feature. It is a prerequisite for those features, not part of them.
Today the network-facing composer-agent is handed near-total Docker authority:
- It reaches Docker through
DOCKER_HOST=tcp://docker-socket-proxy:2375(agent_installer.py). - The
docker-socket-proxy(tecnativa) is grantedCONTAINERS, IMAGES, NETWORKS, VOLUMES, EVENTS, EXEC, POSTall=1.EXEC=1+POST=1across every resource is effectively arbitrary code execution in any container plus full create/destroy of containers/images/volumes/networks.
Yet the agent's actual Docker-mutating surface is a small named set
(agent_protocol.REMOTE_ACTIONS):
| Action | What it does | Existing guard |
|---|---|---|
dlux.image_update |
compose pull + up -d --force-recreate (scoped) |
version_gate rejects downgrades; scoped service list |
composer.restart |
compose restart <service> |
PROTECTED_RESTART_SERVICES + COMPOSER_AGENT_RESTART_SERVICES allowlist |
composer.recovery_deploy |
recreate from known-good state | requires a reason |
dlux.backup.create |
management command via compose exec/run |
scoped one-off |
agent.rotate_credentials |
credential store only | no Docker authority |
Crucially, on a remote command the agent does not call Docker itself — it
spawns a python -m composer restart|update … child process
(agent.py _run_child) that drives docker compose.
The privilege is over-provisioned relative to what's used: the whole attack
surface exists to run four bounded operations.
Threat model. The agent holds an outbound connection to a Control Plane and
processes untrusted-ish remote commands. A compromise or a command-injection bug
in that network-facing component currently converts to full host-Docker control
(EXEC on any container = root-equivalent on the host in most deployments). The
goal is to make the network-facing component hold zero Docker authority.
Two decisions are locked:
- Separate executor, same image. A new
composer-executorrole (reuses thedebeski/composerimage, different command) holds the real Docker socket and performs every Docker write. Thecomposer-agentkeeps all its control-plane, enrollment, validation, and status logic and its read-only Docker access — itsDOCKER_HOSTstill points atdocker-socket-proxy, but that proxy is demoted so the agent can inspect and never mutate. The agent holds no real Docker socket and no write authority; it asks the executor to perform writes over a private Unix socket on a shared volume. docker-socket-proxydemoted to read-only. It stays for the agent's health/inventory reads (ps,inspect,image inspect, events) but withPOST=0, EXEC=0— GET-only. A compromised agent can therefore enumerate but never mutate; all write authority lives only in the executor.
Control Plane
│ outbound only
▼
composer-agent ──── reads ───▶ docker-socket-proxy (POST=0, EXEC=0)
(no real socket; │ ro (GET only)
DOCKER_HOST → RO proxy) ▼
│ typed write request dockerd
│ (private unix socket) ▲
▼ │ docker.sock (rw)
composer-executor ───────────────┘
(watches image-update trigger;
socket: restart, recovery_deploy;
re-validates every request)
The executor is the sole holder of write authority and the security boundary. The agent pre-validates for UX; the executor re-validates as the authority (defense in depth — never trust the caller).
Both roles run cap_drop: ALL with no-new-privileges:true. But the deploying
role runs docker compose up, which reads the project's 0600 .secrets/.env
locally to resolve secrets before handing the config to dockerd. cap_drop: ALL
strips CAP_DAC_READ_SEARCH, so even UID 0 cannot read a file it does not own —
the deploy then fails the secrets guard with a Permission-denied error, and the
only workaround is a manual per-host setfacl, which defeats unattended inline
updates. The deploying role therefore carries cap_add: [DAC_READ_SEARCH] — a
read-only file/dir override (no write, exec, or setuid bypass). In the
hardened topology that is composer-executor only; the network-facing
composer-agent never deploys and keeps no file caps. In the agent-only
topology the agent is the deployer and carries the cap. Regressing this (dropping
the cap_add) breaks every inline deploy — it is asserted in the stack tests.
The executor is the only process with Docker authority. It owns two channels,
both reusing the existing proven op code (docker_compose_manager,
version_gate, the restart allowlist) rather than reimplementing it. A single
one-operation-in-flight lease serializes across both channels.
Image updates are already file-triggered: DLUX — the inline updater, or the agent
bridging a remote dlux.image_update — writes image-update-request.json, and a
resident watcher shells the composer update pipeline (pull → version_gate
downgrade-reject → recreate → health → post-start). Today that watcher runs
inside the agent (WatchRuntime, honoring DOCKER_HOST). This work moves the
watcher's Docker-executing loop into the executor; the trigger contract with
DLUX is unchanged, only the process holding the socket changes. The agent no
longer performs updates — it reports status by reading deploy-status.json, as it
already does.
Only operations the agent initiates from a remote command need the socket:
| Socket request | Validated fields | Executor-side re-validation |
|---|---|---|
restart |
operation_id, service? |
Reject PROTECTED_RESTART_SERVICES; require membership in COMPOSER_AGENT_RESTART_SERVICES; empty service ⇒ the configured allowlist only. |
recovery_deploy |
operation_id, reason (non-empty) |
Recreate only from the known-good active state; never an arbitrary image/compose. |
dlux_package_apply |
operation_id, version, filename, sha256 |
Filename must match the wheel shape (so it can never carry a path separator); digest must be SHA-256 hex; the staged bytes must hash to it, and the manifest inside the wheel must name that version and permit inline install. |
dlux_package_rollback |
operation_id |
Restores the previous release already on the volume; takes no payload. |
Each request carries protocol_version; the executor rejects an unknown version
(the fail-safe handshake) and runs one operation at a time.
An inline release swap needs two capabilities this split deliberately keeps
apart: egress to fetch and verify the wheel, and Docker authority to
restart and health-gate the services running it. The executor has the second and,
sitting alone on the internal: true docker_proxy network, can never have the
first — giving it egress would put the host-root-equivalent process on a routed
network, which is the whole thing this design removes.
So the halves each do what they can:
- The agent owns
package-update-request.json(the executor's loop ignores it). - On a request the agent resolves the release, verifies PyPI's Trusted Publisher
attestation and the SHA-256, and writes the wheel to
downloads/on the shared runtime volume. - It sends
dlux_package_applywith the version, filename and digest. - The executor re-hashes the staged bytes, re-reads the manifest inside the wheel, unpacks, activates, restarts, health-gates, and rolls back if the deployment does not come back healthy — without one outbound connection.
The digest travels over the socket rather than in a file beside the wheel because
celery mounts that volume read-write too: a record kept next to the bytes is a
record a planted wheel's author could write. Bytes may come from the volume; what
they must hash to may not.
The agent still holds no Docker authority — it stages a file and asks. Exit 3 ("rolled back and still unhealthy") crosses back unchanged, so a caller that retries on failure does not retry a deployment that needs a human.
dlux.backup.createruns a DjangoLux management command inside the DLUX container (no Docker authority); the agent bridges it to DLUX unchanged.agent.rotate_credentialsis agent-local (credential store, no Docker).- Fetching a DjangoLux release needs the network the executor does not have; the agent stages the verified wheel and the executor swaps it (see C).
- Reads — availability via
docker image inspect,ps/inspectfor health — stay with the agent through the read-onlydocker-socket-proxy.
- Arbitrary Docker requests, raw
composeargs, shell strings, container definitions, mounts, services, images, volumes, networks. - Any
exec(the only exec paths — backups — are DLUX-side, not in the executor). - Any image without verified release metadata or an immutable digest (image-update
path, enforced by
version_gate). - Any service outside the configured scope/allowlist.
- Transport: a Unix domain socket on a dedicated shared volume
(
composer_exec_sock), mounted into agent and executor only. Never TCP, never the runtime bridge dir. - Authorization: filesystem permissions (
0770, dedicated uid/gid shared by the two roles) plusSO_PEERCREDpeer-uid check in the executor. Optional shared secret handed to both roles via the same mechanism as existing agent env, as belt-and-suspenders. - Framing: length-prefixed JSON request → JSON result, both bounded (reuse the existing 64 KiB command limit). One request per connection; the executor closes after the terminal result.
- Versioning: every request carries a
protocol_version; the executor rejects an unknown or mismatched version with a typed error instead of guessing. A transient agent/executor skew (e.g. mid-update) therefore fails safe — the agent surfaces "executor version mismatch" rather than handing an operation to an executor that might interpret it differently. - Liveness: the agent treats socket-unavailable as "executor down" and surfaces it as a health/agent-status condition — it must never fall back to a direct Docker path (there is none).
Applies to the shared runtime bridge the agent and executor use, and is part of this release even where it's not executor-specific:
- Symlink-safe writes: private unique temp file in the same dir,
O_NOFOLLOWreads, restrictive perms, atomicrenamereplacement. - Reject following symlinks on read; reject world/group-writable inputs.
- Size and count caps on every file consumed.
- Bounded command, event, result, snapshot, and artifact queues (drop-oldest or reject-new with a typed error; never unbounded growth).
The docker-socket-proxy service, the docker_proxy network, and the agent's
Docker wiring are generated by dlux and asserted by its stack contract, so this
release is a coordinated composer + dlux-scaffold change.
compose.yml.tmpl:- Add the
composer-executorservice (same image,executorcommand, holds the realdocker.sockrw, not network-facing). - Keep
composer-agent'sDOCKER_HOSTpointing at the (now read-only) proxy for its reads; addCOMPOSER_EXECUTOR_SOCKETand the sharedcomposer_exec_sockvolume so it can send typed writes to the executor. - Flip the proxy env to
POST=0, EXEC=0(and dropVOLUMES/NETWORKS; keep GET for containers/images/events). - Add the
composer_exec_sockvolume, mounted into agent + executor only.
- Add the
stack_contract.json: addcomposer-executor(networks, restart class, read/write volume split so the executor — not the agent — is the docker-authority holder); adjustdocker-socket-proxyto read-only.test_scaffold.pynetwork topology tests: assert the agent has no docker path, the executor is the only service on the docker-authority path, and the proxy publishes no write grant.- Docs: security/DSRP, inline-updater deployment architecture, reference stack table.
agent_installer.py: emit the executor service + read-only proxy + socket volume; keep the guarded, diff-printed migration UX. Addcomposer-executorto the exclusion/self-management sets alongsidecomposer-agent,docker-socket-proxy.- Migration for live deployments (decrees, dhub, sales-crm, …): an
executor enableone-cycle forwarder that prints the migration diff from "broad-proxy agent" to "executor + read-only proxy", mirroring howagent enablemigratedcomposer-updater→composer-agent. Carry forward the existing caveat: a stale pre-migration proxy/agent must be reconciled before the first post-migration update. executor enableMUST be wired intocomposer check --fix, exactly likeagent enableis today.checkalready detects the legacy-agent topology (_check_topology) and hints at the fix;_maybe_fixgains an "agent present, no executor" condition that — after the standard consequences/confirm prompt — runs theexecutor enablemigration. So an operator never has to know the command name:composer check --fixboth diagnoses and hardens, andcomposer executor enable --applyremains the direct path. This is a non-optional acceptance criterion for the slice.
Composer already separates two Docker-authority paths, and the split preserves that separation rather than adding a new self-recreation problem:
- Operator / deployer path —
start.sh→docker run debeski/composer …with the host socket. Operator-invoked, transient, not network-facing; legitimately holds full Docker access. This is howupdate,agent update,agent restart, andagent offalready run. - Resident / agent path — the long-lived, network-facing agent that processes remote Control-Plane commands. This is the only path being de-privileged.
Consequences to design in:
- No resident service recreates itself.
agent updateis an operator action performed by the transient deployer, which recreates the resident services — so the executor holding the socket is never asked to replace itself, and no network-facing component needs Docker authority to self-update. agent updatemust target the resident pair. TodayAGENT_SERVICEis a singlecomposer-agent(launcher.py); with the split,agent update,agent restart,agent off, andagent checkmust act oncomposer-agentandcomposer-executortogether — pull the shared image once and recreate both, so they can never drift to different versions.agent checkchecks one image for the pair (both aredebeski/composer), so a single availability check still covers both.
Together with the socket protocol_version handshake (§4), a partially-applied
update fails safe instead of running a new agent against an old executor.
Own release train, deployed before any remote-settings work:
- Composer
v1.3.0(executor role + agent client + installer/migration). - DjangoLux scaffold
v1.6.0(executor service, read-only proxy, contract, tests).
Deploy order — Composer first. A freshly-generated project references the
executor command; if the dlux scaffold shipped first, that project would pull a
composer:latest without the executor and fail to start. So:
- Composer
v1.3.0published first —debeski/composer:latestnow has theexecutor runrole, so both new stacks andexecutor enablemigrations resolve. - DjangoLux scaffold
v1.6.0published — new generations get the hardened stack. - Existing deployments migrate via
composer check --fix/executor enable(diff-reviewed), then a normal update recreates the agent + executor. - Non-production canary: run image update,
restart, andrecovery_deployend-to-end through the executor; confirm a raw Docker mutation from the agent fails (its proxy is read-only). - Low-risk production canary, then fleet-wide.
Rollback: revert Composer v1.3.0 (executor withdrawn, agent returns to the prior
proxy model) independently of dlux; no feature depends on this, so rollback is
clean.
Executor:
- Rejects every non-typed request: raw docker/compose args, shell, container defs, mounts, arbitrary images/volumes/networks, non-backup exec.
version_gatedowngrade rejection; scope/exclusion enforcement.- Restart allowlist + protected-service rejection.
recovery_deployrequires a reason and uses only known-good state.- Requires verified release metadata or immutable digest.
- Single in-flight lease; second concurrent request rejected.
Socket & bridge:
- Peer-uid rejection; wrong-perms socket rejection.
- Symlink/traversal/oversized/queue-exhaustion inputs rejected.
- Length-prefix bounds; malformed frames rejected.
Agent:
- Its
DOCKER_HOSTproxy is read-only: a mutating Docker call (create/recreate/ exec) from the agent is rejected by the proxy; only reads succeed. - Holds no real Docker socket; all writes go through the executor.
- Executor-down surfaces as a health condition, never a direct-Docker fallback.
dlux scaffold:
- Topology tests: agent off the docker-authority path; executor is the only writer; proxy publishes no write grant.
- Generated stack matches
stack_contract.json.
End-to-end:
- Old-agent → migrate → executor path for update/restart/recovery/backup.
- Composer/executor restart mid-operation; durable replay is a no-op.
- Live
image_update+ rollback with the executor in place.
- No remote-settings, snapshot-v2, alerts, or profiles work (separate plan).
- No change to the Control-Plane protocol beyond what the split requires.
- No new remote capability — the executor only relocates existing authority.