From 41f183e5e5950e5ba2152ac0fcb572a350423ab3 Mon Sep 17 00:00:00 2001 From: agentwall-bot Date: Wed, 5 Aug 2026 00:47:50 -0500 Subject: [PATCH 1/2] Add container image, honest about attribution loss 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//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//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. --- .dockerignore | 25 ++++++ .github/dependabot.yml | 9 ++ Dockerfile | 129 ++++++++++++++++++++++++++++ docs/install.md | 150 +++++++++++++++++++++++++++++++++ examples/container.config.yaml | 63 ++++++++++++++ 5 files changed, 376 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 examples/container.config.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a31b96d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +# Deny by default, then name the build inputs. +# +# An allowlist is the right shape for this repository specifically: the root holds +# operator-local files that are gitignored but still sit on disk (agentwall.config.yaml, +# agentwall-approvals.json, .env, *.bak rescue copies). A denylist ignores what someone +# remembered to list; anything created later lands in the build context and then in an +# image layer, where `docker history` hands it to whoever pulls the image. Deny-by-default +# means a new file has to be named here before it can ship. +* + +# Build stage inputs: dependency manifests, the compiler config, and the sources. +!package.json +!package-lock.json +!tsconfig.json +!src + +# Runtime stage inputs: the dashboard's static assets and the shipped example configs, +# one of which the image runs as its default configuration. +!public +!examples + +# Never, even if a rule above widens: source maps and declarations are emitted into dist +# by the build stage inside the image, so a stale host dist/ must not shadow them. +dist +node_modules diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a36dbb7..497747c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -25,3 +25,12 @@ updates: directory: "/" schedule: interval: weekly + + - package-ecosystem: docker + directory: "/" + schedule: + interval: weekly + # The Dockerfile pins its base by digest. A digest pin never expires on its own, so + # without this ecosystem the image would keep shipping whatever node:22-slim contained + # on the day the pin was written, including its unpatched CVEs, and nothing would say + # so. Dependabot rewrites the digest and the trailing tag comment together. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8e35d19 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,129 @@ +# Agentwall container image. +# +# What this image is for, stated before anything else, because the honest answer is +# narrower than "run Agentwall": +# +# - The control plane. Policy evaluation, DLP, approvals, the dashboard, the runtime +# guards, and the tamper-evident audit chain all work exactly as they do on a host, +# because none of them read another process's /proc. +# - Egress attribution for processes that share the container's PID and network +# namespaces, which is the sidecar case: `--network=container:agent +# --pid=container:agent`, with the agent running as the same uid and gid, attributes +# that agent fully and needs no host namespace and no AppArmor change. +# - Trying Agentwall without installing Node. +# +# What it is NOT for by default: attributing egress to processes on the HOST. Attribution +# maps a client socket to its owner by reading /proc/net/tcp, which is per network +# namespace, and then /proc//fd, which is per PID namespace and gated by +# PTRACE_MODE_READ. A container has its own of both namespaces, so a host agent's +# connection resolves to no process and the record carries pid null, comm unknown. That is +# a real reduction of the product's headline capability, not a rough edge. It is recorded +# honestly rather than hidden: the ledger says null, the audit event's agentId says +# "unattributed", and docs/install.md gives the measured flag matrix, including the two +# combinations that restore host attribution and the privilege each one costs. + +# Base pinned by digest, not by tag. `node:22-slim` is a moving target: the same tag +# resolves to different bytes week to week, so a tag-pinned build is not reproducible and +# a rebuild can quietly change the runtime under a signed release. The trailing comment +# names the tag the digest belonged to when it was resolved; Dependabot's docker ecosystem +# opens the bump PRs against this line. +FROM node:22-slim@sha256:f576cc608b02e6b04bb0700e13be83eb5ceb7bb24584c3181b0f4ecfa0cd0edf AS build + +WORKDIR /app + +# Manifests first so the dependency layer survives a source-only edit. +COPY package.json package-lock.json ./ + +# --ignore-scripts: an install script runs arbitrary code from a dependency at image build +# time, with the build's filesystem and network. No dependency in this tree needs one, so +# the capability is declined rather than trusted. `npm ci` also refuses to proceed when +# package-lock.json disagrees with package.json, which is what makes the build reproducible. +RUN npm ci --ignore-scripts + +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build + +# Drop devDependencies from the tree the runtime stage copies. The compiler, the test +# runner, and ts-node are build-time tools; carrying them into a shipped image means +# shipping their transitive CVEs and giving anyone who reaches code execution a compiler. +RUN npm prune --omit=dev + + +FROM node:22-slim@sha256:f576cc608b02e6b04bb0700e13be83eb5ceb7bb24584c3181b0f4ecfa0cd0edf AS runtime + +# Production mode for the dependency tree, and a hint to fastify and pino that this is +# not a development process. +ENV NODE_ENV=production + +WORKDIR /app + +# Only the pruned dependency tree and the compiled output cross the stage boundary. src/, +# tsconfig.json, and the devDependency tree stay behind in the build stage. +# +# Nothing is chowned to the runtime user. Everything the process executes stays root-owned +# and unwritable by it, so code execution inside Agentwall does not get to rewrite the +# dashboard JavaScript it serves to an operator's browser, or its own dist/. +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +COPY public ./public +COPY examples ./examples +COPY package.json ./ + +# COPY preserves the mode of the build context, so these three paths arrive carrying the +# umask of whoever ran `docker build`. On a machine with umask 077 that is 0600, and the +# image then only works for uid 1000: `--user 1001` cannot read the policy file and the +# process exits at startup. Attribution needs `--user`, so this would break the one flag +# combination the image exists to support. Normalizing here also makes the image +# independent of the builder's umask, which a release artifact has to be. +# a=rX is read for everyone, traverse on directories, write for nobody. +RUN chmod -R a=rX /app/public /app/examples && chmod a=r /app/package.json + +# The one writable path: approvals, approved manifest hashes, and (when +# AGENTWALL_AUDIT_FILE points here) the audit chain. +# +# Group 0 and group-writable, because `docker run --user ` with no group assigns +# gid 0, and that is the common form. It does not cover every case: attributing a host +# process requires matching its gid as well as its uid, so that run passes +# `--user :` and lands outside both owner and group here. Such a run bind-mounts +# a host directory it owns over this path, which is what it should be doing anyway for an +# audit chain that has to outlive the container. +# +# No VOLUME instruction. It would create an anonymous volume on every `docker run`, +# initialized with uid 1000 ownership, which a run using `--user 1001:1001` cannot write +# to: a declared convenience that breaks the documented flags, plus a new orphan volume +# per container start. +RUN install -d -o node -g 0 -m 0775 /app/state + +# Non-root. uid 1000 comes from the base image. It is deliberately NOT the uid of any host +# agent, and that mismatch alone is enough to make host-process attribution return null: +# reading /proc//fd requires matching the target's uid and gid, or CAP_SYS_PTRACE. +# See docs/install.md, "Attribution inside a container", for the measured matrix. +USER node + +# 3000 is the port the shipped container config binds. 3015 is the port +# examples/monitor-first.config.yaml uses, for an operator who mounts that file instead. +# The forward proxy has no default port at all: it starts only when +# AGENTWALL_PROXY_PORT is set, so publish that port explicitly when enabling it. +EXPOSE 3000 3015 + +# The container binds 0.0.0.0 because a container's loopback is private. The file explains +# the tradeoff; override it with -e AGENTWALL_CONFIG plus a read-only mount. +ENV AGENTWALL_CONFIG=/app/examples/container.config.yaml + +# The healthcheck calls the real endpoint, GET /health from src/routes/health.ts, and +# checks the documented body rather than settling for any 200: a reverse proxy or a +# misrouted port can return 200 from something that is not Agentwall. node's global fetch +# is used because the image ships no curl or wget, and adding one to satisfy a healthcheck +# would enlarge the attack surface of every running container to save four lines. +# The URL is an env var so that overriding the config's port does not silently leave the +# container reporting unhealthy forever. +ENV AGENTWALL_HEALTHCHECK_URL=http://127.0.0.1:3000/health +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --start-interval=2s --retries=3 \ + CMD ["node", "-e", "fetch(process.env.AGENTWALL_HEALTHCHECK_URL).then(r=>r.ok?r.json():Promise.reject(r.status)).then(b=>process.exit(b.status===\"ok\"?0:1)).catch(()=>process.exit(1))"] + +# The server is PID 1 so that `docker stop` delivers SIGTERM to it directly. The CLI is +# reachable at /app/dist/cli.js via --entrypoint; it is not the entrypoint itself because +# `cli.js start` spawns the server as a child, and a PID 1 that does not forward signals +# turns every `docker stop` into a ten second wait followed by SIGKILL. +ENTRYPOINT ["node", "/app/dist/index.js"] diff --git a/docs/install.md b/docs/install.md index d669250..66218b2 100644 --- a/docs/install.md +++ b/docs/install.md @@ -51,6 +51,156 @@ agentwall start curl http://127.0.0.1:3000/health ``` +## Container + +### What the image gives you, and what it does not + +Everything except host-process egress attribution works in a container exactly as it does +on a host: policy evaluation, DLP, approvals, the dashboard, the runtime guards, and the +tamper-evident audit chain. + +Attribution is the exception, and it is the product's headline capability, so read this +before deciding the image is what you want. Naming the process behind an outbound +connection is a two-step read of `/proc`: `/proc/net/tcp` maps the client's port to a +socket inode, then `/proc//fd` finds the process holding that inode. The first file +is per network namespace. The second is per PID namespace, and resolving its symlinks +additionally requires `PTRACE_MODE_READ`, which means matching the target process's uid +and gid or holding `CAP_SYS_PTRACE`. A default container has its own network namespace, +its own PID namespace, and runs as uid 1000, so all three conditions fail. + +A default container therefore records host egress like this: + +```json +{"host":"example.com","port":443,"scheme":"https","method":"CONNECT", + "client":{"pid":null,"comm":null},"decision":"allow"} +``` + +and the matching audit event carries `agentId: "unattributed"`, `pid: "unknown"`, +`comm: "unknown"`. The destination, the byte counts, the decision, and the hash chain are +all still there. The identity of the caller is not. That is the same degradation the +README documents for non-Linux hosts, and it is recorded rather than hidden, but a monitor +that cannot say which process called out is doing less than the one this project +describes. If naming the process is why you are here, run Agentwall on the host. + +### Run it + +Build from a checkout: + +```bash +docker build -t agentwall . +``` + +Run the control plane with the dashboard published: + +```bash +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 +``` + +Without `AGENTWALL_OPERATOR_TOKEN`, every route except `/health` answers 401. `/health` is +unauthenticated so that orchestrators can probe it. + +The CLI is in the image but is not its entrypoint, because the entrypoint is the server +and `cli.js start` would run it as a child process that never receives `docker stop`: + +```bash +docker run --rm --entrypoint node agentwall /app/dist/cli.js --version +docker run --rm --entrypoint node agentwall /app/dist/cli.js --help +``` + +### Attribution inside a container: measured + +Measured on Linux 6.8 with Docker 29.1.3 and AppArmor enabled, a host client running as +uid 1001 gid 1001, one HTTPS CONNECT through the container's forward proxy each time. + +| Flags | Result | +| --- | --- | +| (default) | `pid null`. The client's socket is not in the container's network namespace at all. | +| `--network=host` | `pid null`. The socket is found; only one process is visible, so no owner. | +| `--network=host --pid=host` | `pid null`. 457 of 460 `/proc//fd` are unreadable as uid 1000. | +| `--network=host --pid=host --user 1001` | `pid null`. Bare `--user ` assigns gid 0, and the gid must match too. | +| `--network=host --pid=host --user 1001:1001` | `pid null`. AppArmor's `docker-default` profile denies the `/proc//fd` symlink read. | +| `--network=host --pid=host --user 1001:1001 --security-opt apparmor=unconfined` | `pid 1300177 comm curl`. Attributes processes of that uid and gid only. | +| `--network=host --pid=host --user 0 --cap-add=SYS_PTRACE --security-opt apparmor=unconfined` | `pid 1300177 comm curl`. Attributes every process on the host. | +| `--pid=host --user 1001:1001 --security-opt apparmor=unconfined` | `pid null`. Without `--network=host` the socket is invisible, so the PID namespace does not matter. | + +Two combinations work, and both are expensive: + +- `--network=host --pid=host --user : --security-opt apparmor=unconfined` + attributes only processes running as that uid and gid. Lower privilege, narrower reach. +- Adding `--user 0 --cap-add=SYS_PTRACE` attributes every process on the host. This is a + root process with the capability to read any process's memory and descriptors, sharing + the host's network and PID namespaces, with its mandatory access control profile + removed. Four of the five isolation mechanisms a container provides are gone. Whether + that trade is worth making is a judgement about your threat model, but it is not a + smaller decision than installing Agentwall on the host directly, and it should not be + presented as one. + +`--security-opt apparmor=unconfined` is needed on hosts running Docker's default AppArmor +profile, which Debian and Ubuntu enable out of the box. The profile permits `ptrace` and +`read` only against peers in the same profile, so `readdir` of `/proc//fd` succeeds +while `readlink` of its entries returns `EACCES`, and attribution silently returns null +even for a root container holding `CAP_SYS_PTRACE`. + +### Sidecar: attribution without host privileges + +Sharing namespaces with one agent container, rather than with the host, gives full +attribution of that agent and needs neither host namespace nor an AppArmor change, because +`docker-default` allows the read between two containers under the same profile. Run the +agent as the same uid and gid as Agentwall. + +```bash +docker run -d --name agentwall \ + -e AGENTWALL_OPERATOR_TOKEN="$(openssl rand -hex 32)" \ + -e AGENTWALL_PROXY_PORT=3128 \ + -e AGENTWALL_PROXY_LEDGER=/app/state/egress.jsonl \ + -e AGENTWALL_AUDIT_FILE=/app/state/audit.jsonl \ + -v agentwall-state:/app/state \ + agentwall + +docker run --rm \ + --network=container:agentwall --pid=container:agentwall --user 1000:1000 \ + -e https_proxy=http://127.0.0.1:3128 \ + your-agent-image +``` + +Recorded egress then names the process: + +```json +{"host":"example.com","port":80,"scheme":"http","method":"GET", + "client":{"pid":31,"comm":"wget"},"decision":"allow"} +``` + +### Image behaviour worth knowing + +- Runs as uid 1000 (`node`). `/app` is root-owned and unwritable by the process, so code + execution inside Agentwall cannot rewrite the dashboard JavaScript it serves. +- `/app/state` is the only writable path. Mount it. Approvals, approved manifest hashes, + and the audit chain live there, and an audit chain that dies with the container is not + evidence of much. A run using `--user :` must bind-mount a host directory that + uid owns. +- The image runs `examples/container.config.yaml`, which is the monitor-first posture with + two changes: it binds `0.0.0.0`, because a container's loopback is private and a server + on `127.0.0.1` inside one is unreachable through `-p`, and it uses port 3000. Under + `--network=host` there is no network namespace to bound that bind, so pass your own + config there. Override with `-e AGENTWALL_CONFIG=/etc/agentwall/config.yaml` and a + read-only mount. +- `HEALTHCHECK` calls `GET /health` with node's built-in `fetch` and checks the response + body, not just the status code. If you change the config's port, set + `AGENTWALL_HEALTHCHECK_URL` to match or the container reports unhealthy forever. +- The forward proxy has no default port and does not start until `AGENTWALL_PROXY_PORT` is + set. Publish that port too when you enable it. +- The base image is pinned by digest, so a rebuild produces the same runtime until the pin + is deliberately moved. + +Published images and their signature verification are documented alongside the release +workflow. + ## Uninstall - User-level launcher only: remove `/usr/local/bin/agentwall` diff --git a/examples/container.config.yaml b/examples/container.config.yaml new file mode 100644 index 0000000..d743731 --- /dev/null +++ b/examples/container.config.yaml @@ -0,0 +1,63 @@ +# Agentwall configuration baked into the container image. +# +# It is examples/monitor-first.config.yaml with two container-shaped changes and nothing +# else, because the shipped posture does not change just because the process is in a +# container: monitor-first, default-deny policy engine, watchdog observing. +# +# Change 1, the bind address. A container has its own loopback interface, so a server +# bound to 127.0.0.1 inside one is unreachable from `docker run -p`, including by the +# operator who published the port. The isolation that 127.0.0.1 provides on a host is +# provided here by the network namespace plus the ports the operator chooses to publish. +# Read the limit plainly: under `--network=host` there is no network namespace, so this +# bind covers every interface on the host. Non-health routes still answer 401 until +# AGENTWALL_OPERATOR_TOKEN is set, but an operator running with host networking should +# pass a config that binds a specific address. +# +# Change 2, port 3000 rather than 3015, matching the port the image EXPOSEs, the +# healthcheck, and the /health example in docs/install.md. +# +# Override the whole file with `-e AGENTWALL_CONFIG=/etc/agentwall/config.yaml` plus a +# read-only mount. + +port: 3000 +host: "0.0.0.0" +logLevel: "info" + +approval: + mode: "auto" + timeoutMs: 30000 + backend: "file" + persistencePath: "./state/agentwall-approvals.json" + +policy: + # Default deny in the engine so decisions reflect the target end state, while the + # caller enforces only the categories it is ready to enforce. + defaultDecision: "deny" + configPath: "./examples/monitor-first.policy.yaml" + +dlp: + enabled: true + redactSecrets: true + +egress: + enabled: true + defaultDeny: true + allowPrivateRanges: false + allowedHosts: + - "api.openai.com" + - "api.anthropic.com" + - "generativelanguage.googleapis.com" + allowedSchemes: + - "https" + allowedPorts: + - 443 + +manifestIntegrity: + enabled: true + approvedHashesPath: "./state/approved-manifests.json" + +watchdog: + enabled: true + staleAfterMs: 30000 + timeoutMs: 120000 + killSwitchMode: "monitor" From 1a5e09ee73b34b9e3797d97af6bd5a0df1c9b443 Mon Sep 17 00:00:00 2001 From: agentwall-bot Date: Wed, 5 Aug 2026 00:51:23 -0500 Subject: [PATCH 2/2] Tighten three container-doc claims to what was measured - 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. --- docs/install.md | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/docs/install.md b/docs/install.md index 66218b2..b7539e6 100644 --- a/docs/install.md +++ b/docs/install.md @@ -77,10 +77,12 @@ A default container therefore records host egress like this: and the matching audit event carries `agentId: "unattributed"`, `pid: "unknown"`, `comm: "unknown"`. The destination, the byte counts, the decision, and the hash chain are -all still there. The identity of the caller is not. That is the same degradation the -README documents for non-Linux hosts, and it is recorded rather than hidden, but a monitor -that cannot say which process called out is doing less than the one this project -describes. If naming the process is why you are here, run Agentwall on the host. +all still there. The identity of the caller is not. The README already lists attribution as +Linux-only for the same underlying reason, that it is a `/proc` read; a container is a +second way to lose it, on a Linux host that would otherwise have it. It is recorded rather +than hidden, but a monitor that cannot say which process called out is doing less than the +one this project describes. If naming the process is why you are here, run Agentwall on the +host. ### Run it @@ -133,13 +135,13 @@ Two combinations work, and both are expensive: - `--network=host --pid=host --user : --security-opt apparmor=unconfined` attributes only processes running as that uid and gid. Lower privilege, narrower reach. -- Adding `--user 0 --cap-add=SYS_PTRACE` attributes every process on the host. This is a - root process with the capability to read any process's memory and descriptors, sharing - the host's network and PID namespaces, with its mandatory access control profile - removed. Four of the five isolation mechanisms a container provides are gone. Whether - that trade is worth making is a judgement about your threat model, but it is not a - smaller decision than installing Agentwall on the host directly, and it should not be - presented as one. +- Adding `--user 0 --cap-add=SYS_PTRACE` attributes every process on the host. Count what + that run gives up: the network namespace, the PID namespace, the non-root user, and the + AppArmor profile, and then add the capability to read any process's descriptors and + memory. What remains of the container is the filesystem and the cgroup. Whether that + trade is worth making is a judgement about your threat model, but it is not a smaller + decision than installing Agentwall on the host directly, and it should not be presented + as one. `--security-opt apparmor=unconfined` is needed on hosts running Docker's default AppArmor profile, which Debian and Ubuntu enable out of the box. The profile permits `ptrace` and @@ -165,7 +167,7 @@ docker run -d --name agentwall \ docker run --rm \ --network=container:agentwall --pid=container:agentwall --user 1000:1000 \ - -e https_proxy=http://127.0.0.1:3128 \ + -e http_proxy=http://127.0.0.1:3128 -e https_proxy=http://127.0.0.1:3128 \ your-agent-image ```