# Agent security model

> Why the agent reaches Docker only through a socket proxy, and what a compromised agent key can and cannot do.

Canonical: https://docs.getlumin.dev/agent/security/

The agent runs next to your application containers and reads the Docker API. That puts it in a position where shortcuts in the threat model — like a "read-only" socket bind-mount — would matter. This page is the threat model.

## Why the socket proxy is not optional

The obvious way to give a container access to the Docker API is:

```yaml
volumes:
  - /var/run/docker.sock:/var/run/docker.sock:ro
```

This is **not safe**. The `:ro` flag only makes the socket *inode* read-only. The Docker daemon on the other end of that socket still services every API verb a client sends — including:

```
POST /containers/create
{
  "Image": "alpine",
  "HostConfig": {
    "Binds": ["/:/host"],
    "Privileged": true
  }
}
```

That single API call creates a new container that bind-mounts the host's root filesystem at `/host` and runs privileged. From inside that container, an attacker has root on the host. The `:ro` flag does nothing to prevent this — read-only on the socket inode does not constrain the verbs the daemon will execute.

The agent never directly mounts `/var/run/docker.sock`. Instead, a [`wollomatic/socket-proxy`](https://github.com/wollomatic/socket-proxy) sidecar holds the sole bind-mount of the host socket. It forwards a request only when the method and the path match its allowlist. The documented setup allows the five requests that the agent makes:

| Request | What the agent reads |
|---|---|
| `HEAD /_ping` | The API version, for version negotiation |
| `GET /v1.NN/containers/json` | The list of running containers |
| `GET /v1.NN/containers/{id}/json` | The restart count and the start time (inspect) |
| `GET /v1.NN/containers/{id}/stats` | CPU, memory, and network figures |
| `GET /v1.NN/containers/{id}/logs` | The log lines of a `lumin.logs=true` container |

The proxy answers every other path with `403 Forbidden` and every other method with `405 Method Not Allowed`. So `POST /containers/create`, exec, start, stop, and delete never reach the daemon, and neither do `export`, `archive`, `attach`, `/version`, `/info`, or `/events`.

Four more details of the setup matter:

- **`SP_ALLOWFROM: lumin-agent`.** The proxy accepts connections only from the container with that name, and other containers on the same network get `403 Forbidden`. The proxy looks up the name on each request, so a recreated agent keeps its access. If you rename the agent service, change this value too.
- **The full container ID.** The `{id}` in each pattern must be 64 hexadecimal characters. A container name, a slash, or `..` in that position does not match.
- **`user: "0:0"`.** The image runs as user 65534, and on Linux the Docker socket belongs to `root:docker` with mode `0660`. The default user cannot open the socket, so the proxy exits at start. Any process that can open the socket controls the Docker daemon, so a non-root user in the `docker` group would not limit a compromised proxy more. The container has no capabilities, no shell, and a read-only filesystem.
- **A `|` in a pattern must stay inside parentheses.** The proxy adds `^` and `$` to each pattern as text, so `a|b` becomes `^a|b$`. That pattern allows every path that starts with `a` or ends with `b`. Keep this in mind if you change the allowlist.

The agent reaches the proxy via `DOCKER_HOST=tcp://lumin-docker-proxy:2375` over the Compose bridge network. The official Docker Go client honors `DOCKER_HOST` transparently, so the agent doesn't know or care that it's talking to a proxy.

`DOCKER_HOST` is the whole of it: the agent talks to the endpoint that variable names and to nothing else. With it unset the agent does not reach Docker at all — it ships host metrics and skips container metrics, log tailing, and scraping, rather than falling back to the default socket path. Point it at a socket yourself and the agent still runs, but it logs a warning at startup naming the proxy as the supported setup.

**Net effect:** a compromised agent (or a vulnerability in any of its dependencies that gives an attacker arbitrary HTTP calls to the agent's Docker client) can send only those five requests. It cannot create, start, stop, delete, or exec into a container, so the `POST /containers/create` escape above is closed. It cannot read container files through `export` or `archive`, and it cannot attach to a container.

One of the five requests reads more than the agent needs. Inspect returns the full configuration of a container, environment variables included, and the proxy cannot remove fields from a response. Treat every secret that you pass to a container as an environment variable as readable by a compromised agent.

## What a compromised agent API key can do

The agent's `LUMIN_API_KEY` is a write-only ingest credential, scoped to one project. If it leaks, an attacker can:

- `POST /v1/logs` — inject arbitrary log rows into that project.
- `POST /v1/metrics` — inject arbitrary metric points into that project.
- `POST /v1/events` — inject arbitrary browser-shaped events.
- `POST /v1/agent/snapshot` — inject arbitrary host/container snapshots.

An attacker **cannot**:

- Read any data — the key has no read scope. The UI lives behind session cookies on a different hostname.
- Reach any other project — every write is scoped to the project that minted the key.
- Reach the app engine — the host-based dispatcher only routes `/v1/*` to the API engine, and the API engine has no read endpoints.

The right response to a suspected leak is rotation, not panic. See [Rotating the API key](https://docs.getlumin.dev/agent/install/#rotating-the-api-key).

## Hardening the agent container itself

The recommended Compose stanza already sets:

- `read_only: true` with `tmpfs: [/tmp]` — the only file the agent writes is the health sentinel on the tmpfs. Making the root filesystem read-only removes a place to drop an attacker payload and costs nothing given the empty write set.
- `cap_drop: [ALL]` — non-root UID already strips most capabilities, but the container's namespace still grants the default capability set. The agent needs none of them.
- `security_opt: ["no-new-privileges:true"]` — blocks setuid escalation inside the container, even if a binary with the bit set somehow ends up there.

These are defense-in-depth, not the primary control. The primary control is the socket proxy in front of the daemon.

## Log content is your responsibility

`lumin.logs=true` forwards a container's stdout and stderr to Lumin exactly as
written. The agent parses a timestamp, sniffs a level, clips the message at
16 KiB, and ships the rest unchanged. If your application logs a credential, that
credential lands in your Lumin project.

**The agent does not redact, and will not.** That is a deliberate position, not a
missing feature:

- The agent sees an opaque byte stream with no knowledge of your log schema.
  Redaction would mean pattern matching, which misses every format the patterns
  do not anticipate and mangles legitimate data that happens to match.
- A redaction setting invites you to stop caring at the source. A filter that
  catches most secrets most of the time is more dangerous than no filter,
  because it converts a visible problem into an invisible one.
- Secret formats are open-ended and change constantly. A pattern list in the
  agent would be permanently, silently out of date.

The producer owns this. Concretely:

- **For code you control**, do not log the secret. Redact at the call site, where
  the schema is known and the fix is exact.
- **For images you do not control**, configure their logging. Most well-known
  images expose it — Caddy can drop headers from its access log, proxies can be
  told not to log query strings, database images can lower statement logging.
- **When neither is possible**, do not add the label. Log shipping is opt-in per
  container precisely so that a container with output you have not reviewed
  stays off by default.

Two properties bound the exposure. The agent attaches with `tail=0`, so adding
the label never back-fills history — only what the container writes from that
moment forward. And the label is per container, so the decision is yours one
container at a time, not a host-wide switch.

Note what this is and is not about. It is not about the API key — that key is
write-only, so a leaked key cannot read your logs back (see above). It is about
where the data goes: a secret written to a labelled container's stdout leaves
your host, lands in Lumin, stays for your retention window, and is readable by
anyone with UI access to that project. A secret in a log line is a secret in
Lumin.

## Docker CVEs your scanner will report

The agent vendors the official Docker Go client to talk to the proxy. Scanning it
reports five advisories against `github.com/docker/docker`, and every one of them
is reported with **`Fixed in: N/A`**:

| Advisory | Summary |
|---|---|
| [GO-2026-4887](https://pkg.go.dev/vuln/GO-2026-4887) | AuthZ plugin bypass on oversized request bodies |
| [GO-2026-4883](https://pkg.go.dev/vuln/GO-2026-4883) | Off-by-one in plugin privilege validation |
| [GO-2026-5746](https://pkg.go.dev/vuln/GO-2026-5746) | `PUT /containers/{id}/archive` executes a container binary on the host |
| [GO-2026-5668](https://pkg.go.dev/vuln/GO-2026-5668) | Race in `docker cp` allows creation of arbitrary files |
| [GO-2026-5617](https://pkg.go.dev/vuln/GO-2026-5617) | Race in `docker cp` allows bind mount redirection |

**All five are bugs in the Docker daemon, not in the client.** They live in Moby's
`daemon` package: plugin privilege handling, the AuthZ layer, and the archive
extraction path behind `docker cp`. The agent is a client. It never runs a daemon,
never serves the Docker API, and never handles a plugin or an archive extraction.
It links only `docker/docker/api/types`, `docker/docker/client`, and
`docker/docker/pkg/stdcopy`; no `daemon` package is present in the binary.

There is no version to upgrade to, because the fix ships in the daemon you already
run — not in the client library. These advisories will appear in a scan of *any*
image that vendors the Docker client, and will keep appearing.

If your scanner marks two of them as "called", read the call traces before acting.
They are package initialisation chains of the form `collector.init calls
client.init, which calls api.init` — the analyser attributing a whole-module
advisory to any import of that module, not a path to the vulnerable code.

Anything with a real fix does get upgraded. The OpenTelemetry advisory
[GO-2026-5158](https://pkg.go.dev/vuln/GO-2026-5158) had one, so the agent moved
to `go.opentelemetry.io/otel` v1.46.0 and it no longer appears.

## What's not in scope

- **Network egress filtering.** Operators who want to constrain *where* the agent can speak (e.g. block all destinations except `api.getlumin.dev`) should do that at the host firewall layer or by giving the agent a network with no general egress and routing through a known proxy. The agent itself does not enforce egress policy.
- **Per-container scope on the agent key.** Today the key is scoped to a project but observes every container on the host. If you have a multi-tenant host (which you probably shouldn't), the agent will mix logs and metrics from all tenants under the one project. Run one agent per tenancy boundary.
