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:

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 hostWhat it holds
.gitthe whole history, deleted files included
ignored files in container/srcvisitor 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, ~/.gnupgkeys
~/.local/share/opencode/ of the hostauth.json with provider keys, opencode.db with every past session
/var/run/docker.sockDocker itself, which is root on the host
uncommitted work in the main checkoutyour 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

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

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" }

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

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:

TargetListens on the host atFrom the container
the local site, port 80800.0.0.0reachable
Allure reports, port 50500.0.0.0reachable
the aredel backoffice, port 8765127.0.0.1connection refused
the internet—reachable, needed for the model

The local site leaks what step 1 (a worktree with tracked files only) 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 (what the container does not solve).

Tests

Test setup

The tests ran on 2026-10-26 with OpenCode 2.1.4 in the image from step 2 (the image), 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.

TestCommandExpectedActualResult
1cat ~/.config/aredel/site.envno such fileno 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.

2ls -a container/src/services/user_profile/data/no such folderno such folder

What happened: ls answered No such file or directory.

Why: The visitor folder is git-ignored, and the worktree from step 1 (a worktree with tracked files only) holds tracked files only, so the folder does not exist there.

3grep -rn 203.0.113 /workno matchno 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.

4git log --oneline -1no gitgit: 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.

5echo note >> AGENTS.mdrefusedread-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.

6ls -l /var/run/docker.sockno socketno 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.

7apk add curlrefusedpermission denied

What happened: ERROR: Unable to open log: Permission denied, exit code 99.

Why: The agent runs as the user from step 2 (the image), not as root, and no-new-privileges keeps it that way. The tools in the container are the tools of the image.

8php -r 'echo 2 + 2;'runsran: 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.

9rm -rf container/src/ai && ls container/srcruns, git restoresran, 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.

10ln -s /home/me/.config/aredel/site.env container/src/notes.txtruns, the review shows itran, 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 commands of review and commit on the host: 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.

11terminal: container/tools/ai/opencode-docker.sh . run hirefusedrefused

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 (the launcher) asks git for ignored files under container/src. A fresh worktree has none; the main checkout has them.

12the task from step 5 (run a task)fixed, yoursfixed, 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

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

Problems on the first real tasks

On 2026-10-27 two tasks ran with the launcher of the general manual OpenCode in Docker on Ubuntu 24.04 (page The launcher), version 0.2.0. Besides container/src it mounts container/tools, container/docs and container/tests of the worktree read-only, and its notes in CONTAINER.md replace the rules of AGENTS.md that cannot work in a container. The model was the free OpenCode model:

WorktreeBranchTask
../site-agentagent/alpine-menua Perl course under /dev/perl/
../site-agent-log-errorsagent/alpine-menu-log-errorsa plan for the errors in the access log of 2026-10-26

Both branch names were left over from an earlier task; name a branch after the task it holds. The general lessons are in problems met on real tasks of that manual. Here is what each problem looked like in aredel.

The report of the course

The agent wrote 58 pages and linked the course from /dev/, dev/RelatedArticles.php and a new .php/right_blocks/perl.php. It reported every check clean, in Russian, although the task was in English: end a task with "Answer in English." when that matters. What the report did not cover:

The sitemap

The agent ran ./tools/generate-sitemap.sh, as AGENTS.md says after new pages. The script dates each page by the time of its file, and in the new worktree every file had the time of the checkout:

Committed sitemapAfter the agent's run
URLs4,2704,392, 58 of them the course
Most common lastmod2026-10-23, 857 pages2026-10-26, 4,333 pages
git diff --statsitemap.xml | 9302

It also left sitemap.xml.bak-20261027084101. CONTAINER.md now tells the agent not to run the script. The review restores the file and deletes the backup:

git -C ../site-agent restore container/src/sitemap.xml

After the merge the sitemap is rebuilt in the main checkout, where the files have their real times. That commit added the 58 URLs of the course.

The session

run "can you repeat your report but in english" started a new session, which had no report to repeat. A later run in ../site-agent-log-errors carried on with the course, from a session of the shared volume opencode-agent-data. It searched the worktree of the log task for dev/perl and /tmp/agentwork and waited with sleep 20. The course itself was safe in ../site-agent, where the fix run had changed 47 files by 12:08.

The log

