Skip to content

Add container image, honest about attribution loss - #33

Merged
reesebuilt merged 3 commits into
mainfrom
agent/4.2b-docker
Aug 5, 2026
Merged

Add container image, honest about attribution loss#33
reesebuilt merged 3 commits into
mainfrom
agent/4.2b-docker

Conversation

@reesebuilt

Copy link
Copy Markdown
Owner

What

A container image for Agentwall, plus the container section of docs/install.md, plus the
docker ecosystem in Dependabot.

Files: Dockerfile (new), .dockerignore (new), examples/container.config.yaml (new),
docs/install.md, .github/dependabot.yml. release.yml is untouched; the docker job
spec for 4.2a is at the bottom of this description.

The honest part

Process-level egress attribution reads /proc/net/tcp (per network namespace) and then
/proc/<pid>/fd (per PID namespace, and resolving those symlinks is gated by
PTRACE_MODE_READ). A default container has its own of both namespaces and runs as uid
1000, so the headline capability degrades to pid null, comm unknown.

Rather than assert that, I measured it. Eight flag combinations, one real HTTPS CONNECT
through the container's forward proxy each time, host client running as uid 1001 gid 1001,
Linux 6.8 / Docker 29.1.3 / AppArmor enabled:

Flags Result
(default) pid null. Client socket is not in the container's netns at all.
--network=host pid null. Socket found; 1 pid visible, so no owner.
--network=host --pid=host pid null. 457 of 460 /proc/<pid>/fd unreadable as uid 1000.
--network=host --pid=host --user 1001 pid null. Bare --user <uid> assigns gid 0, and the gid must match too.
--network=host --pid=host --user 1001:1001 pid null. AppArmor docker-default denies the symlink read.
--network=host --pid=host --user 1001:1001 --security-opt apparmor=unconfined pid 1300177 comm curl. Same uid and gid only.
--network=host --pid=host --user 0 --cap-add=SYS_PTRACE --security-opt apparmor=unconfined pid 1300177 comm curl. Any process on the host.
--pid=host --user 1001:1001 --security-opt apparmor=unconfined pid null. No --network=host, so the socket is invisible and the PID namespace is moot.

Two findings worth calling out, because I would have gotten both wrong by reasoning alone:

  1. AppArmor, not capabilities, is the real blocker on Debian and Ubuntu. With
    --user 0 --cap-add=SYS_PTRACE, readdir of every host /proc/<pid>/fd succeeds
    (0 EACCES across 427 pids) and attribution still returns null, because docker-default
    permits ptrace/read only against peers in the same profile, so readlink of the fd
    entries returns EACCES. Only adding --security-opt apparmor=unconfined fixes it.
  2. ptrace_may_access checks gid as well as uid. --user 1001 alone assigns gid 0 and
    fails; --user 1001:1001 is required.

Both working combinations are expensive, and the doc says so plainly rather than listing
them as configuration. The root variant gives up four of the five isolation mechanisms a
container provides, and the doc states that it is not a smaller decision than installing on
the host.

The sidecar case is the one I would recommend, and it also is measured: sharing
namespaces with one agent container rather than the host attributes that agent fully with
no host namespace and no AppArmor change, because docker-default allows the read between
two containers under the same profile.

docker run --rm --network=container:agentwall --pid=container:agentwall --user 1000:1000 \
  -e http_proxy=http://127.0.0.1:3128 alpine wget -q -O /dev/null http://example.com
{"host":"example.com","port":80,"scheme":"http","method":"GET",
 "client":{"pid":31,"comm":"wget"},"decision":"allow"}

Two defects the build surfaced and this PR fixes

  • COPY carries the build context's file modes. On this box (umask 077) public/,
    examples/, and package.json landed in the image as 0600. The image then only worked
    for uid 1000: --user 1001 died at startup on EACCES opening the policy file, which is
    exactly the flag host attribution requires. It also made the image bytes depend on who
    ran docker build, which a release artifact cannot do. Modes are normalized in the image.
  • VOLUME ["/app/state"] is a trap here. It creates an anonymous volume per
    docker run, initialized with uid 1000 ownership, unwritable by a run using
    --user 1001:1001. Removed; the doc says bind-mount instead, which is what an audit chain
    that must outlive the container wants anyway.

Build properties

Two stages, both node:22-slim pinned by digest
(sha256:f576cc608b02e6b04bb0700e13be83eb5ceb7bb24584c3181b0f4ecfa0cd0edf, resolved at
execution). npm ci --ignore-scripts so no dependency install script runs at image build
time, npm run build, npm prune --omit=dev. Runtime stage copies only the pruned
node_modules, dist, public, examples, package.json. Nothing is chowned to the
runtime user, so code execution inside Agentwall cannot rewrite the dashboard JavaScript it
serves or its own dist/. USER node. .dockerignore is deny-by-default, because this
repo's root holds gitignored operator files (agentwall.config.yaml, .env, *.bak) that
a denylist would eventually let into a layer.

HEALTHCHECK calls the real GET /health from src/routes/health.ts using node's global
fetch (no curl or wget in the image) and checks body.status === "ok" rather than
accepting any 200.

