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..b7539e6 100644 --- a/docs/install.md +++ b/docs/install.md @@ -51,6 +51,158 @@ 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. 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 + +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. 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 +`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 http_proxy=http://127.0.0.1:3128 -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"