The task named backup/aredel.com.access.log.20261026. backup/ is git-ignored, so the file is not in the worktree, and it holds visitor IP addresses, which do not go to a free model. list_404.py from container/tools/analyze_logs keeps the time and the path of each hit, nothing else:

cd ~/github/aredel/container/tools/analyze_logs

python3 list_404.py --log-file ~/github/aredel/backup/aredel.com.access.log.20261026 --status 404 --skip-probes --output ~/github/site-agent-log-errors/container/docs/log-20261026-404.txt > /dev/null

python3 list_404.py --log-file ~/github/aredel/backup/aredel.com.access.log.20261026 --status 500 --output ~/github/site-agent-log-errors/container/docs/log-20261026-500.txt > /dev/null

Each line is YYYY-MM-DD HH:MM:SS /url/path. The lists are untracked files in the read-only container/docs of the worktree, and they are deleted after the task.

The hooks in the worktree

The first commit of the course in ../site-agent stopped at three hooks:

php -l (container/src, staged)...........................................Failed - hook id: php-lint-src - exit code: 1 ERROR: src is outside the git repo. phpcs (container/src, staged)............................................Failed - hook id: phpcs-src - exit code: 1 ERROR: src is outside the git repo. check-no-redirected-pages (staged).......................................Failed - hook id: check-no-redirected-pages - exit code: 1 ERROR: new page /.php/right_blocks/perl.php is a redirect source (.htaccess line 25, 301 ^(.+\.php)/.+$). Remove the redirect or pick a different URL.

ScriptWhy it failedFix
lint-php.sh, lint-static.sh, lint-sh.shgit -C "$SCRIPT_DIR" rev-parse --show-toplevel returned container/tools/linters, because git hands a hook in a worktree GIT_DIR without GIT_WORK_TREE. lint-sh.sh was skipped here, with no .sh stagedunset GIT_DIR GIT_WORK_TREE at the start; versions 0.0.2, 0.0.4 and 0.0.2
lint-php.shit runs php -l in php-apache, which mounts the src of the main checkout. docker compose in the container/ folder of a worktree reaches the same container: same folder name, same container_namein a linked worktree it lints with php:8.2-cli on the worktree's src
check-no-redirected-pages.shit took every new .php for a page. /.php/… matches the PATH_INFO rule of line 25 and is sent to /.php, like every include thereskips dot folders, as generate-sitemap.sh does

With the fix, the hooks found what the second run had broken:

Parse error: syntax error, unexpected floating-point number "127.0", expecting "," or ";" in /code/dev/perl/course/47_capstone_logs.php on line 67 Errors parsing /code/dev/perl/course/47_capstone_logs.php linted: 63, failures: 1

Two printf commands of the lesson had raw single quotes inside the echo '…' string of the page. On the site the page would have answered 500. phpcs reported 152 findings, all from the same break. Each quote became &#39;.

The commit

By then the worktree was ten commits behind main, feed updates among them. The post-commit hook post-to-feed.sh stages feed.json and arch.json after a commit with new pages. The commands that went through, from the main checkout:

git -C ../site-agent merge --ff-only main

git -C ../site-agent add container/src/dev/perl/course/47_capstone_logs.php

git -C ../site-agent commit -m "Adds a Perl course under /dev/perl/: 50 lessons, install guides and a hub"

git -C ../site-agent commit -m "Updates feed"

git merge --ff-only agent/alpine-menu

container/tools/generate-sitemap.sh

git clean -f -- 'container/src/sitemap.xml.bak-*'

git add container/src/sitemap.xml

git commit -m "Adds the Perl course to the sitemap"

git worktree remove ../site-agent

git branch -d agent/alpine-menu

git clean removes only untracked backups; sitemap.xml.bak-20261015080545 is tracked and stays.

Another agent in the main checkout

The commit of the hook fix, in the main checkout, failed first:

lint-php.sh self test....................................................Failed - hook id: lint-php-sh-selftest - files were modified by this hook

pre-commit had put 7 changed files aside at 14:32. At 14:32:26 container/src/qa/testing_top_path.php changed, a file the commit did not touch: another Claude Code session and an OpenCode started on the host both worked in the main checkout. The self test passed on its own, through pre-commit run, and in a clone with the same staged and unstaged files. The same commit went through on the next try.

What the container does not solve

Checklist

Article author: Klavdiya Kondakova