Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
129 changes: 129 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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/<pid>/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 <uid>` 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 <uid>:<gid>` 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/<pid>/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"]
152 changes: 152 additions & 0 deletions docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<pid>/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/<pid>/fd` are 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's `docker-default` profile denies the `/proc/<pid>/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 <uid>:<gid> --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/<pid>/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 <uid>:<gid>` 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`
Expand Down
63 changes: 63 additions & 0 deletions examples/container.config.yaml
Original file line number Diff line number Diff line change
@@ -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"