The Host-Action Bridge: Letting a Sandboxed Agent Control Containers It Cannot Reach
An AI coding agent running inside a container with no container runtime at all is a genuinely good security default. It cannot reach the host's container engine and cannot escalate through the one mechanism that would hand it the keys to everything. The trouble starts the moment the agent is asked to do the work a developer does every day against a real, multi-container application: restart a service, rebuild an image, check whether the stack is up. Every one of those is normally a command against the container engine, exactly what the sandbox forbids. Something has to close that gap without reopening the one it was built to close.
The Problem: A Sandbox That Still Needs to Turn Things On
Stripping the container runtime binary out of an agent image is a small change with an outsized effect. No podman, no docker, no socket: the agent has no path to the host's container engine, and an entire class of escalation closes with it.
That default holds until the agent does real work against a project such as ~/Projects/demo-app, backed by a web tier, an API tier, a database, and background workers, each a separate container on the host. It edits a file in the API service and needs to restart demoapp_api. It bumps a dependency and needs an image rebuilt. None of that is optional friction; it is what agentic development against a containerised application looks like.
The reflex fix, mounting the engine socket back in, undoes the whole premise. A socket is not a scoped permission with a notion of "only restart this one service"; it is the entire engine, equivalent to root on the host, from which an attacker can start a new container with the host filesystem bind-mounted in and run as root inside it. Every precaution taken building the sandbox becomes irrelevant the instant that mount exists. What is needed instead is a channel that grants exactly the handful of actions agentic development requires, and nothing else.
The Bridge: Components and Lifecycle
A host-action bridge is a small, asynchronous, file-based channel that lets the agent request one of a fixed set of orchestration actions, and a separate, trusted process on the host that decides whether to honour it. The agent never runs a command against the container engine; it writes a file describing what it wants, then waits.
Both sides already share one channel that needs no new plumbing: the bind-mounted repository checkout. Inside it sits a spool directory, untracked/demo-bridge/, with fixed subdirectories: tmp/ for atomic writes, requests/ for incoming requests, processing/ for whatever the watcher has claimed, responses/ for outcomes the agent polls, archive/ for completed requests and logs, quarantine/ for input too malformed to classify, and diagnostics/, a mirror the agent can read directly.
The .path unit watches requests/ and triggers the paired .service unit the instant a file appears; checking both file-modification events and a glob at start-up means a request queued while the watcher was down still gets picked up. The .service unit is Type=oneshot: it drains whatever is waiting, once, and exits, so there is no long-running daemon. The watcher script lives outside the bind mount, on a host-only path the agent container cannot write to.
# demo-bridge-demo-app.path
[Unit]
Description=Demo Bridge: watch the request spool for demo-app
[Path]
PathModified=%h/Projects/demo-app/untracked/demo-bridge/requests
PathExistsGlob=%h/Projects/demo-app/untracked/demo-bridge/requests/*.json
Unit=demo-bridge-demo-app.service
[Install]
WantedBy=default.target
# demo-bridge-demo-app.service
[Unit]
Description=Demo Bridge: drain the request spool once for demo-app
StartLimitIntervalSec=60
StartLimitBurst=50
[Service]
Type=oneshot
ExecStart=%h/.local/bin/demo-bridge-watcher-demo-app drain
KillMode=process
StandardOutput=append:%h/.local/state/demo-bridge/demo-app/service.log
StandardError=append:%h/.local/state/demo-bridge/demo-app/service.log
NoNewPrivileges=true
RuntimeMaxSec=1200
On the agent side, the request writer does exactly two things, write a file, and poll for a response, with no dependency on the container engine anywhere in it.
#!/usr/bin/env bash
# request.bash <verb> [service]: runs INSIDE the runtime-less agent
# container. Writes a request file, polls for a response. Never touches
# the container engine itself.
set -euo pipefail
SPOOL="$REPO_ROOT/untracked/demo-bridge"
VERB="$1"; ARG="${2:-}"
TS="$(date -u +%Y%m%dT%H%M%SZ)"
NONCE="$(od -An -tx1 -N8 /dev/urandom | tr -d ' \n')"
REQ_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
BASE="${TS}-${VERB}-${NONCE}.json"
TMP="$(mktemp "$SPOOL/tmp/req.XXXXXX")"
jq -n --arg verb "$VERB" --arg arg "$ARG" --arg ra "$REQ_AT" \
'{verb:$verb, arg:($arg | select(length>0)), requested_at:$ra}' > "$TMP"
mv -f "$TMP" "$SPOOL/requests/$BASE" # atomic publish
RESP="$SPOOL/responses/${BASE}.response.json"
elapsed=0
while [ "$elapsed" -lt 120 ]; do
if [ -f "$RESP" ]; then
state="$(jq -r '.state // empty' "$RESP")"
case "$state" in
done) exit 0 ;;
denied|expired|failed) echo "bridge: $state" >&2; exit 70 ;;
esac
fi
sleep 2; elapsed=$((elapsed + 2))
done
echo "bridge: timed out waiting for a response" >&2
exit 75
Every request leaves requests/ on every code path, accepted, denied, expired, or too malformed to read, which stops a bad file wedging the pipeline: the .path unit re-triggers on anything left sitting there, crash-looping the service if the watcher ever failed to clear it. A subtler reason the watcher acknowledges a request before validating it: it writes a queued response the instant it starts draining, because more than one invocation can start at once and only one holds the single-flight lock. Without that acknowledgement, the losing invocation would leave the request unacknowledged, and the agent's poll loop would time out even though the winner was moments from handling it.
The channel is deliberately asynchronous rather than a fast RPC, acceptable because the verbs on offer are "rebuild an image", not high-frequency calls, and it keeps working even when the whole stack is down.
The Trust Model, in Full
Everything from here traces back to one invariant: the agent container never gains the ability to run an arbitrary command on the host. Every gate below closes a specific way of violating that invariant.
A closed verb allowlist with a fixed argv
The single most important property in the design. A request carries a verb, a short enumerated string, and for a handful of verbs an arg, also enumerated, never a command line or free text. The watcher's dispatcher is a case statement: each verb maps to exactly one hardcoded argv shape, fixed when the watcher was written.
# --- Hardcoded literals (cannot be changed from the container) -------------
readonly HARDCODED_DENY="reset-db shell:web shell:api shell:db exec"
readonly ALLOWLIST="ping status health up down restart rebuild init logs"
readonly ARG_VERBS="restart rebuild logs"
readonly READONLY_VERBS="ping status health logs"
readonly BUILDABLE_SERVICES="web api worker"
# --- Fixed-argv dispatch: the ONLY thing ever executed ----------------------
build_argv() {
local verb="$1" arg="$2"
ARGV=()
case "$verb" in
status) ARGV=("$STACK_BIN" status) ;;
health) ARGV=("$STACK_BIN" health) ;;
up) ARGV=("$STACK_BIN" start) ;;
down) ARGV=("$STACK_BIN" stop) ;;
restart) ARGV=("$STACK_BIN" restart "$arg") ;;
rebuild) ARGV=("$STACK_BIN" build "$arg") ;;
init) ARGV=("$STACK_BIN" init) ;;
logs) ARGV=("$STACK_BIN" logs "$arg") ;;
*) return 2 ;; # unreachable post-validation
esac
return 0
}
Nothing interpolates request content into a shell string; there is no eval, no sh -c fed by user input. arg passes validation before it reaches this table and arrives as a single argv element, never concatenated into something a shell parses. That is what makes "closed allowlist" a real security property: the complete set of commands the bridge can ever run is enumerable by reading one function.
Read-only versus mutating, arg enumeration, and a narrower "buildable" set
Verbs split into read-only (status, health, logs, ping) and mutating (up, down, restart, rebuild, init). Rate limiting exempts read-only verbs, and the integrity gate below only runs ahead of mutating ones.
restart, rebuild, and logs take a required argument naming a service, checked twice: a regex rejecting shell metacharacters and whitespace, then an enumerated list of real service names taken from the project's orchestration definition at install time. An argument passing the regex but naming no real service is still denied. rebuild checks against a narrower "buildable" enum: a test-runner sidecar can be excluded entirely, so it can never be bridge-targeted at all.
validate_request() {
local base="$1" raw="$2"
V_VERB=""; V_ARG=""; V_DENY=""
# Filename schema: rejects hostile basenames outright.
[[ "$base" =~ ^[0-9]{8}T[0-9]{6}Z-[a-z-]+-[0-9a-f]{16}\.json$ ]] \
|| { V_DENY="basename schema violation"; return 1; }
local verb; verb="$(jq -r '.verb // ""' <<<"$raw")"
[ -n "$verb" ] || { V_DENY="missing verb"; return 1; }
# Hardcoded-deny checked BEFORE the allowlist: cannot be overridden
# by any config file.
for d in $HARDCODED_DENY; do
[ "$verb" = "$d" ] && { V_DENY="hardcoded-deny: $verb"; return 1; }
done
local ok=0
for a in $ALLOWLIST; do [ "$verb" = "$a" ] && ok=1; done
[ "$ok" -eq 1 ] || { V_DENY="unknown verb: $verb"; return 1; }
# Arg-taking verbs: required, regex-shaped, then enum-checked.
for a in $ARG_VERBS; do
if [ "$verb" = "$a" ]; then
local arg; arg="$(jq -r '.arg // ""' <<<"$raw")"
[ -n "$arg" ] || { V_DENY="verb '$verb' requires an arg"; return 1; }
[[ "$arg" =~ ^[a-z][a-z0-9_-]*$ ]] || { V_DENY="arg shape"; return 1; }
local enum="$SERVICE_ENUM"
[ "$verb" = "rebuild" ] && enum="$BUILDABLE_SERVICES"
local member found=0
for member in $enum; do [ "$arg" = "$member" ] && found=1; done
[ "$found" -eq 1 ] || { V_DENY="arg '$arg' not enumerated"; return 1; }
V_ARG="$arg"
fi
done
V_VERB="$verb"
return 0
}
Order matters too: the hardcoded deny list is checked before the allowlist, so a raw shell-into-container action sits in a list no config file can override, checked unconditionally, in code, before policy gets any say.
Per-verb policy: auto or deny, deliberately nothing in between
A policy file, seeded once at install and never silently overwritten on reinstall, maps each verb to auto, executed once every other gate passes, or deny, always refused with a reason logged. A verb missing from the file, or carrying an unrecognised mode, fails closed to deny.
# demo-bridge policy: per-verb execution mode.
# Parsed as KEY=value, never sourced. Unknown/missing keys fail CLOSED
# to deny. There is no "confirm" mode by design: deny it here and run
# it by hand if you want a human gate.
MODE_ping=auto
MODE_status=auto
MODE_health=auto
MODE_logs=auto
MODE_up=auto
MODE_down=auto
MODE_restart=auto
MODE_rebuild=auto
# init resets seed data: flip to deny if you would rather run it by hand.
MODE_init=auto
Conspicuously absent is a third mode, "ask a human to confirm." That absence is deliberate: a bridge pausing mid-flight for confirmation needs a process listening for it, reintroducing the standing, addressable channel the design exists to avoid. A project wanting a human gate on some verb should set it to deny and run the command by hand instead, a real gate enforced by the absence of any automated path.
Rate limiting as a deliberate feature, not friction
A sliding-window counter caps how often mutating verbs can run, independent of policy, exempting read-only verbs. It bounds the blast radius of a runaway loop: a bug hammering rebuild degrades to "denied, rate limited" instead of repeatedly rebuilding images. The first time it engages, mid-session during heavy debugging, resist the instinct to raise or remove it: hitting the limit occasionally is the cost that buys the protection, and the correct response is to wait it out.
Atomic publish and symlink-safe moves
Every request filename embeds a timestamp and a random nonce, so filenames never collide or replay. Every write into the spool follows the same pattern: write to a temporary file on the same filesystem, then move it into place. That move is atomic and, critically, replaces whatever sits at the destination rather than following it, so a planted symlink cannot write through to an arbitrary target.
# Write into a pinned spool subdirectory atomically and symlink-safely.
# tmp/ is on the same filesystem as the destination, so mv is atomic;
# rename() replaces a destination symlink rather than following it.
publish_atomic() {
local destsub="$1" destname="$2" content="$3"
local tmp
tmp="$(mktemp "$SPOOL/tmp/wtmp.XXXXXX")" || return 1
printf '%s' "$content" > "$tmp" || { rm -f "$tmp"; return 1; }
mv -f "$tmp" "$SPOOL/$destsub/$destname" || { rm -f "$tmp"; return 1; }
}
The watcher goes further: for the whole duration of a drain it holds one directory file descriptor open per spool subdirectory, verified as a real directory rooted under the spool rather than a swapped-in symlink, and performs every open, move, and prune through that pinned descriptor, closing the window between checking a path and acting on it.
Quarantine, not silence, for malformed input
A file in requests/ that fails to parse as JSON, carries a filename outside the expected schema, or is not a plain regular file, a symlink or FIFO planted where a request should be, moves to quarantine/ rather than being left, deleted, or processed. Leaving it would crash-loop the .path unit; deleting it silently would erase evidence of an attack or a writer bug. Quarantine preserves the artefact while keeping requests always leaving the inbox.
The audit log: off the mount it audits
Every outcome, accepted, denied and why, expired, succeeded, failed with what exit code, is appended to an audit log under the host's own state directory, off the shared mount, because a compromised agent container could otherwise tamper with its own history. A best-effort mirror lands in diagnostics/ for the agent, but that copy is diagnostic-only, never authoritative.
An integrity gate on the trusted computing base itself
Before any mutating verb, the watcher closes an obvious bypass: what if the agent edited the files that define orchestration, then triggered a verb acting on the edited version? Tracked files, the orchestration script, the compose definition, the reverse-proxy configuration, are checked against HEAD with git status; any local modification refuses mutating verbs until committed or reverted. A SHA-256 of the untracked .env file, computed once at install and baked into the watcher copy, catches the gap a git check alone would miss: if .env changes, mutating verbs are refused until a human re-runs the installer. Both checks are honestly scoped, catching "edit the config, trigger a rebuild that does something else", not every transitively-touched file.
Transient scopes and the inherited file descriptor problem
A Type=oneshot service starts, does its work, and exits, and by default its whole cgroup is torn down with it. Several bridged verbs deliberately leave long-running containers behind. Wired up naively, either the containers get killed the instant the oneshot exits, or they linger inside its cgroup, which never settles back to idle, stopping the watcher being re-triggered. The fix launches those verbs inside a transient systemd scope via systemd-run --user --scope, placing spawned containers in a sibling unit that outlives the drain, while the drain's own cgroup still empties out cleanly. One wrinkle costs an afternoon if missed: a file descriptor the drain holds open, a lock, a pinned spool descriptor, is inherited by the scoped child unless explicitly closed, so the lock it represents stays held open for as long as that long-running child survives, long after the drain itself has exited. The fix is unglamorous: close every non-essential descriptor first.
What the bridge deliberately cannot do
No verb, argument, or combination reaches an eval, a shell fed by request content, or any string-built command; the executed surface is exactly the fixed argv table. The watcher runs as the same unprivileged host user under a systemd --user session, never root, no sudo anywhere. Every variable parameter is validated against a closed enumeration and passed as a discrete argv element. There is no socket, no SSH key, no open port: just a filesystem path both sides already had access to.
Why the Obvious Shortcuts Lose
Four alternatives get reached for before a bridge like this, roughly in order of how tempting each looks and how badly each one costs.
Mounting the container engine's socket into the agent container is the most tempting and the worst. It grants an attacker who compromises the agent everything, root on the host, not root inside a container: from the socket, a new container can be started with the host filesystem bind-mounted in and run as root. Every precaution taken building the sandbox becomes irrelevant the moment this mount exists.
SSH back to the host trades a socket for a credential, which is not an improvement. A private key authenticating as a real host user now lives inside the sandbox, so compromising the container means compromising something trusted everywhere. It also hands back a full interactive shell, since SSH does not constrain which commands can run unless forced commands and restricted shells are bolted on afterwards, in effect a worse-designed version of the bridge being avoided.
Running the agent privileged is giving up on the premise entirely. A privileged container can escape to the host through several well-known techniques, and once that door is open, "can the agent damage the host" becomes a question of how much every dependency it might pull in can be trusted.
Opening host firewall ports for a small control API looks the most principled, architecturally closest to the bridge, but stands on a worse foundation. A listening service has to defend against being reached by more than the one container it was meant for, needs its own authentication and TLS story built from nothing, and tends to accumulate as untracked drift, unlike a channel authenticated by its placement inside a bind mount only one container can see.
The bridge's cost is real: multi-second latency rather than an instant call, and a small, fixed set of things the agent can ask for. For orchestration verbs, that trade favours the bridge, because none of those actions benefit from being instant, and all benefit from being closed, validated, and audited.
Lessons That Generalise Beyond This One Bridge
A handful of failures only became obvious after watching a bridge like this run for a while.
Namespace every host-global artefact by project from day one, not just the in-repo spool. A first bridge built for one project tends to get simple, global-sounding names for its systemd units, config directory, and installed binary, which works until a second, unrelated project installs its own copy on the same host. Installers of this kind are meant to be idempotent and self-healing, so the second install does not fail; it silently overwrites the first project's live bridge, and one project's agent starts issuing requests validated against another project's service list. Nothing crashes; it quietly does the wrong thing. Carry a project slug in every unit name, config path, and binary name outside the project's own checkout.
# project-slug.bash: sourced by every host-side script.
PROJECT_SLUG="${PROJECT_SLUG:-demo-app}"
# Derived, namespaced locations: every host-global artefact carries the
# slug so a second project's install can never collide with the first's.
UNIT_PATH="demo-bridge-${PROJECT_SLUG}.path"
UNIT_SERVICE="demo-bridge-${PROJECT_SLUG}.service"
CONFIG_DIR="$HOME/.config/demo-bridge/${PROJECT_SLUG}"
STATE_DIR="$HOME/.local/state/demo-bridge/${PROJECT_SLUG}"
WATCHER_BIN="$HOME/.local/bin/demo-bridge-watcher-${PROJECT_SLUG}"
Resolve aliased binaries at install time, never at call time. A tool used daily from an interactive shell is often a shell alias, invisible to anything that is not that shell. A systemd --user unit's PATH comes from its own environment, not from sourcing rc files, so resolving such a tool at call time fails even though the same name works at a prompt. Resolve it once, at install, and bake the absolute path into the generated configuration.
Make remediation output copy-pasteable, with no log prefix glued onto it: a prefixed warning cannot be pasted straight into a terminal, and stripping the prefix by hand turns a five-second fix into "later."
"The agent cannot reach the app" is usually a networking-join problem, not a firewall problem: health checks time out even though the same address works from the host. Opening a firewall port fixes it but is the wrong default, untracked drift nobody remembers to close. The better default is one more bridged verb that joins the agent container to the application's own network, demo-app-network, so the agent reaches services by name with no host port ever opened.
Restrictive file permissions written by one process can break a completely different one. A worker on the shared checkout can write new files owner-only where the project is group- and world-readable, which surfaces elsewhere: a container running as a different user gets a permission denial that can look like almost anything except a permissions problem.
Rate limits biting during interactive debugging are the system working, not a bug. Wait out the window, the way a careful operator would pace manual restarts.
The General Shape
None of this is specific to AI coding agents, to any one container engine, or to systemd. Whenever a sandboxed process legitimately needs to trigger a privileged action on a system it cannot otherwise reach, the same shape holds: a closed, code-level set of pre-approved actions rather than a generic execution channel; validation and policy that treat different actions as genuinely different risks; and a durable, tamper-resistant record of what happened rather than trusting the sandboxed side's own account. Loosening the sandbox is always the cheaper-looking option, one mount or one credential away. A purpose-built channel costs more to build once, and it is the only one of the two that still shows, with certainty, exactly what ran and why, on the day it turns out to matter.
Need infrastructure that's actually run this way?
Get in touch