From 9fbc93fc621d50e834b088a4580f32973d45b381 Mon Sep 17 00:00:00 2001 From: Nat Welch Date: Wed, 14 Oct 2026 14:00:47 +0000 Subject: [PATCH 1/6] fix: refuse to start without a data volume and test state across recreates The entrypoint now exits when $HOME (/data) is not a mountpoint, so sessions, credentials, and logs can never land in the container layer. The smoke test runs both servers on a named volume, then recreates the OpenCode container and checks that a session, the server log, and the config and state directories survive. OpenChamber chats move to /data/workspace/.chats so agent sessions no longer run with a working directory inside /data/.config. Co-Authored-By: Claude Opus 5.5 --- Dockerfile | 1 + entrypoint.sh | 5 ++++ scripts/smoke-test.sh | 70 ++++++++++++++++++++++++++++++------------- 3 files changed, 55 insertions(+), 21 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9603ec1..824a5d0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -63,6 +63,7 @@ ENV HOME=/data \ XDG_CACHE_HOME=/data/.cache \ OPENCODE_CONFIG=/etc/opencode/opencode.json \ OPENCODE_CLIENT=app \ + OPENCHAMBER_CHATS_DIR=/data/workspace/.chats \ SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt COPY --from=config /build/runtime.json /etc/opencode/opencode.json diff --git a/entrypoint.sh b/entrypoint.sh index 5dc185f..fd6f6e1 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -19,6 +19,11 @@ if [ "${1:-}" = openchamber ] && [ "${#ui_password}" -lt 32 ]; then exit 1 fi +if ! mountpoint -q "$HOME"; then + echo "$HOME must be a volume or bind mount; sessions, credentials, and logs written to the container layer are lost when it is recreated" >&2 + exit 1 +fi + mkdir -p "$XDG_CONFIG_HOME/opencode" "$XDG_CONFIG_HOME/gh" "$XDG_DATA_HOME/opencode" \ "$XDG_STATE_HOME/opencode" "$XDG_CACHE_HOME/opencode" "$HOME/workspace/.worktrees" diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 596dc11..8c1f542 100644 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -47,33 +47,42 @@ test "$status" = 1 status=0 timeout 15 docker run --rm -e OPENCODE_PASSWORD=short "$image" || status=$? test "$status" = 1 +# Startup must also fail without a volume at /data. +status=0 +timeout 15 docker run --rm -e "OPENCODE_PASSWORD=$password" "$image" || status=$? +test "$status" = 1 -docker run -d --name "$name" \ - --cap-drop ALL --security-opt no-new-privileges:true \ - --pids-limit 2048 --memory 4g --cpus 2 \ - -p 127.0.0.1::4096 \ - -e "OPENCODE_PASSWORD=$password" \ - -e LUNCHMONEY_API_TOKEN=catalog-test-only \ - -e KARAKEEP_API_ADDR=http://127.0.0.1:1 \ - -e KARAKEEP_API_KEY=catalog-test-only \ - "$image" +volume="opencode-test-data-$$" +start_opencode() { + docker run -d --name "$name" \ + --cap-drop ALL --security-opt no-new-privileges:true \ + --pids-limit 2048 --memory 4g --cpus 2 \ + -p 127.0.0.1::4096 \ + -v "$volume:/data" \ + -e "OPENCODE_PASSWORD=$password" \ + -e LUNCHMONEY_API_TOKEN=catalog-test-only \ + -e KARAKEEP_API_ADDR=http://127.0.0.1:1 \ + -e KARAKEEP_API_KEY=catalog-test-only \ + "$image" + url="http://127.0.0.1:$(docker port "$name" 4096/tcp | cut -d: -f2)" + for attempt in $(seq 1 60); do + if docker exec "$name" /usr/local/bin/healthcheck.sh; then + break + fi + printf 'Waiting for OpenCode (%s/60)\n' "$attempt" + sleep 3 + done + docker exec "$name" /usr/local/bin/healthcheck.sh +} cleanup() { - docker logs "$name" + docker logs "$name" || true docker exec "$name" sh -c 'for log in "$XDG_DATA_HOME"/opencode/log/*.log; do [ ! -f "$log" ] || cat "$log"; done' || true - docker rm -f "$name" + docker rm -f "$name" || true + docker volume rm "$volume" || true } trap cleanup EXIT -port=$(docker port "$name" 4096/tcp | cut -d: -f2) -url="http://127.0.0.1:$port" +start_opencode -for attempt in $(seq 1 60); do - if docker exec "$name" /usr/local/bin/healthcheck.sh; then - break - fi - printf 'Waiting for OpenCode (%s/60)\n' "$attempt" - sleep 3 -done -docker exec "$name" /usr/local/bin/healthcheck.sh for path in /api/info /api/integration; do test "$(curl -s -o /dev/null -w '%{http_code}' "$url$path")" = 401 test "$(curl -s -o /dev/null -w '%{http_code}' --user opencode:wrong-password "$url$path")" = 401 @@ -97,6 +106,23 @@ docker exec "$name" sh -ec ' cmp /etc/opencode/AGENTS.md "$XDG_CONFIG_HOME/opencode/AGENTS.md" ' +# Sessions, logs, config, and state must survive recreating the container on the same volume. +location='location%5Bdirectory%5D=/data/workspace' +session=$(curl --fail -s --user "opencode:$password" -H 'Content-Type: application/json' \ + -d '{"title":"Persistence check","agent":"build","location":{"directory":"/data/workspace"}}' \ + "$url/api/session?$location" | jq -er '.data.id') +docker exec "$name" sh -ec 'touch "$XDG_CONFIG_HOME/gh/persist-check" "$XDG_STATE_HOME/opencode/persist-check"' +docker rm -f "$name" +start_opencode +curl --fail -s --user "opencode:$password" "$url/api/session/$session?$location" | + jq -e --arg id "$session" '.data.id == $id' >/dev/null +docker exec "$name" sh -ec ' + test -f "$XDG_CONFIG_HOME/gh/persist-check" + test -f "$XDG_STATE_HOME/opencode/persist-check" + starts=$(grep "message=\"cli starting\"" "$XDG_DATA_HOME/opencode/log/opencode.log" | grep -c -e --hostname) + test "$starts" -ge 2 +' + # OpenChamber must refuse to start without a strong UI password. for ui in "" short " a "; do status=0 @@ -113,6 +139,7 @@ docker network connect --alias opencode "$network" "$name" docker run -d --name "$chamber" --network "$network" \ --cap-drop ALL --security-opt no-new-privileges:true \ -p 127.0.0.1::3000 \ + -v "$volume:/data" \ -e OPENCODE_HOST=http://opencode:4096 \ -e OPENCODE_SKIP_START=true \ -e "OPENCODE_PASSWORD=$password" \ @@ -132,6 +159,7 @@ for attempt in $(seq 1 40); do sleep 3 done docker exec "$chamber" /usr/local/bin/openchamber-healthcheck.sh +docker exec "$chamber" test -d /data/workspace/.chats test "$(curl -s -o /dev/null -w '%{http_code}' "$chamber_url/api/session")" = 401 test "$(curl -s -o /dev/null -w '%{http_code}' -H 'Content-Type: application/json' \ From d8ef5c32b5d636d113c435faabc10ba6c033dc86 Mon Sep 17 00:00:00 2001 From: Nat Welch Date: Wed, 14 Oct 2026 14:00:48 +0000 Subject: [PATCH 2/6] fix: ask before agents delete opencode state and document what persists Build and General now ask before rm commands that name a .config or .local path, and AGENTS.md forbids deleting /data/.config or /data/.local. The README lists what lives where under /data and how to restore it. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 1 + README.md | 15 +++++++++++++-- opencode.jsonc | 12 ++++++++++-- tests/routing.test.mjs | 11 ++++++++++- 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fa859b2..a874df5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,7 @@ - Before editing, inspect the canonical checkout and create a task branch with `git worktree add`. Move the OpenCode session to the worktree and run edits, tests, commits, and pushes there. - Keep uncommitted user changes untouched. Do not delete worktrees with uncommitted work or remove another session's worktree. - For research-only tasks, reading the canonical checkout is fine. For non-Git directories, ask before initializing a repository. +- Never delete, move, or reset `/data/.config` or `/data/.local`. They hold the OpenCode database, logs, credentials, and OpenChamber settings for every session. ## Workflow diff --git a/README.md b/README.md index 99ef32a..5bb6841 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,18 @@ docker compose up -d Open and sign in with `OPENCHAMBER_UI_PASSWORD`. Keep both passwords in your deployment's secret store and export them for later Compose commands; startup requires at least 32 characters for each. OpenCode has no host port; only OpenChamber reaches it, over the Compose network. -The `/data` volume preserves repositories, sessions, credentials, and caches. Keep repositories in `/data/workspace` and back up the volume. Bind mounts must be writable by UID/GID 1000. Compose also mounts the host's `~/.ssh` read-only; keys must be readable by UID 1000, and `known_hosts` must be maintained on the host. +All state lives in the `/data` volume, and startup exits if `/data` is not a volume or bind mount. Bind mounts must be writable by UID/GID 1000. Compose also mounts the host's `~/.ssh` read-only; keys must be readable by UID 1000, and `known_hosts` must be maintained on the host. + +| Path under `/data` | Contents | +| --- | --- | +| `.local/share/opencode/opencode.db` | Sessions and provider credentials | +| `.local/share/opencode/log/opencode.log` | OpenCode server log | +| `.local/state/opencode` | Service registration and locks | +| `.config/opencode`, `.config/gh` | Global `AGENTS.md` (replaced on each start), GitHub CLI login | +| `.config/openchamber` | OpenChamber settings, passkeys, and login secret | +| `workspace` | Repositories, worktrees, and OpenChamber chats in `workspace/.chats` | + +Back up the whole volume. To restore, stop both containers, replace the volume contents, and start them again. The servers keep files open or cached in memory, so deleting one from a running volume takes effect at the next restart. To update the image or apply changed environment variables: @@ -61,7 +72,7 @@ Grafana and Karakeep can connect before their credentials are validated; check a ## Agents and configuration -- **Build** is the default agent and can use all configured tools. **General** can implement and test; **Explore** is read-only. Both subagents may use Context7 but deny Grafana, Lunch Money, Karakeep, Notion, Robinhood, and further delegation. Tool permissions do not sandbox shell access. +- **Build** is the default agent and can use all configured tools. **General** can implement and test; **Explore** is read-only. Both subagents may use Context7 but deny Grafana, Lunch Money, Karakeep, Notion, Robinhood, and further delegation. Build and General ask before `rm` commands that name a `.config` or `.local` path. Tool permissions do not sandbox shell access. - [Superpowers](https://github.com/obra/superpowers) supplies planning, debugging, testing, and review skills. The image also ships [`commit-push-pr` and `go-repo`](skills/). [`AGENTS.md`](AGENTS.md) requires repository edits in `/data/workspace/.worktrees//`. - [OpenCode Quota](https://github.com/slkiser/opencode-quota) provides `/quota` and `/quota_status`. - Defaults and pinned plugins live in [`opencode.jsonc`](opencode.jsonc). The image converts it to `/etc/opencode/opencode.json`; project `opencode.json(c)` files can override settings. See the [V2 configuration guide](https://opencode.ai/v2/docs/config). diff --git a/opencode.jsonc b/opencode.jsonc index ab67fcc..02450b2 100644 --- a/opencode.jsonc +++ b/opencode.jsonc @@ -13,7 +13,11 @@ "agents": { "build": { "permissions": [ - { "action": "*", "resource": "*", "effect": "allow" } + { "action": "*", "resource": "*", "effect": "allow" }, + { "action": "shell", "resource": "rm *.config", "effect": "ask" }, + { "action": "shell", "resource": "rm *.config/*", "effect": "ask" }, + { "action": "shell", "resource": "rm *.local", "effect": "ask" }, + { "action": "shell", "resource": "rm *.local/*", "effect": "ask" } ] }, "general": { @@ -26,7 +30,11 @@ { "action": "subagent", "resource": "*", "effect": "deny" }, { "action": "skill", "resource": "*", "effect": "allow" }, { "action": "execute", "resource": "*", "effect": "allow" }, - { "action": "context7_*", "resource": "*", "effect": "allow" } + { "action": "context7_*", "resource": "*", "effect": "allow" }, + { "action": "shell", "resource": "rm *.config", "effect": "ask" }, + { "action": "shell", "resource": "rm *.config/*", "effect": "ask" }, + { "action": "shell", "resource": "rm *.local", "effect": "ask" }, + { "action": "shell", "resource": "rm *.local/*", "effect": "ask" } ] }, "explore": { diff --git a/tests/routing.test.mjs b/tests/routing.test.mjs index f08b8ff..c50fddb 100644 --- a/tests/routing.test.mjs +++ b/tests/routing.test.mjs @@ -42,11 +42,20 @@ test("existing providers, MCP integrations and quota are preserved", () => { assert.deepEqual(Object.keys(config.mcp.servers).sort(), ["context7", "grafana", "karakeep", "lunchmoney", "notion", "robinhood"]); assert.ok(config.plugins.includes("@slkiser/opencode-quota@5.0.2")); assert.ok(!config.permissions?.some(rule => rule.action === "*" && rule.effect === "allow")); - assert.deepEqual(config.agents.build.permissions, [{ action: "*", resource: "*", effect: "allow" }]); + assert.deepEqual(config.agents.build.permissions[0], { action: "*", resource: "*", effect: "allow" }); for (const name of ["general", "explore"]) assert.ok(config.agents[name].permissions.some(rule => rule.action === "robinhood_*" && rule.resource === "*" && rule.effect === "deny"), `${name} denies Robinhood tools`); }); +test("agents ask before deleting server state under the home directory", async () => { + const asks = ["rm *.config", "rm *.config/*", "rm *.local", "rm *.local/*"] + .map(resource => ({ action: "shell", resource, effect: "ask" })); + for (const name of ["build", "general"]) + assert.deepEqual(config.agents[name].permissions.slice(-asks.length), asks, `${name} ends with the state-deletion asks`); + const instructions = await readFile(new URL("../AGENTS.md", import.meta.url), "utf8"); + assert.match(instructions, /Never delete, move, or reset `\/data\/\.config` or `\/data\/\.local`/); +}); + test("runtime conversion preserves JSONC overrides and rejects malformed input", async () => { const home = await mkdtemp(join(tmpdir(), "opencode-config-test-")); try { From 241d6a23375b26b000a22f16e5aa11083f15317b Mon Sep 17 00:00:00 2001 From: Nat Welch Date: Wed, 14 Oct 2026 14:04:26 +0000 Subject: [PATCH 3/6] fix: match rm commands that name .config or .local anywhere The suffix patterns missed rm -rf .config .local .cache, where neither protected directory is last or followed by a slash. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- opencode.jsonc | 12 ++++-------- tests/routing.test.mjs | 2 +- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 5bb6841..facff49 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Grafana and Karakeep can connect before their credentials are validated; check a ## Agents and configuration -- **Build** is the default agent and can use all configured tools. **General** can implement and test; **Explore** is read-only. Both subagents may use Context7 but deny Grafana, Lunch Money, Karakeep, Notion, Robinhood, and further delegation. Build and General ask before `rm` commands that name a `.config` or `.local` path. Tool permissions do not sandbox shell access. +- **Build** is the default agent and can use all configured tools. **General** can implement and test; **Explore** is read-only. Both subagents may use Context7 but deny Grafana, Lunch Money, Karakeep, Notion, Robinhood, and further delegation. Build and General ask before any `rm` command whose text contains `.config` or `.local`. Tool permissions do not sandbox shell access. - [Superpowers](https://github.com/obra/superpowers) supplies planning, debugging, testing, and review skills. The image also ships [`commit-push-pr` and `go-repo`](skills/). [`AGENTS.md`](AGENTS.md) requires repository edits in `/data/workspace/.worktrees//`. - [OpenCode Quota](https://github.com/slkiser/opencode-quota) provides `/quota` and `/quota_status`. - Defaults and pinned plugins live in [`opencode.jsonc`](opencode.jsonc). The image converts it to `/etc/opencode/opencode.json`; project `opencode.json(c)` files can override settings. See the [V2 configuration guide](https://opencode.ai/v2/docs/config). diff --git a/opencode.jsonc b/opencode.jsonc index 02450b2..a537f83 100644 --- a/opencode.jsonc +++ b/opencode.jsonc @@ -14,10 +14,8 @@ "build": { "permissions": [ { "action": "*", "resource": "*", "effect": "allow" }, - { "action": "shell", "resource": "rm *.config", "effect": "ask" }, - { "action": "shell", "resource": "rm *.config/*", "effect": "ask" }, - { "action": "shell", "resource": "rm *.local", "effect": "ask" }, - { "action": "shell", "resource": "rm *.local/*", "effect": "ask" } + { "action": "shell", "resource": "rm *.config*", "effect": "ask" }, + { "action": "shell", "resource": "rm *.local*", "effect": "ask" } ] }, "general": { @@ -31,10 +29,8 @@ { "action": "skill", "resource": "*", "effect": "allow" }, { "action": "execute", "resource": "*", "effect": "allow" }, { "action": "context7_*", "resource": "*", "effect": "allow" }, - { "action": "shell", "resource": "rm *.config", "effect": "ask" }, - { "action": "shell", "resource": "rm *.config/*", "effect": "ask" }, - { "action": "shell", "resource": "rm *.local", "effect": "ask" }, - { "action": "shell", "resource": "rm *.local/*", "effect": "ask" } + { "action": "shell", "resource": "rm *.config*", "effect": "ask" }, + { "action": "shell", "resource": "rm *.local*", "effect": "ask" } ] }, "explore": { diff --git a/tests/routing.test.mjs b/tests/routing.test.mjs index c50fddb..fd03567 100644 --- a/tests/routing.test.mjs +++ b/tests/routing.test.mjs @@ -48,7 +48,7 @@ test("existing providers, MCP integrations and quota are preserved", () => { }); test("agents ask before deleting server state under the home directory", async () => { - const asks = ["rm *.config", "rm *.config/*", "rm *.local", "rm *.local/*"] + const asks = ["rm *.config*", "rm *.local*"] .map(resource => ({ action: "shell", resource, effect: "ask" })); for (const name of ["build", "general"]) assert.deepEqual(config.agents[name].permissions.slice(-asks.length), asks, `${name} ends with the state-deletion asks`); From 4fa974b2b6f66d6ff4081cb71c9500ee6bca1f88 Mon Sep 17 00:00:00 2001 From: Nat Welch Date: Wed, 14 Oct 2026 14:08:46 +0000 Subject: [PATCH 4/6] fix: cover env-var, tilde, and /data rm forms and test real decisions Adds ask rules for rm commands naming HOME (including XDG_*_HOME), ~, or ending in /data. The integration test now matches each rule's resource as well as its action, and checks ask/allow decisions for sample commands against the agents the server reports. The negative password runs mount a volume, so they fail on the password check rather than the mount guard. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- opencode.jsonc | 12 ++++++++++-- scripts/smoke-test.sh | 6 +++--- scripts/test-router-integration.mjs | 10 ++++++++-- tests/routing.test.mjs | 2 +- 5 files changed, 23 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index facff49..64e761a 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Grafana and Karakeep can connect before their credentials are validated; check a ## Agents and configuration -- **Build** is the default agent and can use all configured tools. **General** can implement and test; **Explore** is read-only. Both subagents may use Context7 but deny Grafana, Lunch Money, Karakeep, Notion, Robinhood, and further delegation. Build and General ask before any `rm` command whose text contains `.config` or `.local`. Tool permissions do not sandbox shell access. +- **Build** is the default agent and can use all configured tools. **General** can implement and test; **Explore** is read-only. Both subagents may use Context7 but deny Grafana, Lunch Money, Karakeep, Notion, Robinhood, and further delegation. Build and General ask before `rm` commands whose text contains `.config`, `.local`, `HOME`, or `~`, or ends in `/data`. Tool permissions do not sandbox shell access. - [Superpowers](https://github.com/obra/superpowers) supplies planning, debugging, testing, and review skills. The image also ships [`commit-push-pr` and `go-repo`](skills/). [`AGENTS.md`](AGENTS.md) requires repository edits in `/data/workspace/.worktrees//`. - [OpenCode Quota](https://github.com/slkiser/opencode-quota) provides `/quota` and `/quota_status`. - Defaults and pinned plugins live in [`opencode.jsonc`](opencode.jsonc). The image converts it to `/etc/opencode/opencode.json`; project `opencode.json(c)` files can override settings. See the [V2 configuration guide](https://opencode.ai/v2/docs/config). diff --git a/opencode.jsonc b/opencode.jsonc index a537f83..567e81a 100644 --- a/opencode.jsonc +++ b/opencode.jsonc @@ -15,7 +15,11 @@ "permissions": [ { "action": "*", "resource": "*", "effect": "allow" }, { "action": "shell", "resource": "rm *.config*", "effect": "ask" }, - { "action": "shell", "resource": "rm *.local*", "effect": "ask" } + { "action": "shell", "resource": "rm *.local*", "effect": "ask" }, + { "action": "shell", "resource": "rm *HOME*", "effect": "ask" }, + { "action": "shell", "resource": "rm *~*", "effect": "ask" }, + { "action": "shell", "resource": "rm */data", "effect": "ask" }, + { "action": "shell", "resource": "rm */data/", "effect": "ask" } ] }, "general": { @@ -30,7 +34,11 @@ { "action": "execute", "resource": "*", "effect": "allow" }, { "action": "context7_*", "resource": "*", "effect": "allow" }, { "action": "shell", "resource": "rm *.config*", "effect": "ask" }, - { "action": "shell", "resource": "rm *.local*", "effect": "ask" } + { "action": "shell", "resource": "rm *.local*", "effect": "ask" }, + { "action": "shell", "resource": "rm *HOME*", "effect": "ask" }, + { "action": "shell", "resource": "rm *~*", "effect": "ask" }, + { "action": "shell", "resource": "rm */data", "effect": "ask" }, + { "action": "shell", "resource": "rm */data/", "effect": "ask" } ] }, "explore": { diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 8c1f542..4017143 100644 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -42,10 +42,10 @@ docker run --rm --entrypoint zsh "$image" -euc ' # Startup must fail promptly when no password is provided. status=0 -timeout 15 docker run --rm "$image" || status=$? +timeout 15 docker run --rm -v /data "$image" || status=$? test "$status" = 1 status=0 -timeout 15 docker run --rm -e OPENCODE_PASSWORD=short "$image" || status=$? +timeout 15 docker run --rm -v /data -e OPENCODE_PASSWORD=short "$image" || status=$? test "$status" = 1 # Startup must also fail without a volume at /data. status=0 @@ -126,7 +126,7 @@ docker exec "$name" sh -ec ' # OpenChamber must refuse to start without a strong UI password. for ui in "" short " a "; do status=0 - timeout 15 docker run --rm -e "OPENCODE_PASSWORD=$password" -e "OPENCHAMBER_UI_PASSWORD=$ui" \ + timeout 15 docker run --rm -v /data -e "OPENCODE_PASSWORD=$password" -e "OPENCHAMBER_UI_PASSWORD=$ui" \ "$image" openchamber serve --foreground || status=$? test "$status" = 1 done diff --git a/scripts/test-router-integration.mjs b/scripts/test-router-integration.mjs index 76c7a8a..a4f9744 100644 --- a/scripts/test-router-integration.mjs +++ b/scripts/test-router-integration.mjs @@ -90,8 +90,9 @@ try { const lead = agents.find(a => a.id === "build"); assert.ok(lead, "native build agent is available"); assert.ok(!agents.some(a => a.id.startsWith("orch-"))); - function permission(agent, action) { - return agent.permissions.filter(rule => new RegExp(`^${rule.action.split("*").map(RegExp.escape).join(".*")}$`).test(action)).at(-1)?.effect; + const glob = pattern => new RegExp(`^${[...pattern].map(c => c === "*" ? ".*" : c === "?" ? "." : RegExp.escape(c)).join("")}$`, "s"); + function permission(agent, action, resource = "") { + return agent.permissions.filter(rule => glob(rule.action).test(action) && glob(rule.resource).test(resource)).at(-1)?.effect; } for (const action of ["execute", "grafana_list_datasources", "lunchmoney_get_user", "karakeep_search-bookmarks", "notion_notion-search", "robinhood_test_tool", "context7_query-docs"]) assert.equal(permission(lead, action), "allow", `lead can use ${action}`); @@ -108,6 +109,11 @@ try { assert.equal(permission(explore, action), "deny", `explore cannot use ${action}`); for (const action of ["edit", "shell"]) assert.equal(permission(general, action), "allow", `general can implement and test with ${action}`); + for (const agent of [lead, general]) { + assert.equal(permission(agent, "shell", "rm -rf node_modules"), "allow", `${agent.id} can delete project files`); + for (const command of ["rm -rf ~/.config", 'rm -rf "$HOME/.local/share/opencode"', 'rm -rf "$XDG_CONFIG_HOME"', "rm -rf .config .local .cache", "rm -rf ~", "rm -rf /data", "rm -rf /data/"]) + assert.equal(permission(agent, "shell", command), "ask", `${agent.id} asks before ${command}`); + } const skills = (await request("/api/skill")).data; for (const id of ["using-superpowers", "brainstorming", "test-driven-development", "using-git-worktrees", "verification-before-completion"]) assert.ok(skills.some(skill => skill.id === id), `missing Superpowers skill ${id}`); diff --git a/tests/routing.test.mjs b/tests/routing.test.mjs index fd03567..514f359 100644 --- a/tests/routing.test.mjs +++ b/tests/routing.test.mjs @@ -48,7 +48,7 @@ test("existing providers, MCP integrations and quota are preserved", () => { }); test("agents ask before deleting server state under the home directory", async () => { - const asks = ["rm *.config*", "rm *.local*"] + const asks = ["rm *.config*", "rm *.local*", "rm *HOME*", "rm *~*", "rm */data", "rm */data/"] .map(resource => ({ action: "shell", resource, effect: "ask" })); for (const name of ["build", "general"]) assert.deepEqual(config.agents[name].permissions.slice(-asks.length), asks, `${name} ends with the state-deletion asks`); From 2653aeea848a0d4c090aa33da7208b051903b2e7 Mon Sep 17 00:00:00 2001 From: Nat Welch Date: Wed, 14 Oct 2026 14:16:41 +0000 Subject: [PATCH 5/6] chore: retrigger copilot review Co-Authored-By: Claude Opus 5.5 From f8ade2ef8eda699c396d96af4b6eee13ab31fe81 Mon Sep 17 00:00:00 2001 From: Nat Welch Date: Wed, 14 Oct 2026 14:19:48 +0000 Subject: [PATCH 6/6] fix: check /data itself and ask before rm of /data dotfiles The mount guard checked $HOME, which a caller can override while the XDG state paths stay hard-coded under /data. Build and General now also ask before rm commands containing /data/., which catches rm -rf /data/.*. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- entrypoint.sh | 4 ++-- opencode.jsonc | 6 ++++-- scripts/test-router-integration.mjs | 2 +- tests/routing.test.mjs | 2 +- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 64e761a..818bd11 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Grafana and Karakeep can connect before their credentials are validated; check a ## Agents and configuration -- **Build** is the default agent and can use all configured tools. **General** can implement and test; **Explore** is read-only. Both subagents may use Context7 but deny Grafana, Lunch Money, Karakeep, Notion, Robinhood, and further delegation. Build and General ask before `rm` commands whose text contains `.config`, `.local`, `HOME`, or `~`, or ends in `/data`. Tool permissions do not sandbox shell access. +- **Build** is the default agent and can use all configured tools. **General** can implement and test; **Explore** is read-only. Both subagents may use Context7 but deny Grafana, Lunch Money, Karakeep, Notion, Robinhood, and further delegation. Build and General ask before `rm` commands whose text contains `.config`, `.local`, `HOME`, `~`, or `/data/.`, or ends in `/data`. Tool permissions do not sandbox shell access. - [Superpowers](https://github.com/obra/superpowers) supplies planning, debugging, testing, and review skills. The image also ships [`commit-push-pr` and `go-repo`](skills/). [`AGENTS.md`](AGENTS.md) requires repository edits in `/data/workspace/.worktrees//`. - [OpenCode Quota](https://github.com/slkiser/opencode-quota) provides `/quota` and `/quota_status`. - Defaults and pinned plugins live in [`opencode.jsonc`](opencode.jsonc). The image converts it to `/etc/opencode/opencode.json`; project `opencode.json(c)` files can override settings. See the [V2 configuration guide](https://opencode.ai/v2/docs/config). diff --git a/entrypoint.sh b/entrypoint.sh index fd6f6e1..1308481 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -19,8 +19,8 @@ if [ "${1:-}" = openchamber ] && [ "${#ui_password}" -lt 32 ]; then exit 1 fi -if ! mountpoint -q "$HOME"; then - echo "$HOME must be a volume or bind mount; sessions, credentials, and logs written to the container layer are lost when it is recreated" >&2 +if ! mountpoint -q /data; then + echo "/data must be a volume or bind mount; sessions, credentials, and logs written to the container layer are lost when it is recreated" >&2 exit 1 fi diff --git a/opencode.jsonc b/opencode.jsonc index 567e81a..25f1a04 100644 --- a/opencode.jsonc +++ b/opencode.jsonc @@ -19,7 +19,8 @@ { "action": "shell", "resource": "rm *HOME*", "effect": "ask" }, { "action": "shell", "resource": "rm *~*", "effect": "ask" }, { "action": "shell", "resource": "rm */data", "effect": "ask" }, - { "action": "shell", "resource": "rm */data/", "effect": "ask" } + { "action": "shell", "resource": "rm */data/", "effect": "ask" }, + { "action": "shell", "resource": "rm */data/.*", "effect": "ask" } ] }, "general": { @@ -38,7 +39,8 @@ { "action": "shell", "resource": "rm *HOME*", "effect": "ask" }, { "action": "shell", "resource": "rm *~*", "effect": "ask" }, { "action": "shell", "resource": "rm */data", "effect": "ask" }, - { "action": "shell", "resource": "rm */data/", "effect": "ask" } + { "action": "shell", "resource": "rm */data/", "effect": "ask" }, + { "action": "shell", "resource": "rm */data/.*", "effect": "ask" } ] }, "explore": { diff --git a/scripts/test-router-integration.mjs b/scripts/test-router-integration.mjs index a4f9744..703ef7c 100644 --- a/scripts/test-router-integration.mjs +++ b/scripts/test-router-integration.mjs @@ -111,7 +111,7 @@ try { assert.equal(permission(general, action), "allow", `general can implement and test with ${action}`); for (const agent of [lead, general]) { assert.equal(permission(agent, "shell", "rm -rf node_modules"), "allow", `${agent.id} can delete project files`); - for (const command of ["rm -rf ~/.config", 'rm -rf "$HOME/.local/share/opencode"', 'rm -rf "$XDG_CONFIG_HOME"', "rm -rf .config .local .cache", "rm -rf ~", "rm -rf /data", "rm -rf /data/"]) + for (const command of ["rm -rf ~/.config", 'rm -rf "$HOME/.local/share/opencode"', 'rm -rf "$XDG_CONFIG_HOME"', "rm -rf .config .local .cache", "rm -rf ~", "rm -rf /data", "rm -rf /data/", "rm -rf /data/.*"]) assert.equal(permission(agent, "shell", command), "ask", `${agent.id} asks before ${command}`); } const skills = (await request("/api/skill")).data; diff --git a/tests/routing.test.mjs b/tests/routing.test.mjs index 514f359..d13746b 100644 --- a/tests/routing.test.mjs +++ b/tests/routing.test.mjs @@ -48,7 +48,7 @@ test("existing providers, MCP integrations and quota are preserved", () => { }); test("agents ask before deleting server state under the home directory", async () => { - const asks = ["rm *.config*", "rm *.local*", "rm *HOME*", "rm *~*", "rm */data", "rm */data/"] + const asks = ["rm *.config*", "rm *.local*", "rm *HOME*", "rm *~*", "rm */data", "rm */data/", "rm */data/.*"] .map(resource => ({ action: "shell", resource, effect: "ask" })); for (const name of ["build", "general"]) assert.deepEqual(config.agents[name].permissions.slice(-asks.length), asks, `${name} ends with the state-deletion asks`);