examples/container.config.yaml is monitor-first.config.yaml with two changes: binds
0.0.0.0, because a container's loopback is private and 127.0.0.1 is unreachable through
-p, and port 3000. The file states the --network=host consequence. Override with
-e AGENTWALL_CONFIG=... plus a read-only mount, which the measured runs above all did.

Verification

$ docker build -t agentwall .
Successfully built ...

$ docker image ls agentwall
DISK USAGE   CONTENT SIZE
371MB        88.9MB          (base node:22-slim alone is 329MB / ~80MB)

$ docker run --rm --entrypoint id agentwall -u
1000

$ docker run --rm --entrypoint node agentwall /app/dist/cli.js --version
0.1.0

$ docker run -d --name agentwall -p 3000:3000 \
    -e AGENTWALL_OPERATOR_TOKEN="$(openssl rand -hex 32)" \
    -v agentwall-state:/app/state agentwall
$ curl -fsS http://127.0.0.1:3000/health
{"status":"ok","service":"agentwall","version":"0.1.0","timestamp":"2026-08-05T05:45:23.303Z"}

$ docker inspect --format '{{.State.Health.Status}}' agentwall
healthy

(Published on 13000 rather than 3000 on the test box, where 3000 is occupied by an unrelated
service. Everything else is verbatim.)

Full suite:

Test Suites: 34 passed, 34 total
Tests:       300 passed, 300 total

npm run lint clean. No em dashes, en dashes, or emoji in any changed file (checked
bytewise with LC_ALL=C grep).

Not in this PR, deliberately

docs/install.md carries no cosign verify command. Nothing publishes an image today, so a
verify invocation would have a guessed registry path, a guessed tag, and a guessed
certificate identity, and would fail for every reader. That is the overclaim the attribution
section spends sixty lines refusing to make. The doc has one line that cannot rot
("Published images and their signature verification are documented alongside the release
workflow"); the real command lands with Step 5.2, after 4.3 has published something to run
it against.

Job spec for 4.2a (release.yml)

  docker:
    needs: build
    permissions:
      contents: read
      packages: write
      id-token: write
  • Context is the repo root, file ./Dockerfile, no build-args, no secrets.
  • Platforms: linux/amd64 only. That is the only architecture built and verified here.
  • docker/login-action to ghcr.io with GITHUB_TOKEN.
  • docker/metadata-action for ghcr.io/reesebuilt/agentwall, tags: the release tag plus latest.
  • docker/build-push-action, SHA-pinned, push: true, provenance: true.
  • sigstore/cosign-installer, then keyless cosign sign --yes against
    steps.<build>.outputs.digest, not the tag.
  • Verification identity the docs will use once published:
    --certificate-identity https://github.com/reesebuilt/agentwall/.github/workflows/release.yml@refs/tags/<tag>
    --certificate-oidc-issuer https://token.actions.githubusercontent.com

agentwall-bot and others added 3 commits August 5, 2026 00:47
The image runs the control plane, DLP, approvals, the dashboard, the runtime
guards, and the audit chain exactly as a host install does. It does not attribute
host egress to a process, because attribution reads /proc/net/tcp (per network
namespace) and /proc/<pid>/fd (per PID namespace, gated by PTRACE_MODE_READ), and
a container has its own of both and runs as uid 1000.

That loss is measured rather than asserted. docs/install.md carries an eight-row
matrix of flag combinations against a real HTTPS CONNECT through the container's
forward proxy, including the finding that host attribution stays null even for a
root container holding CAP_SYS_PTRACE, because Docker's default AppArmor profile
permits readdir of /proc/<pid>/fd while denying readlink of its entries. Two
combinations work and both cost most of the container's isolation. The sidecar
case, sharing namespaces with one agent container instead of the host, attributes
that agent fully and needs neither host namespace nor an AppArmor change.

Build: two stages on node:22-slim pinned by digest, npm ci --ignore-scripts,
npm prune --omit=dev, non-root, nothing chowned to the runtime user so the process
cannot rewrite the dashboard JavaScript it serves, and a healthcheck that calls
GET /health and checks the body rather than accepting any 200.

COPY carries the build context's file modes, so a build on a umask 077 machine
produced a 0600 tree that only uid 1000 could read, which broke the --user flag
that attribution requires. Modes are normalized in the image so it does not depend
on who built it.

examples/container.config.yaml is monitor-first with two container changes: it
binds 0.0.0.0, because a container's loopback is private and 127.0.0.1 is
unreachable through -p, and it uses port 3000.

Dependabot gains the docker ecosystem: a digest pin never expires on its own, so
without it the image would keep shipping the base layer's unpatched CVEs silently.
- The README lists attribution as Linux-only; it does not describe the record
  shape a container produces. Say what the README actually says.
- Replace a rhetorical count of lost isolation with the enumeration: network
  namespace, PID namespace, non-root user, AppArmor profile, plus an added
  capability to read any process's descriptors and memory.
- The sidecar snippet set only https_proxy, but the sample record beneath it came
  from a plain-HTTP fetch through http_proxy. Set both, which is what an agent
  needs anyway.
@reesebuilt
reesebuilt merged commit 01c58de into main Aug 5, 2026
9 checks passed
@reesebuilt
reesebuilt deleted the agent/4.2b-docker branch August 5, 2026 05:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant