fix(docker): address review findings

Entrypoint drops the capability bounding set and sets no_new_privs. Security docs
and spec no longer claim the agent can't reach the secrets; docker exec fallbacks
use a clean environment. PLAYWRIGHT_ENABLED drops the nested compose default.
Tests pin the Dockerfile's Playwright install step and the compose model fallback.
This commit is contained in:
EugeneTes
2026-09-27 12:34:58 +02:00
parent b9a95a57c8
commit 7bb41f0b5b
8 changed files with 124 additions and 30 deletions

View File

@@ -25,9 +25,14 @@ docker compose logs -f bot
The bot and everything it starts run as `app` (uid/gid 1654, `HOME=/home/app`). The image's own user stays root:
the entrypoint `setpriv` drops to `app` before it starts `dotnet`, so PID 1 (`docker-init`, from compose's
`init: true`) is owned by root and the agent can't read the container's environment from `/proc/1/environ`.
Don't set `user:` in the compose file: `setpriv` needs root to switch users. For the same reason `docker exec`
opens a root shell; add `-u app` to act as the bot.
`init: true`) is owned by root and the agent can't read the container's environment from `/proc/1/environ`. It also
empties the capability bounding set and sets `no_new_privs`, so nothing the agent runs regains root through a setuid
binary such as `su` or `mount`. Don't set `user:` in the compose file: `setpriv` needs root to switch users. For the
same reason `docker exec` opens a root shell; add `-u app` to act as the bot. Any command that writes under `HOME`
(`npm`, `npx`, `git config`, `gh auth`, `claude`) must run with `-u app`: as root it leaves root-owned files in
`/home/app` (`HOME` is set image-wide), and the bot's `npx` and `git` then fail with `EACCES` until the next redeploy.
An interactive `app` session should also start from a clean environment (see
[First run and Claude login](#first-run-and-claude-login)).
## Build arguments
@@ -39,7 +44,20 @@ opens a root shell; add `-u app` to act as the bot.
| `PLAYWRIGHT_MCP_VERSION` | `0.0.82` | Must equal the version in `PlaywrightPreset.DefaultPackage`; `PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage` fails when they differ. The compose file doesn't override it |
The compose file passes `INSTALL_PLAYWRIGHT` and `CLAUDE_CODE_VERSION` from `.env` (or Coolify's variables).
Without compose: `docker build --build-arg INSTALL_PLAYWRIGHT=true -t volcanic-agentic-core .`
Without compose:
```bash
docker build --build-arg INSTALL_PLAYWRIGHT=true -t volcanic-agentic-core .
docker run -d --init --shm-size 1g --restart unless-stopped --env-file .env \
-e PLAYWRIGHT_ENABLED=true -e PLAYWRIGHT_HEADLESS=true -e PLAYWRIGHT_EXTRA_ARGS=--no-sandbox \
-e PLAYWRIGHT_COMMAND=playwright-mcp \
-v workspaces:/data/workspaces -v agent:/data/agent -v claude:/data/claude volcanic-agentic-core
```
`--init` is required: without it `dotnet` is PID 1, so nothing reaps the zombies that killed `claude` trees and `bash`
children leave behind, and PID 1 is no longer root's. `--shm-size 1g` keeps Chromium from crashing. The compose
file's defaults (the `-e` lines, which win over `--env-file`) don't apply to a plain `docker run`, and neither does
its `TELEGRAM_MODEL` / `LOG_LEVEL` mapping: pass `-e Telegram__Model=...` instead.
## Configuration
@@ -59,14 +77,14 @@ The compose file maps them into the container.
| `GOOGLE_CLIENT_SECRET` | — | See above | No, and redacted |
| `DEFAULT_TIMEZONE` | the container's `TZ` | IANA zone for chats that never ran `/timezone`, e.g. `Europe/Berlin`. An unknown name stops startup | Yes |
| `PROCESS_ENV_ALLOW` | — | Comma-separated names or globs let through despite the deny rules, e.g. `GH_TOKEN` | Yes |
| `PLAYWRIGHT_ENABLED` | same as `INSTALL_PLAYWRIGHT` | Browser tools on or off | Yes |
| `PLAYWRIGHT_ENABLED` | `true` | Browser tools on or off. Set it to `false` for an image built with `INSTALL_PLAYWRIGHT=false` | Yes |
| `PLAYWRIGHT_HEADLESS` | `true` | The container has no display | Yes |
| `PLAYWRIGHT_BROWSER` | `chromium` | Only Chromium is installed in the image | Yes |
| `PLAYWRIGHT_EXTRA_ARGS` | `--no-sandbox` | Extra `playwright-mcp` arguments. Keep `--no-sandbox` when you override it: Chromium's sandbox needs user namespaces, which Docker's default seccomp profile denies | Yes |
| `PLAYWRIGHT_COMMAND` | `playwright-mcp` | The preinstalled server, so the first browser call doesn't wait for an `npx` download | Yes |
`TELEGRAM_MODEL`, `LOG_LEVEL`, `INSTALL_PLAYWRIGHT` and `CLAUDE_CODE_VERSION` are read by the compose file only; the
bot itself reads the names in the right-hand form.
`TELEGRAM_MODEL` and `LOG_LEVEL` are read by the compose file only, which passes them to the bot as `Telegram__Model`
and `Logging__LogLevel__Default`; `INSTALL_PLAYWRIGHT` and `CLAUDE_CODE_VERSION` are build arguments (see above).
> **Never set `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL`, `CLAUDE_CODE_OAUTH_TOKEN`,
> `CLAUDE_CODE_USE_BEDROCK` or `CLAUDE_CODE_USE_VERTEX`.** The Claude Code provider runs on a Claude subscription only
@@ -145,8 +163,17 @@ process. A rejected token (`401`) exits with code 1 and invalid configuration ex
When the login expires later, the owner gets the same prompt automatically and the paused request continues after
the code is pasted.
Without the bot: `docker exec -u app -it <container> claude auth login --claudeai`. The `-u app` matters: `docker exec`
runs as root by default, and credentials written by root can't be read by the bot.
Without the bot:
```bash
docker exec -u app -it <container> env -i HOME=/home/app \
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin CLAUDE_CONFIG_DIR=/data/claude TERM=xterm \
claude auth login --claudeai
```
The `-u app` matters: `docker exec` runs as root by default, and credentials written by root can't be read by the bot.
So does `env -i`: a `docker exec` process gets the whole container environment, bot token and API keys included, and
while it runs as `app` the agent's `bash` or a scheduled watcher can read it from `/proc/<pid>/environ`.
## Updating
@@ -173,7 +200,9 @@ docker restart <container>
Build them against the same `Volcanic.AgenticCore.Abstractions` version as the image. See [extensions.md](extensions.md).
Project context files (`AGENTS.md` or `CLAUDE.md`, `PERSONA.md`, `MEMORY.md`) live in the chat's workspace directory.
The agent can edit them, or you can with `docker exec -u app -it <container> bash`.
The agent can edit them, or you can with `docker exec -u app -it <container> env -i HOME=/home/app
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin TERM=xterm bash` (clean environment, for the reason
given under [First run and Claude login](#first-run-and-claude-login)).
## Troubleshooting
@@ -192,9 +221,16 @@ The agent can edit them, or you can with `docker exec -u app -it <container> bas
The container is the isolation boundary. Inside it, the agent's `bash` runs as `app`, the same user as the bot: it
can read the workspaces, the agent directory with every chat's sessions, and the Claude login in `/data/claude`.
What it can't get at are the configured secrets: they are filtered out of its environment, the bot process is
The configured secrets are kept out of its way: they are filtered out of its environment, the bot process is
non-dumpable (its `/proc/<pid>/environ` is unreadable and it can't be attached to), PID 1 is root's so
`/proc/1/environ` is unreadable too, the diagnostic socket is off, and secret values are redacted from tool output.
None of this is a sandbox. Anyone on the allow-list can run any command in the container.
`/proc/1/environ` is unreadable too, the diagnostic socket is off, setuid binaries can't raise its privileges, and
secret values are redacted from tool output.
That stops secrets leaking by accident, not an agent that goes after them on purpose. `app` owns `/data/workspaces`
and `/data/agent`, so the agent can drop a DLL into `<workspace>/.volcanic/extensions/` (loaded into the bot process,
with its unfiltered environment, at the next `/new`) or add an MCP server whose `env` uses `${env:TELEGRAM_BOT_TOKEN}`
to `/data/agent/settings.json`, then kill the bot so that `restart: unless-stopped` brings it back with that server.
Treat everyone on the allow-list as able to obtain every secret you configure. None of this is a sandbox: anyone on
the allow-list can run any command in the container.
Details in [telegram-bot.md](telegram-bot.md#security).

View File

@@ -21,7 +21,7 @@ build and to the Claude Code provider's subscription-only rules.
| Files | `Dockerfile`, `.dockerignore`, `docker-compose.yml`, `.env.example` at the repo root; user doc `docs/docker.md` |
| Build | Multi-stage: `mcr.microsoft.com/dotnet/sdk:10.0-noble` → `mcr.microsoft.com/dotnet/runtime:10.0-noble` |
| Publish | Framework-dependent `dotnet publish -c Release` of the Telegram host only. No self-contained, no AOT, no single-file: `claude` launches the bridge as `dotnet <Bridge>.dll`, so the `dotnet` muxer and a loose `Bridge.dll` + `.runtimeconfig.json` must exist |
| Entry | `ENTRYPOINT ["setpriv", "--reuid=1654", "--regid=1654", "--init-groups", "--inh-caps=-all", "--", "dotnet", "Volcanic.AgenticCore.Telegram.dll"]`: `setpriv` (util-linux, already in the base image) drops to `app` and execs `dotnet`, so `Environment.ProcessPath` is still the muxer `BridgeLocator.FindDotnet` wants. No entrypoint script |
| Entry | `ENTRYPOINT ["setpriv", "--reuid=1654", "--regid=1654", "--init-groups", "--inh-caps=-all", "--bounding-set=-all", "--no-new-privs", "--", "dotnet", "Volcanic.AgenticCore.Telegram.dll"]`: `setpriv` (util-linux, already in the base image) drops to `app` and execs `dotnet`, so `Environment.ProcessPath` is still the muxer `BridgeLocator.FindDotnet` wants. No entrypoint script |
| Agent tools | `bash`, `git`, `ripgrep`, `python3` (+ `python-is-python3`, `python3-venv`), `curl`, `ca-certificates`, `tzdata`, `jq`, `less`, `procps`, `openssh-client` |
| Node | Node.js 24 from NodeSource (`ARG NODE_MAJOR=24`); Ubuntu noble's `nodejs` is too old |
| Claude CLI | `npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}`, `ARG CLAUDE_CODE_VERSION=2.1` (newest 2.1.x at build time; set an exact version to pin). Updated by rebuilding, never in place |
@@ -113,8 +113,9 @@ services:
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
- DEFAULT_TIMEZONE=${DEFAULT_TIMEZONE:-} # chats' default zone (scheduler spec §7); empty → TZ
- PROCESS_ENV_ALLOW=${PROCESS_ENV_ALLOW:-} # deny-listed variables the agent may see, e.g. GH_TOKEN
# Playwright MCP preset (MCP spec). Enabled when the image has it; the preinstalled binary, no npx download.
- PLAYWRIGHT_ENABLED=${PLAYWRIGHT_ENABLED:-${INSTALL_PLAYWRIGHT:-true}}
# Playwright MCP preset (MCP spec): the preinstalled binary, no npx download. Set PLAYWRIGHT_ENABLED=false for an
# image built with INSTALL_PLAYWRIGHT=false (no nested default: Coolify parses these expressions itself).
- PLAYWRIGHT_ENABLED=${PLAYWRIGHT_ENABLED:-true}
- PLAYWRIGHT_HEADLESS=${PLAYWRIGHT_HEADLESS:-true} # no display in the container
- PLAYWRIGHT_BROWSER=${PLAYWRIGHT_BROWSER:-}
- PLAYWRIGHT_EXTRA_ARGS=${PLAYWRIGHT_EXTRA_ARGS:---no-sandbox}
@@ -159,13 +160,24 @@ volumes:
PID 1's own `/proc/1/environ` is root-only, and the configured secrets' values are replaced by `[REDACTED]` in
tool output (process-environment-hook spec §8–§9). The side effect is no core dumps and no
`dotnet-dump`/`dotnet-trace` attach; `ProcessEnvironment__HardenHostProcess=false` turns it off for a debugging
session. The container stays the real isolation boundary: none of this is a sandbox.
session. The entrypoint also gives the bot an empty capability bounding set and `no_new_privs`, so nothing it
spawns regains root through the base image's setuid binaries (`su`, `mount`, `passwd`, ...).
All of this keeps secrets out of the agent's environment and tool output by accident; it does **not** stop an agent
that goes after them on purpose. `app` owns `/data/workspaces` and `/data/agent`, extensions are discovered on every
session creation, and MCP servers expand `${env:NAME}` from the host's unfiltered environment, so the agent can drop
a DLL into `<workspace>/.volcanic/extensions/` (loaded into the bot at the next `/new`) or add an MCP server with
`env: {"X": "${env:TELEGRAM_BOT_TOKEN}"}` to `/data/agent/settings.json` (loaded after it kills the same-uid bot
and `restart: unless-stopped` brings it back). Anyone on the allow-list must be treated as able to obtain every
secret configured here. The container stays the real isolation boundary: none of this is a sandbox (open question 5).
- Playwright: `PLAYWRIGHT_COMMAND=playwright-mcp` runs the globally installed, version-pinned server, so the first
browser call in a chat doesn't wait on an npx download. `PLAYWRIGHT_EXTRA_ARGS` defaults to `--no-sandbox`:
Chromium's sandbox needs user namespaces, which Docker's default seccomp profile denies to the non-root `app` user,
so the container is the isolation boundary. Overriding `PLAYWRIGHT_EXTRA_ARGS` must keep `--no-sandbox`.
`PLAYWRIGHT_ENABLED` follows `INSTALL_PLAYWRIGHT` (nested default, supported by Compose v2), so an image built
without Playwright doesn't advertise tools it can't run.
`PLAYWRIGHT_ENABLED` defaults to `true`, so an image built with `INSTALL_PLAYWRIGHT=false` also needs
`PLAYWRIGHT_ENABLED=false` (otherwise the `playwright` server is logged as unavailable and its tools are missing).
It doesn't follow `INSTALL_PLAYWRIGHT` through a nested default (`${PLAYWRIGHT_ENABLED:-${INSTALL_PLAYWRIGHT:-true}}`):
Compose v2 supports that, but Coolify parses these expressions itself to build its variable UI, and a value cut at
the first `}` would make the host exit 2 in a restart loop.
- The owner (first id in `TELEGRAM_ALLOWED_USER_IDS`) gets the `🟢 Online.` message and the Claude Code re-login
prompts. The calendar has no owner special case: every chat connects its own account.
- Coolify prefixes named volumes with the resource uuid; they survive redeploys and are only removed when the
@@ -192,8 +204,11 @@ shows the service as running without health status; that is acceptable.
`/data/claude`. `/claude-code-doctor` should then report logged in, `claude.ai`, bridge ok.
4. Later expiries use the existing owner re-login flow; nothing Docker-specific.
Fallback without the bot: `docker exec -u app -it <container> claude auth login --claudeai` (same `CLAUDE_CONFIG_DIR`;
`docker exec` defaults to root, whose credentials the bot couldn't read). Copying an existing `.credentials.json` into
Fallback without the bot: `docker exec -u app -it <container> env -i HOME=/home/app
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin CLAUDE_CONFIG_DIR=/data/claude TERM=xterm claude auth
login --claudeai` (`docker exec` defaults to root, whose credentials the bot couldn't read). `env -i` matters: a
`docker exec` process gets the whole container environment, secrets included, and an ordinary (dumpable) `app` process
has its `/proc/<pid>/environ` readable by the agent's `bash` and watcher scripts for as long as it runs. Copying an existing `.credentials.json` into
the `claude` volume also works but is not the documented path.
## Extensions and project context
@@ -249,11 +264,16 @@ Verification record (2026-09-27, Docker Desktop 29 on Windows 11, Linux containe
(`disabled` / `enabled (headless, chromium)`), `Speech to text`, `Using Claude Code at /usr/bin/claude, model
claude-code/claude-opus-5-5`, `Scheduler`, `Default time zone: UTC`, `Allowed users: 1. Workspaces under
/data/workspaces`, then `Telegram unreachable` backoff.
- `docker compose config`: with a minimal `.env` (`INSTALL_PLAYWRIGHT=false`), `PLAYWRIGHT_ENABLED` follows it to
`"false"`; with `.env.example`'s defaults it is `"true"`; `--no-sandbox`, `playwright-mcp`, `init: true`,
`shm_size`, three named volumes, no ports.
- `docker compose config`: with a minimal `.env` (`INSTALL_PLAYWRIGHT=false`, `PLAYWRIGHT_ENABLED=false`), both are
`"false"`; with `.env.example`'s defaults both are `"true"` and `Telegram__Model` is the compose fallback;
`--no-sandbox`, `playwright-mcp`, `init: true`, `shm_size`, three named volumes, no ports. Not yet checked on
Coolify itself.
- `docker compose up -d --build` with `TELEGRAM_BOT_TOKEN=123:fake`: same startup lines, then `Telegram rejected the bot
token (401 Unauthorized)`, exit 1, and a visible `restart: unless-stopped` loop; no other warnings.
- After review (entrypoint with `--bounding-set=-all --no-new-privs`): the bot's `/proc/<pid>/status` shows
`CapEff`/`CapBnd` all zero and `NoNewPrivs: 1`; `su` as `app` fails; the host starts and logs the same lines; a
headless Chromium launch (`--no-sandbox`) under the same `setpriv` flags renders a page; the `env -i` login fallback
runs `claude --version` with only `HOME`, `PATH`, `CLAUDE_CONFIG_DIR`, `TERM` in its environment.
## Open questions
@@ -265,3 +285,7 @@ Verification record (2026-09-27, Docker Desktop 29 on Windows 11, Linux containe
at the cost of manual bumps?
4. Should the agent get a .NET SDK in the image (`INSTALL_DOTNET_SDK` build arg, +~700 MB) so it can build .NET
projects in its workspace?
5. The agent can reach the configured secrets on purpose (see the secrets bullet): it owns the directories that
extension and MCP-server discovery read. Mitigate with a host option that turns off workspace/agent-dir extension
and settings discovery in the container, or with root-owned, read-only `extensions/` and `settings.json` paths?
Until then this is documented, and the allow-list is the trust boundary.