OpenCode in Docker: auto mode on container/src only
Why a container, not more rules
The deny rules page tried four rule sets to make OpenCode ask less without opening the secrets of the aredel repo. Each set traded prompts for gaps:
- Rules match text. A command that reaches a file without naming it passes (examples), and an ask added for one spelling misses the next one: python3 -c asks, python -c and perl -e do not (rule set 4).
- The prompts stayed. With OpenCode rule set 4, 11 of the 15 commands of a working day still asked. The count drops to 2 only while the agent follows one line in AGENTS.md and searches with git grep; any other content search asks again.
- Auto mode stays forbidden. --auto approves every ask, so on the host every rule that protects the logs or the network turns into an allow (the price).
A container turns the question around. Instead of a list of what the agent must not touch, the agent gets a filesystem in which nothing needs protecting: the tracked files of container/src, its instructions read-only, and nothing else. Inside it --auto approves everything, and that is fine: the worst a command can do is break files that git restores.
The idea fits any project in which the agent works on one folder. This page builds it for aredel, where container/src is the docroot of the site. Tools used here: Docker, git, OpenCode and PHP.
What the container sees
Everything the agent can open, five entries. Each name leads to the step that puts it there:
/work/ in the container ├── container/src/ the worktree's, read-write ├── AGENTS.md read-only ├── .claude/skills/ read-only └── opencode.jsonc read-only, container config /home/agent/.local/share/opencode/ Docker volume: sessions
What stays out is not denied, it is not there. A command that would read it gets No such file or directory, however it is spelled (tests 1 to 6):
| Stays on the host | What it holds |
|---|---|
| .git | the whole history, deleted files included |
| ignored files in container/src | visitor records, PHP error logs, debug logs, a database, page backups |
| container/tools/ | users.db with visitor devices, and the log tools |
| ~/.local/share/aredel/logs/ | production logs with visitor IP addresses |
| ~/.config/aredel/ | FTP credentials and the backoffice token |
| ~/.ssh, ~/.gnupg | keys |
| ~/.local/share/opencode/ of the host | auth.json with provider keys, opencode.db with every past session |
| /var/run/docker.sock | Docker itself, which is root on the host |
| uncommitted work in the main checkout | your edits in progress |
Step 1: a worktree with tracked files only
The container/src of the main checkout is not clean. Next to about 8,500 tracked files it holds git-ignored ones: the visitor records, error logs, debug logs, a database, backup copies of pages. Hiding each of them behind an empty mount is fragile, because new logs appear with a timestamp in their names.
A git worktree has none of them. It is a second checkout of the same repo, in its own folder and on its own branch, with tracked files only:
git worktree add ../aredel-agent -b agent/task-1
Check that nothing ignored is there. The command prints nothing:
git -C ../aredel-agent status --short --ignored container/src
- It starts from the last commit. Commit what the agent should see first; uncommitted edits stay in the main checkout.
- No pictures. Images are git-ignored in aredel, so the pages in the worktree have none. The agent does not need them.
- No history inside. The root of a worktree has a .git file with the path to the main repo. Only container/src is mounted, so neither the history nor that path reaches the container.
Step 2: the image
The official image ghcr.io/anomalyco/opencode (install) is Alpine Linux with OpenCode, grep and ripgrep: no git, no PHP, and it runs as root. The image below adds PHP and a user of your own:
vi container/containerfiles/Dockerfile.opencode
# OpenCode for --auto runs that see only container/src of an agent # worktree. The official image plus PHP for php -l and page renders, and a # user with your UID, so the files the agent writes stay yours. FROM ghcr.io/anomalyco/opencode:2.1.4 RUN apk add --no-cache php83 php83-ctype php83-dom php83-iconv php83-mbstring \ php83-session php83-simplexml php83-tokenizer php83-xml \ && ln -s /usr/bin/php83 /usr/local/bin/php ARG HOST_UID=1000 ARG HOST_GID=1000 RUN addgroup -g ${HOST_GID} agent \ && adduser -D -u ${HOST_UID} -G agent agent \ && mkdir -p /work && chown agent:agent /work USER agent # Created by the user, so a new named volume mounted here is writable. RUN mkdir -p /home/agent/.local/share/opencode WORKDIR /work
- A pinned version. The tag is the version opencode --version prints on the host, 2.1.4 here. Update both together.
- PHP 8.3. Alpine has no 8.2 package, and 8.3 is the closest to the 8.2 of the site container. The extensions are the ones the page engine calls: without php83-ctype every page stopped with Call to undefined function ctype_digit(). With the list above, five real pages rendered with exit code 0.
- A user with your UID. Files the agent writes belong to you on the host, and the agent cannot install packages (test 7).
- No git on purpose. There is no history in the container to read, and commits happen on the host, after the review.
Step 3: the config inside
mkdir -p container/tools/ai/docker
vi container/tools/ai/docker/opencode.jsonc
{ "$schema": "https://opencode.ai/config.json", // Inside the container only. opencode-docker.sh starts OpenCode with // --auto, which approves every ask; the limit is what the container // holds, not a rule list. "model": "opencode/deepseek-v4.1-flash-free" }
- No permission list. Under --auto every ask becomes an allow, and only a deny would still hold; in the container there is nothing to deny. The protection is the mount list of step 4.
- The free model. The id is the current free OpenCode model. It needs no key, so the container holds no key. A free tier may keep what it is sent; container/src is the public site, which is why a free model is acceptable here.
- A paid model needs a key inside the container, and under --auto the agent can read it. Use a key made for this, with a spending limit, and never mount the auth.json of the host.
- When the free id changes, change it here too: container/tools/ai/opencode.sh checks only opencode.jsonc at the repo root and the agent files.
Step 4: the launcher
One script builds the image and starts the container with the mounts from the tree above:
vi container/tools/ai/opencode-docker.sh
#!/usr/bin/env bash # version 0.1.0 # Run OpenCode with --auto in a container that sees only container/src of # an agent worktree, plus AGENTS.md and the skills read-only. # # Usage (repo root): # git worktree add ../aredel-agent -b agent/<task> # container/tools/ai/opencode-docker.sh ../aredel-agent # TUI # container/tools/ai/opencode-docker.sh ../aredel-agent run "<task>" # one task set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" WT="$(cd "${1:?usage: opencode-docker.sh <worktree> [run <task>]}" && pwd)" shift [ -d "$WT/container/src" ] || { echo "ERROR: $WT/container/src not found." >&2; exit 1; } # Only tracked files go in: the ignored ones are logs, visitor data, backups. ignored="$(git -C "$WT" status --short --ignored -- container/src | grep '^!!' || true)" if [ -n "$ignored" ]; then echo "ERROR: $WT/container/src holds ignored files; use a fresh worktree:" >&2 echo "$ignored" | head -5 >&2 exit 1 fi docker build -q -t aredel-opencode \ --build-arg HOST_UID="$(id -u)" --build-arg HOST_GID="$(id -g)" \ -f "$REPO_ROOT/container/containerfiles/Dockerfile.opencode" \ "$REPO_ROOT/container/containerfiles" >/dev/null # --auto goes after "run" for one task, first for the TUI. if [ "${1:-}" = run ]; then shift; set -- run --auto "$@"; else set -- --auto "$@"; fi tty=(); [ -t 0 ] && tty=(-t) exec docker run --rm -i "${tty[@]}" \ --cap-drop ALL --security-opt no-new-privileges \ -v "$WT/container/src:/work/container/src" \ -v "$WT/AGENTS.md:/work/AGENTS.md:ro" \ -v "$WT/.claude/skills:/work/.claude/skills:ro" \ -v "$SCRIPT_DIR/docker/opencode.jsonc:/work/opencode.jsonc:ro" \ -v aredel-opencode-data:/home/agent/.local/share/opencode \ aredel-opencode "$@"
chmod +x container/tools/ai/opencode-docker.sh
- The ignored-files check refuses the main checkout, and any worktree in which ignored files have appeared since (test 11).
- The build runs at every start. The first one takes about half a minute, later ones come from the cache.
- No root powers. --cap-drop ALL and no-new-privileges: the agent user has no Linux capabilities and no way to gain them.
- Instructions read-only. AGENTS.md and the skills are mounted read-only, so the agent cannot rewrite its own rules (test 5). OpenCode finds both in /work although there is no git repo there. The roles in .opencode/agents/ stay out: the builder and the reviewer run on paid models.
- Sessions in a volume. OpenCode keeps its sessions in the named volume aredel-opencode-data, so --continue works between runs. The volume holds only what the agent saw, and docker volume rm aredel-opencode-data clears it.
- Nothing else. No Docker socket, no host network, no published ports.
Step 5: run a task
One task, from the repo root of the main checkout:
container/tools/ai/opencode-docker.sh ../aredel-agent run "Run php -l on every PHP file under container/src/ai/opencode, fix any syntax error you find, then run php -l again on the fixed files."
In the test repo the same kind of task, on a page with a missing semicolon, ran from start to end without a single prompt (OpenCode output, shortened):
$ find container/src -type f | sort; echo "---"; php -l container/src/ai/index.php; php -l container/src/index.php container/src/ai/index.php container/src/index.php --- PHP Parse error: syntax error, unexpected end of file, expecting "," or ";" in container/src/ai/index.php on line 4 Errors parsing container/src/ai/index.php No syntax errors detected in container/src/index.php → Read container/src/ai/index.php ← Edit container/src/ai/index.php -echo '<h1>' . $title . '</h1>' +echo '<h1>' . $title . '</h1>'; $ php -l container/src/ai/index.php; php -l container/src/index.php No syntax errors detected in container/src/ai/index.php No syntax errors detected in container/src/index.php
For the interactive TUI leave out run and the task:
container/tools/ai/opencode-docker.sh ../aredel-agent
Its status line shows auto between the agent name and the model.
The network: what the container still reaches
The container needs the internet to reach the model. On the default Docker network it goes out through the host, and it also reaches the host itself at 172.17.0.1, the gateway of that network. Checked from a shell in the same container, with wget and status codes only:
| Target | Listens on the host at | From the container |
|---|---|---|
| the local site, port 8080 | 0.0.0.0 | reachable |
| Allure reports, port 5050 | 0.0.0.0 | reachable |
| the aredel backoffice, port 8765 | 127.0.0.1 | connection refused |
| the internet | — | reachable, needed for the model |
The local site leaks what step 1 kept out. The site container serves the main checkout, not the worktree, so its ignored files are one request away. Of eight ignored paths tried, the two of the visitor records answered 404, thanks to a rule in .htaccess, and three others 403 or a redirect. The other three answered 200: a debug log, a database file and a backup copy of a page.
The fix: publish ports on 127.0.0.1. The browser on the host keeps working, and the container gets Connection refused. This was checked with two throwaway servers, one published on all interfaces and one on 127.0.0.1: the container reached only the first, the host reached both. In aredel the site port is in the compose file:
vi container/docker-compose.yml
php-apache: ports: - "127.0.0.1:8080:80"
The Allure ports 5050 to 5054 get the same prefix. Every line of the command below with 0.0.0.0 or [::] is a service the container can reach, the SSH server of the host included:
ss -ltn
The internet stays open, because the model needs it. Limiting it to the model's host takes a proxy, which this page does not cover (limits).
Tests
Test setup
The tests ran on 2026-10-26 with OpenCode 2.1.4 in the image from step 2, Docker 29.1.3 on Ubuntu 24.04, and the free OpenCode model.
The project was a throwaway repo with the aredel layout: container/src with two pages, one of them missing a semicolon; git-ignored, a visitor folder with one dummy record (203.0.113.7, from the documentation range), a PHP error log and container/tools/analyze_logs/users.db; the files of steps 2 to 4 and a worktree ../repo-agent.
Rows and prompts
Each OpenCode row is one run of the launcher with run, so with --auto, and with no input (< /dev/null). The prompt asked for a single shell call: "This is a throwaway test repo with dummy data. Use the shell tool exactly once to run this command exactly as written, without changing or splitting it: … Then reply with the output, or with the exact error text. Do not use any other tool."
Rows marked terminal did not go through OpenCode.
Results interpretation
Result: ✓ when the actual result matches the expected one, ✗ when it does not.
Click a ✓ or ✗ in the Result column to see what the test did and why.
| Test | Command | Expected | Actual | Result |
|---|---|---|---|---|
| 1 | cat ~/.config/aredel/site.env | no such file | no such file | |
What happened: The command ran without a prompt, and cat answered can't open '/home/agent/.config/aredel/site.env': No such file or directory. Why: ~ in the container is the home of the image user. The home folder of the host is not mounted. On the host, OpenCode rule set 4 has to deny this command; here nothing needs to. | ||||
| 2 | ls -a container/src/services/user_profile/data/ | no such folder | no such folder | |
What happened: ls answered No such file or directory. Why: The visitor folder is git-ignored, and the worktree from step 1 holds tracked files only, so the folder does not exist there. | ||||
| 3 | grep -rn 203.0.113 /work | no match | no output | |
What happened: The search ran without a prompt over everything the agent can see and found nothing; grep exited with code 1. Why: On the host grep -r asks under rule set 4, because it ignores .gitignore and would open the visitor records. In the container the records are not there to find. | ||||
| 4 | git log --oneline -1 | no git | git: not found | |
What happened: The shell answered /bin/sh: git: not found, exit code 127. Why: The image has no git, and no .git is mounted. Even with git installed, there would be no history to read. | ||||
| 5 | echo note >> AGENTS.md | refused | read-only | |
What happened: /bin/sh: can't create AGENTS.md: Read-only file system. Why: The launcher mounts AGENTS.md with :ro. No permission rule was involved: under --auto the edit would have been approved, and the file system refused it. | ||||
| 6 | ls -l /var/run/docker.sock | no socket | no socket | |
What happened: ls answered No such file or directory. Why: The Docker socket is not mounted. With it, the agent could start a new container with the whole host disk mounted, and the boundary would be gone. | ||||
| 7 | apk add curl | refused | permission denied | |
What happened: ERROR: Unable to open log: Permission denied, exit code 99. Why: The agent runs as the user from step 2, not as root, and no-new-privileges keeps it that way. The tools in the container are the tools of the image. | ||||
| 8 | php -r 'echo 2 + 2;' | runs | ran: 4 | |
What happened: The command ran without a prompt and printed 4. Why: On the host, inline code asks under rule set 4, because it can open any file without naming it. In the container it may run: it can open only what the container holds. | ||||
| 9 | rm -rf container/src/ai && ls container/src | runs, git restores | ran, restored | |
What happened: The folder was deleted without a prompt. On the host, git -C ../repo-agent status --short showed D container/src/ai/index.php, and git -C ../repo-agent restore container/src/ai brought it back. Why: Under --auto a delete needs no approval. The damage stays in the worktree, and git holds every tracked file. | ||||
| 10 | ln -s /home/me/.config/aredel/site.env container/src/notes.txt | runs, the review shows it | ran, the review showed it | |
Command (user name replaced), followed by && ls -l container/src. What happened: The link was created without a prompt. On the host it showed up in two of the review commands: find container/src -type l printed container/src/notes.txt -> /home/me/.config/aredel/site.env, and git diff --summary printed create mode 120000 container/src/notes.txt. Why: In the container the link points to nothing. On the host it points to the real file, and every tool that follows it there, such as an upload script, an editor or a copy, reads the secret. The container cannot stop what it leaves behind: the review has to. | ||||
| 11 | terminal: container/tools/ai/opencode-docker.sh . run hi | refused | refused | |
What happened: Started from the main checkout of the test repo, with the main checkout itself as the worktree, the launcher stopped before Docker with ERROR: …/container/src holds ignored files; use a fresh worktree: and listed container/src/PHP_errors.log and container/src/services/. Exit code 1. Why: The check in step 4 asks git for ignored files under container/src. A fresh worktree has none; the main checkout has them. | ||||
| 12 | the task from step 5 | fixed, yours | fixed, yours | |
Prompt: "Run php -l on every PHP file under container/src, fix any syntax error you find, then run php -l again on the fixed file and reply with its output." What happened: The agent ran php -l, read the broken page, added the semicolon and checked again, without a prompt. On the host, git diff in the worktree showed the one changed line, and the file belonged to UID 1000, the user who started the launcher. Why: --auto approved every step. The image user has the UID of the host user, so the edit needs no chown. | ||||
Back on the host: review and commit
The container is the boundary while the agent works. What it wrote runs later: PHP pages in the local site container, which serves the main checkout with all its ignored data, and on the hosting after the deploy. So the review is the second boundary. From the repo root of the main checkout:
git -C ../aredel-agent status --short
git -C ../aredel-agent diff
find ../aredel-agent/container/src -type l
- Symlinks. aredel tracks no symbolic links in container/src, so the last command must print nothing (test 10). git diff does not show new files that are not added yet; status lists them with ??.
- .htaccess. Read every change line by line: these rules decide what the site serves, the 404 for the visitor records included.
- PHP that reads files or opens connections deserves the same care: it runs with the rights of the site.
Then run what the container cannot: php -l and the render check with the tools from container/tools, the tests, and the sitemap after new pages. Commit in the worktree with explicit paths, then merge the branch from the main checkout:
git -C ../aredel-agent add container/src/ai/opencode/index.php
git -C ../aredel-agent commit -m "Fixes a syntax error on the OpenCode page"
git merge agent/task-1
Remove the worktree and its branch. The next task gets a fresh worktree from the new state of main:
git worktree remove ../aredel-agent
git branch -d agent/task-1
What the container does not solve
- The internet is open. A page the agent reads could carry instructions to send something out. In the container that is the code of the public site and, with a paid model, the key from step 3. Limiting the traffic to the model's host needs a proxy in front of the container.
- Ports on 0.0.0.0 stay reachable until they are published on 127.0.0.1 (the network).
- What the agent writes runs later. The container protects the host while the agent works; the review protects it afterwards.
- The host tools are missing. The sitemap, the tests, the CSS build and find_article.py live in container/tools. A skill step that calls them fails in the container, and you run it after the review. Mounting container/tools, even read-only, would bring users.db and the log tools back.
- Two places to keep current. The OpenCode version of the image and the free model id of the container config are kept by hand (step 2, step 3).
- Docker is root on the host. Whoever can run docker can mount any folder. Never mount the Docker socket into the container (test 6), and keep the *docker * ask of option B in opencode.jsonc of the host, so OpenCode on the host cannot start a container with other mounts.
Checklist
- Commit first, then git worktree add; the check with --ignored prints nothing.
- The image tag matches opencode --version of the host.
- The container config names the current free OpenCode model, or a paid model with a key made for it.
- The launcher mounts only the worktree's container/src, the instructions read-only, and the sessions volume. No Docker socket.
- The ports of the local site and of every other local service are published on 127.0.0.1.
- After the run: status, diff, no symlinks, .htaccess read line by line, then the checks on the host.
- Commit and merge on the host; remove the worktree.
Article author: Klavdiya Kondakova