From 820299c68cc71bef10b277e5a820a9f30414ff08 Mon Sep 17 00:00:00 2001 From: Fityan <63894003+fityannugroho@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:04:56 +0700 Subject: [PATCH 01/17] fix: keep npm-style opencode shim layout inside sandbox --- opencodebox | 18 +++++++++++++++--- tests/conftest.py | 12 +++++++++--- tests/test_launcher.py | 25 ++++++++++++++++++++++++- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/opencodebox b/opencodebox index eea48ea..03a2112 100755 --- a/opencodebox +++ b/opencodebox @@ -287,9 +287,21 @@ CONFIG_DIR="$HOME/.config/opencode" AGENTS_DIR="$HOME/.agents" CACHE_DIR="$HOME/.cache/opencode" -# Locate opencode binary +# Locate the opencode launcher. npm-style installs (mise/aube, npm, pnpm, bun) +# put a shim in a node_modules/.bin directory that execs its target through a +# path relative to itself (e.g. "$basedir/../.mise/@opencode+cli@/..."). +# The shim's parent directory must therefore stay visible next to it. OPCODE_PATH=$(command -v opencode) OPCODE_DIR=$(dirname "$OPCODE_PATH") +OPCODE_BIND_FLAG="--bind" +OPCODE_ROOT="$OPCODE_DIR" +OPCODE_LAUNCHER="$(basename "$OPCODE_PATH")" +if [[ "$(basename "$OPCODE_DIR")" == ".bin" ]] && ! is_sensitive_path "$(dirname "$OPCODE_DIR")"; then + OPCODE_BIND_FLAG="--ro-bind" + OPCODE_ROOT="$(dirname "$OPCODE_DIR")" + OPCODE_LAUNCHER="$(basename "$OPCODE_DIR")/$(basename "$OPCODE_PATH")" +fi +OPCODE_SANDBOX_PATH="/opt/opencode/$OPCODE_LAUNCHER" # bwrap --bind hard-fails if the source is missing; opencode v2 keeps state under # ~/.local/state/opencode, which v1 users and fresh hosts may not have created yet. @@ -355,7 +367,7 @@ declare -a BWRAP_ARGS=( --bind "$APP_DATA" "$APP_DATA" # OpenCode data (read-write) # --- OpenCode Binary --- - --bind "$OPCODE_DIR" /opt/opencode + "$OPCODE_BIND_FLAG" "$OPCODE_ROOT" /opt/opencode ) # Private keyring (unmount if present) @@ -499,5 +511,5 @@ exec bwrap "${BWRAP_ARGS[@]}" -- \ if [ "'"$mise_installed"'" = true ]; then eval "$(mise activate bash)" fi - exec /opt/opencode/opencode "$@" + exec "'"$OPCODE_SANDBOX_PATH"'" "$@" ' -- "$@" diff --git a/tests/conftest.py b/tests/conftest.py index 8b60e50..6cec8ea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,11 +19,11 @@ class Launcher: - def __init__(self, root: Path): + def __init__(self, root: Path, bin_dir: Path | None = None): self.root = root self.home = root / "home" self.project = root / "project with spaces" - self.bin = root / "bin" + self.bin = bin_dir if bin_dir is not None else root / "bin" for directory in (self.home, self.project, self.bin): directory.mkdir(parents=True) self.capture = root / "bwrap.json" @@ -35,7 +35,7 @@ def __init__(self, root: Path): "CAPTURE": str(self.capture), "CALLS": str(self.calls), } - for name in ("bash", "cat", "dirname", "uname", "id", "stat", "realpath", "mkdir"): + for name in ("bash", "basename", "cat", "dirname", "uname", "id", "stat", "realpath", "mkdir"): target = shutil.which(name, path="/usr/bin:/bin") assert target is not None, name (self.bin / name).symlink_to(target) @@ -102,6 +102,12 @@ def launcher(tmp_path: Path) -> Launcher: return Launcher(tmp_path) +@pytest.fixture +def launcher_shim(tmp_path: Path) -> Launcher: + """Launcher whose opencode is an npm-style shim in node_modules/.bin.""" + return Launcher(tmp_path, bin_dir=tmp_path / "node_modules/.bin") + + def seqs(options: list, n: int) -> list: return [options[i:i + n] for i in range(len(options) - n + 1)] diff --git a/tests/test_launcher.py b/tests/test_launcher.py index a99ad6f..6b7eae9 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -1,7 +1,7 @@ """Smoke tests for current main behavior. All black-box: bwrap is mocked, argv is asserted. No sandbox ever runs here.""" -from conftest import has_seq, seqs +from conftest import Launcher, has_seq, seqs def test_help_exits_zero_without_bwrap(launcher): @@ -126,3 +126,26 @@ def test_ssh_permissions_enforced(launcher): ssh.chmod(0o700) options, _ = launcher.captured(launcher.run_launcher()) assert has_seq(options, ["--dir", str(ssh)]) + + +def test_shim_launcher_keeps_relative_layout(launcher_shim): + # npm-style installs exec their target relative to the shim, so the parent + # of node_modules/.bin must be mounted, not the .bin directory alone. + options, command = launcher_shim.captured(launcher_shim.run_launcher()) + assert has_seq(options, ["--ro-bind", str(launcher_shim.bin.parent), "/opt/opencode"]) + assert "/opt/opencode/.bin/opencode" in command[2] + + +def test_plain_bin_launcher_argv_unchanged(launcher): + # Non-.bin layouts must keep the pre-existing argv and exec target. + options, command = launcher.captured(launcher.run_launcher()) + assert has_seq(options, ["--bind", str(launcher.bin), "/opt/opencode"]) + assert "/opt/opencode/opencode" in command[2] + + +def test_shim_under_home_falls_back_without_binding_home(tmp_path): + # ~/.bin/opencode: parent is $HOME, which must never enter the sandbox. + launcher = Launcher(tmp_path, bin_dir=tmp_path / "home/.bin") + options, command = launcher.captured(launcher.run_launcher()) + assert has_seq(options, ["--bind", str(launcher.bin), "/opt/opencode"]) + assert "/opt/opencode/opencode" in command[2] From 6a511d491e123ae742ddb8efd8732d752bd05e6e Mon Sep 17 00:00:00 2001 From: Fityan <63894003+fityannugroho@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:33:15 +0700 Subject: [PATCH 02/17] fix: tighten .bin shim relocation and document the launcher mount --- README.md | 8 ++++++++ opencodebox | 20 ++++++++++++++------ tests/conftest.py | 2 +- tests/test_launcher.py | 40 +++++++++++++++++++++++++++++++++------- 4 files changed, 56 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 4e46ff5..4528087 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,14 @@ opencodebox --with /data --with-ro /config serve - `$HOME/.cache` - Universal cache - `$HOME/.local/share/keyrings` - Exclude private keyring (if exists on host) +### OpenCode Launcher Mount + +The directory holding the `opencode` binary is mounted at `/opt/opencode` and executed from there (read-write, as before). When `opencode` is an npm-style shim inside a `<...>/node_modules/.bin` directory, the sandbox instead mounts that `node_modules` directory **read-only** and executes the shim at `/opt/opencode/.bin/opencode`, because such shims resolve their target relative to their own location. + +- The shim must resolve its target with exactly one `..` level (as with mise's npm backend/aube). +- A shim outside `node_modules` (for example `$HOME/.bin`) is not relocated: it keeps the old mount, never binds `$HOME`, and prints a warning; expect `not found` (exit 127) for relative-target shims. +- Self-upgrading from inside the sandbox (`opencode upgrade`, plugin bootstrap into the install tree) is not supported for the read-only layout — upgrade on the host instead (e.g. `mise upgrade opencode`). + ### Conditional Tool Mounts (Requires Tool Installed on Host) Each tool is mounted only when `command -v ` succeeds on the host. If the tool is not installed, none of its directories are bound into the sandbox. diff --git a/opencodebox b/opencodebox index 03a2112..042eec4 100755 --- a/opencodebox +++ b/opencodebox @@ -292,14 +292,22 @@ CACHE_DIR="$HOME/.cache/opencode" # path relative to itself (e.g. "$basedir/../.mise/@opencode+cli@/..."). # The shim's parent directory must therefore stay visible next to it. OPCODE_PATH=$(command -v opencode) -OPCODE_DIR=$(dirname "$OPCODE_PATH") +# Lexical normalisation only (symlinks stay unresolved): PATH entries with "." +# or ".." must not change what gets mounted or slip past the guard below. +OPCODE_DIR="$(realpath -ms "$OPCODE_PATH" 2>/dev/null || printf '%s' "$OPCODE_PATH")" +OPCODE_DIR="${OPCODE_DIR%/*}" +OPCODE_PARENT="${OPCODE_DIR%/*}" OPCODE_BIND_FLAG="--bind" OPCODE_ROOT="$OPCODE_DIR" -OPCODE_LAUNCHER="$(basename "$OPCODE_PATH")" -if [[ "$(basename "$OPCODE_DIR")" == ".bin" ]] && ! is_sensitive_path "$(dirname "$OPCODE_DIR")"; then - OPCODE_BIND_FLAG="--ro-bind" - OPCODE_ROOT="$(dirname "$OPCODE_DIR")" - OPCODE_LAUNCHER="$(basename "$OPCODE_DIR")/$(basename "$OPCODE_PATH")" +OPCODE_LAUNCHER="${OPCODE_PATH##*/}" +if [[ "${OPCODE_DIR##*/}" == ".bin" ]]; then + if [[ "${OPCODE_PARENT##*/}" == "node_modules" ]] && ! is_sensitive_path "$OPCODE_PARENT"; then + OPCODE_BIND_FLAG="--ro-bind" + OPCODE_ROOT="$OPCODE_PARENT" + OPCODE_LAUNCHER="${OPCODE_DIR##*/}/${OPCODE_PATH##*/}" + else + echo "Warning: $OPCODE_PATH looks like an npm-style shim, but ${OPCODE_PARENT} is not mounted (not node_modules, or sensitive). Unless the shim is self-contained, it will fail with 'not found' (exit 127)." >&2 + fi fi OPCODE_SANDBOX_PATH="/opt/opencode/$OPCODE_LAUNCHER" diff --git a/tests/conftest.py b/tests/conftest.py index 6cec8ea..2c80504 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,7 +35,7 @@ def __init__(self, root: Path, bin_dir: Path | None = None): "CAPTURE": str(self.capture), "CALLS": str(self.calls), } - for name in ("bash", "basename", "cat", "dirname", "uname", "id", "stat", "realpath", "mkdir"): + for name in ("bash", "cat", "dirname", "uname", "id", "stat", "realpath", "mkdir"): target = shutil.which(name, path="/usr/bin:/bin") assert target is not None, name (self.bin / name).symlink_to(target) diff --git a/tests/test_launcher.py b/tests/test_launcher.py index 6b7eae9..2a7535f 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -133,19 +133,45 @@ def test_shim_launcher_keeps_relative_layout(launcher_shim): # of node_modules/.bin must be mounted, not the .bin directory alone. options, command = launcher_shim.captured(launcher_shim.run_launcher()) assert has_seq(options, ["--ro-bind", str(launcher_shim.bin.parent), "/opt/opencode"]) - assert "/opt/opencode/.bin/opencode" in command[2] + assert 'exec "/opt/opencode/.bin/opencode" "$@"' in command[2] -def test_plain_bin_launcher_argv_unchanged(launcher): - # Non-.bin layouts must keep the pre-existing argv and exec target. +def test_plain_bin_launcher_mount_and_target_unchanged(launcher): + # Non-.bin layouts must keep the pre-existing mount and exec target. options, command = launcher.captured(launcher.run_launcher()) assert has_seq(options, ["--bind", str(launcher.bin), "/opt/opencode"]) - assert "/opt/opencode/opencode" in command[2] + assert 'exec "/opt/opencode/opencode" "$@"' in command[2] def test_shim_under_home_falls_back_without_binding_home(tmp_path): - # ~/.bin/opencode: parent is $HOME, which must never enter the sandbox. + # $HOME/.bin/opencode: $HOME must never enter the sandbox, so the shim is + # not relocated and the user gets a warning instead. launcher = Launcher(tmp_path, bin_dir=tmp_path / "home/.bin") - options, command = launcher.captured(launcher.run_launcher()) + result = launcher.run_launcher() + options, command = launcher.captured(result) + assert has_seq(options, ["--bind", str(launcher.bin), "/opt/opencode"]) + assert 'exec "/opt/opencode/opencode" "$@"' in command[2] + assert "Warning" in result.stderr + + +def test_non_node_modules_bin_falls_back(tmp_path): + # The relocation is limited to the npm convention; any other ".bin" + # directory must not pull its parent into the sandbox. + launcher = Launcher(tmp_path, bin_dir=tmp_path / "vendor/.bin") + result = launcher.run_launcher() + options, command = launcher.captured(result) assert has_seq(options, ["--bind", str(launcher.bin), "/opt/opencode"]) - assert "/opt/opencode/opencode" in command[2] + assert 'exec "/opt/opencode/opencode" "$@"' in command[2] + assert "Warning" in result.stderr + + +def test_dot_dot_path_normalised_before_guard(tmp_path): + # A PATH entry containing ".." must not change what gets mounted: the path + # is normalised before the layout checks, and the fallback binds the + # normalised directory (here the (fake) $HOME/.bin itself). + launcher = Launcher(tmp_path, bin_dir=tmp_path / "home/.local/../.bin") + result = launcher.run_launcher() + options, command = launcher.captured(result) + assert has_seq(options, ["--bind", str(tmp_path / "home/.bin"), "/opt/opencode"]) + assert 'exec "/opt/opencode/opencode" "$@"' in command[2] + assert "Warning" in result.stderr From 18f1536bca67501c8b6fe06ed15e448eca92c9d6 Mon Sep 17 00:00:00 2001 From: Fityan <63894003+fityannugroho@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:41:47 +0700 Subject: [PATCH 03/17] fix: support fresh hosts and opencode v2 version output --- README.md | 3 +++ opencodebox | 10 +++++++++- tests/test_launcher.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4528087..cdf9c3a 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,9 @@ The directory holding the `opencode` binary is mounted at `/opt/opencode` and ex - The shim must resolve its target with exactly one `..` level (as with mise's npm backend/aube). - A shim outside `node_modules` (for example `$HOME/.bin`) is not relocated: it keeps the old mount, never binds `$HOME`, and prints a warning; expect `not found` (exit 127) for relative-target shims. - Self-upgrading from inside the sandbox (`opencode upgrade`, plugin bootstrap into the install tree) is not supported for the read-only layout — upgrade on the host instead (e.g. `mise upgrade opencode`). +- On first run the launcher creates opencode's config, agents, cache (including the helper `bin` directory) and data directories when they are missing, so a fresh host does not fail at the bind step. +- Only `node_modules/.bin` shims are relocated. Global `bin` shims from `npm|pnpm|bun install -g` and symlinks whose target lies outside the mounted roots still fail with `not found` (exit 127). +- OpenCode 2 runs a shared background service on a fixed local port (default `49374`). The sandbox shares the host network namespace, so service-backed commands fail while a host service is running (`Managed service port 49374 on 127.0.0.1 is already in use by another process`); use `--standalone` or stop/disable the host service. The interactive TUI is expected to hit the same limitation (not tested here). ### Conditional Tool Mounts (Requires Tool Installed on Host) diff --git a/opencodebox b/opencodebox index 042eec4..41fbdb6 100755 --- a/opencodebox +++ b/opencodebox @@ -9,7 +9,10 @@ VERSION="0.4.4" print_version() { - echo "opencode v$(opencode --version)" + local opencode_version + opencode_version="$(opencode --version)" + # opencode v1 prints a bare version; v2 prints "opencode v". + echo "opencode v${opencode_version#opencode v}" echo "opencodebox v$VERSION" exit 0 } @@ -315,6 +318,11 @@ OPCODE_SANDBOX_PATH="/opt/opencode/$OPCODE_LAUNCHER" # ~/.local/state/opencode, which v1 users and fresh hosts may not have created yet. mkdir -p "$HOME/.local/state/opencode" +# A fresh host has none of opencode's directories: bwrap --ro-bind/--bind fails +# on missing sources, and opencode (v1 and v2) mkdirs ~/.cache/opencode/bin at +# startup even though the cache tree is mounted read-only. +mkdir -p "$CONFIG_DIR" "$AGENTS_DIR" "$CACHE_DIR/bin" "$APP_DATA" + # Later bindings override earlier ones (--with binds come last = highest priority) declare -a BWRAP_ARGS=( # --- Isolation & Namespaces --- diff --git a/tests/test_launcher.py b/tests/test_launcher.py index 2a7535f..584ad49 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -175,3 +175,34 @@ def test_dot_dot_path_normalised_before_guard(tmp_path): assert has_seq(options, ["--bind", str(tmp_path / "home/.bin"), "/opt/opencode"]) assert 'exec "/opt/opencode/opencode" "$@"' in command[2] assert "Warning" in result.stderr + + +def test_missing_opencode_dirs_created_before_bind(launcher): + # bwrap hard-fails on missing bind sources, and opencode (v1 and v2) + # mkdirs ~/.cache/opencode/bin at startup while the cache tree is read-only. + dirs = ( + launcher.home / ".config/opencode", + launcher.home / ".agents", + launcher.home / ".cache/opencode/bin", + launcher.home / ".local/share/opencode", + ) + for directory in dirs: + assert not directory.exists() + launcher.captured(launcher.run_launcher()) + for directory in dirs: + assert directory.is_dir(), f"launcher must create {directory}" + + +def test_version_handles_v2_prefixed_output(launcher): + # opencode v2 prints "opencode v"; v1 prints a bare version. + launcher.command("opencode", """ +if sys.argv[1:] == ['--version']: + print('opencode v9.9.9-test') +else: + raise SystemExit('opencode must not execute') +""") + result = launcher.run_launcher("--version") + assert result.returncode == 0, result.stderr + lines = result.stdout.splitlines() + assert lines[0] == "opencode v9.9.9-test" + assert lines[1].startswith("opencodebox v") From 564ebc1f5fae893d303dceab45fbc098824e1cd4 Mon Sep 17 00:00:00 2001 From: Fityan <63894003+fityannugroho@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:44:42 +0700 Subject: [PATCH 04/17] docs: correct read-only plugin claims and pin v1 version output --- README.md | 2 +- tests/test_launcher.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cdf9c3a..f054e31 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ The directory holding the `opencode` binary is mounted at `/opt/opencode` and ex - The shim must resolve its target with exactly one `..` level (as with mise's npm backend/aube). - A shim outside `node_modules` (for example `$HOME/.bin`) is not relocated: it keeps the old mount, never binds `$HOME`, and prints a warning; expect `not found` (exit 127) for relative-target shims. -- Self-upgrading from inside the sandbox (`opencode upgrade`, plugin bootstrap into the install tree) is not supported for the read-only layout — upgrade on the host instead (e.g. `mise upgrade opencode`). +- Self-upgrading or installing plugins from inside the sandbox is not supported for the read-only layout: `opencode upgrade` writes to the install tree, and `opencode plugin add` updates the global configuration under `~/.config/opencode` (mounted read-only) — do both on the host instead (e.g. `mise upgrade opencode`). - On first run the launcher creates opencode's config, agents, cache (including the helper `bin` directory) and data directories when they are missing, so a fresh host does not fail at the bind step. - Only `node_modules/.bin` shims are relocated. Global `bin` shims from `npm|pnpm|bun install -g` and symlinks whose target lies outside the mounted roots still fail with `not found` (exit 127). - OpenCode 2 runs a shared background service on a fixed local port (default `49374`). The sandbox shares the host network namespace, so service-backed commands fail while a host service is running (`Managed service port 49374 on 127.0.0.1 is already in use by another process`); use `--standalone` or stop/disable the host service. The interactive TUI is expected to hit the same limitation (not tested here). diff --git a/tests/test_launcher.py b/tests/test_launcher.py index 584ad49..d51161c 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -17,6 +17,7 @@ def test_version_reports_opencodebox(launcher): result = launcher.run_launcher(*args) assert result.returncode == 0, result.stderr assert "opencodebox v" in result.stdout + assert result.stdout.splitlines()[0] == "opencode v9.9.9-test" def test_unconditional_mounts(launcher): From ec4e05debbebfde528310a1659d8e3e38413b076 Mon Sep 17 00:00:00 2001 From: Fityan <63894003+fityannugroho@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:54:32 +0700 Subject: [PATCH 05/17] docs: scope fresh-host and --standalone guidance --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f054e31..5503af8 100644 --- a/README.md +++ b/README.md @@ -122,9 +122,9 @@ The directory holding the `opencode` binary is mounted at `/opt/opencode` and ex - The shim must resolve its target with exactly one `..` level (as with mise's npm backend/aube). - A shim outside `node_modules` (for example `$HOME/.bin`) is not relocated: it keeps the old mount, never binds `$HOME`, and prints a warning; expect `not found` (exit 127) for relative-target shims. - Self-upgrading or installing plugins from inside the sandbox is not supported for the read-only layout: `opencode upgrade` writes to the install tree, and `opencode plugin add` updates the global configuration under `~/.config/opencode` (mounted read-only) — do both on the host instead (e.g. `mise upgrade opencode`). -- On first run the launcher creates opencode's config, agents, cache (including the helper `bin` directory) and data directories when they are missing, so a fresh host does not fail at the bind step. +- On first run the launcher creates opencode's config, agents, cache (including the helper `bin` directory) and data directories when they are missing, so a fresh host does not fail at the bind step. OpenCode 2 starts from there; OpenCode 1 may still need its config seeded on the host (it writes `.gitignore` into `~/.config/opencode`, which is mounted read-only). - Only `node_modules/.bin` shims are relocated. Global `bin` shims from `npm|pnpm|bun install -g` and symlinks whose target lies outside the mounted roots still fail with `not found` (exit 127). -- OpenCode 2 runs a shared background service on a fixed local port (default `49374`). The sandbox shares the host network namespace, so service-backed commands fail while a host service is running (`Managed service port 49374 on 127.0.0.1 is already in use by another process`); use `--standalone` or stop/disable the host service. The interactive TUI is expected to hit the same limitation (not tested here). +- OpenCode 2 runs a shared background service on a fixed local port (default `49374`). The sandbox shares the host network namespace, so service-backed commands fail while a host service is running (`Managed service port 49374 on 127.0.0.1 is already in use by another process`); use `--standalone` where the subcommand supports it (e.g. `opencode models --standalone`); otherwise stop or disable the host service. The interactive TUI is expected to hit the same limitation (not tested here). ### Conditional Tool Mounts (Requires Tool Installed on Host) From a017ce06654bd9e930a0ed9a634cb88ffef3bd18 Mon Sep 17 00:00:00 2001 From: Fityan <63894003+fityannugroho@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:30:26 +0700 Subject: [PATCH 06/17] docs: re-scope the OpenCode 2 service guidance --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5503af8..ac1bfd9 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ The directory holding the `opencode` binary is mounted at `/opt/opencode` and ex - Self-upgrading or installing plugins from inside the sandbox is not supported for the read-only layout: `opencode upgrade` writes to the install tree, and `opencode plugin add` updates the global configuration under `~/.config/opencode` (mounted read-only) — do both on the host instead (e.g. `mise upgrade opencode`). - On first run the launcher creates opencode's config, agents, cache (including the helper `bin` directory) and data directories when they are missing, so a fresh host does not fail at the bind step. OpenCode 2 starts from there; OpenCode 1 may still need its config seeded on the host (it writes `.gitignore` into `~/.config/opencode`, which is mounted read-only). - Only `node_modules/.bin` shims are relocated. Global `bin` shims from `npm|pnpm|bun install -g` and symlinks whose target lies outside the mounted roots still fail with `not found` (exit 127). -- OpenCode 2 runs a shared background service on a fixed local port (default `49374`). The sandbox shares the host network namespace, so service-backed commands fail while a host service is running (`Managed service port 49374 on 127.0.0.1 is already in use by another process`); use `--standalone` where the subcommand supports it (e.g. `opencode models --standalone`); otherwise stop or disable the host service. The interactive TUI is expected to hit the same limitation (not tested here). +- OpenCode 2 is client-server. With the normal HOME the sandboxed client reads `~/.local/state/opencode/service.json`, verifies it over loopback and attaches to the host's shared service, so service-backed commands run without extra flags — but the server, and therefore tool execution, then runs outside the sandbox. To keep the server inside the sandbox, pass `--standalone` after the subcommand where it is supported (`opencode models --standalone`), or disable the shared service from outside opencodebox with `opencode service set disabled true` (it also stops the shared service and disables `pair`; `~/.config/opencode` is read-only inside the sandbox). Commands that do not accept `--standalone` (`mcp list`, `plugin list/…`, `debug agents/config`) need the disable flag. A client that cannot reuse the registration (a different HOME, or a version mismatch on the TUI path) tries to start its own service on the fixed port (default `49374`) and fails with `Managed service port 49374 on 127.0.0.1 is already in use by another process` while a host service is running. ### Conditional Tool Mounts (Requires Tool Installed on Host) From ede625cea872e93be5e63085d33185d24623d22c Mon Sep 17 00:00:00 2001 From: Fityan <63894003+fityannugroho@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:01:56 +0700 Subject: [PATCH 07/17] docs: describe in-sandbox spawn fallback and TUI --standalone --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ac1bfd9..2bd4065 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ The directory holding the `opencode` binary is mounted at `/opt/opencode` and ex - Self-upgrading or installing plugins from inside the sandbox is not supported for the read-only layout: `opencode upgrade` writes to the install tree, and `opencode plugin add` updates the global configuration under `~/.config/opencode` (mounted read-only) — do both on the host instead (e.g. `mise upgrade opencode`). - On first run the launcher creates opencode's config, agents, cache (including the helper `bin` directory) and data directories when they are missing, so a fresh host does not fail at the bind step. OpenCode 2 starts from there; OpenCode 1 may still need its config seeded on the host (it writes `.gitignore` into `~/.config/opencode`, which is mounted read-only). - Only `node_modules/.bin` shims are relocated. Global `bin` shims from `npm|pnpm|bun install -g` and symlinks whose target lies outside the mounted roots still fail with `not found` (exit 127). -- OpenCode 2 is client-server. With the normal HOME the sandboxed client reads `~/.local/state/opencode/service.json`, verifies it over loopback and attaches to the host's shared service, so service-backed commands run without extra flags — but the server, and therefore tool execution, then runs outside the sandbox. To keep the server inside the sandbox, pass `--standalone` after the subcommand where it is supported (`opencode models --standalone`), or disable the shared service from outside opencodebox with `opencode service set disabled true` (it also stops the shared service and disables `pair`; `~/.config/opencode` is read-only inside the sandbox). Commands that do not accept `--standalone` (`mcp list`, `plugin list/…`, `debug agents/config`) need the disable flag. A client that cannot reuse the registration (a different HOME, or a version mismatch on the TUI path) tries to start its own service on the fixed port (default `49374`) and fails with `Managed service port 49374 on 127.0.0.1 is already in use by another process` while a host service is running. +- OpenCode 2 is client-server. With the normal HOME the sandboxed client reads `~/.local/state/opencode/service.json`: if a compatible host service is already running it reuses it over loopback and attaches, so service-backed commands run without extra flags - but the server, and therefore tool execution, then runs outside the sandbox. If no host service is running, the sandboxed client instead starts its own `serve --service` inside the sandbox (it binds 127.0.0.1:49374 only while the run lasts and dies with the sandbox), so tool execution stays confined. A client that cannot reuse an existing registration (a different HOME, or a version mismatch on the TUI path) while a host service is already up fails with `Managed service port 49374 on 127.0.0.1 is already in use`. To always keep the server inside the sandbox, run with a private server: `opencodebox --standalone` for the TUI, or `--standalone` after the subcommand where it is supported (`opencodebox models --standalone`); alternatively disable the shared service from outside opencodebox with `opencode service set disabled true` (it also stops the shared service and disables `pair`; `~/.config/opencode` is read-only inside the sandbox). Commands that do not accept `--standalone` (`mcp list`, `plugin list/...`, `debug agents/config`) need the disable flag. ### Conditional Tool Mounts (Requires Tool Installed on Host) From b634eefd566b16995f3dfa62afa275558eb5e6c2 Mon Sep 17 00:00:00 2001 From: Fityan <63894003+fityannugroho@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:48:15 +0700 Subject: [PATCH 08/17] feat: enforce in-sandbox OpenCode 2 server --- opencodebox | 93 +++++++++++++++++++++++++++- tests/test_launcher.py | 137 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 225 insertions(+), 5 deletions(-) diff --git a/opencodebox b/opencodebox index 41fbdb6..5f85aac 100755 --- a/opencodebox +++ b/opencodebox @@ -30,6 +30,13 @@ Options: --version, -v Show version information --help, -h Show this help message +Isolation: + OpenCode 2 always runs fully isolated: every command gets a private + server inside the sandbox. 'pair', 'service', 'serve --service', + 'serve --stdio', '--server', 'upgrade' and 'uninstall' are refused; + run them with 'opencode' on the host instead (web UI: 'opencodebox + serve --port N', then open the printed URL in the host browser). + Examples: opencodebox --with ../app-backend opencodebox --with /mnt/data:/workspace/data @@ -38,7 +45,6 @@ Examples: EOF exit 0 } - # Helper to check if a path is sensitive (or an ancestor of one) is_sensitive_path() { local path="$1" @@ -276,6 +282,54 @@ exec {GROUP_FD}< <(printf '%s:x:%s:\n' "$GROUP_NAME" "$GROUP_ID") # Restore remaining args for opencode set -- "${REMAINING_ARGS[@]}" +# OpenCode v2 isolation: the server must always run inside the sandbox. +# Refuse commands that would attach to, manage, or register a host service. +_iso_subcommand="" +_iso_skip_next=0 +_iso_serve_service=0 +_iso_serve_stdio=0 +for _iso_tok in "$@"; do + if [[ $_iso_skip_next -eq 1 ]]; then _iso_skip_next=0; continue; fi + case "$_iso_tok" in + --) break ;; + --server|--server=*) + echo "Error: '--server' cannot run in isolation: it connects to a server outside the sandbox." >&2 + exit 1 ;; + --session|--prompt|-s) _iso_skip_next=1 ;; + --service|--service=*) _iso_serve_service=1 ;; + --stdio|--stdio=*) _iso_serve_stdio=1 ;; + -*) ;; + *) + if [[ -z "$_iso_subcommand" ]]; then + case "$_iso_tok" in + upgrade|update|uninstall|acp|api|debug|auth|mcp|plugin|models|stats|mini|run|session|service|reload|pair|serve) + _iso_subcommand="$_iso_tok" ;; + esac + fi ;; + esac +done +unset _iso_tok +case "$_iso_subcommand" in + pair) + echo "Error: 'pair' cannot run in isolation: it requires the background service. Use 'opencodebox serve --port N' and open the printed URL in your browser instead." >&2 + exit 1 ;; + service) + echo "Error: 'service' cannot run in isolation: it manages the host background service. Run 'opencode service ...' on the host instead." >&2 + exit 1 ;; + upgrade|update|uninstall) + echo "Error: '$_iso_subcommand' cannot run in isolation: it modifies the host installation. Run 'opencode $_iso_subcommand' on the host instead." >&2 + exit 1 ;; + serve) + if [[ $_iso_serve_service -eq 1 ]]; then + echo "Error: 'serve --service' cannot run in isolation: it registers a background service that would die with the sandbox. Drop '--service' to run the server in the foreground." >&2 + exit 1 + fi + if [[ $_iso_serve_stdio -eq 1 ]]; then + echo "Error: 'serve --stdio' cannot run in isolation: it is an internal server mode. Drop '--stdio' to run the server in the foreground." >&2 + exit 1 + fi ;; +esac +unset _iso_subcommand _iso_skip_next _iso_serve_service _iso_serve_stdio # Prevent running from sensitive directories (but not for --help/--version which already exited) if is_sensitive_path "$PROJECT_DIR"; then @@ -286,7 +340,18 @@ fi # Define paths for persistent data storage APP_DATA="$HOME/.local/share/opencode" -CONFIG_DIR="$HOME/.config/opencode" +if [[ -n "${OPENCODE_CONFIG_DIR:-}" ]]; then + CONFIG_DIR="$OPENCODE_CONFIG_DIR" +elif [[ -n "${XDG_CONFIG_HOME:-}" ]]; then + CONFIG_DIR="$XDG_CONFIG_HOME/opencode" +else + CONFIG_DIR="$HOME/.config/opencode" +fi +CONFIG_DIR="$(realpath -ms "$CONFIG_DIR")" +if is_sensitive_path "$CONFIG_DIR"; then + echo "Error: service config directory '$CONFIG_DIR' points to or encloses a sensitive location." >&2 + exit 1 +fi AGENTS_DIR="$HOME/.agents" CACHE_DIR="$HOME/.cache/opencode" @@ -322,6 +387,12 @@ mkdir -p "$HOME/.local/state/opencode" # on missing sources, and opencode (v1 and v2) mkdirs ~/.cache/opencode/bin at # startup even though the cache tree is mounted read-only. mkdir -p "$CONFIG_DIR" "$AGENTS_DIR" "$CACHE_DIR/bin" "$APP_DATA" +for _iso_seed in service.json service-local.json; do + if [[ ! -e "$CONFIG_DIR/$_iso_seed" ]]; then + printf '{}\n' > "$CONFIG_DIR/$_iso_seed" + fi +done +unset _iso_seed # Later bindings override earlier ones (--with binds come last = highest priority) declare -a BWRAP_ARGS=( @@ -519,6 +590,24 @@ fi # Add extra binds from --with/--with-ro last so user choices have highest priority. BWRAP_ARGS+=("${EXTRA_BINDS[@]}") +# OpenCode v2 isolation: force a private in-sandbox server for every command. +# Later binds win, so the overlay is appended after EXTRA_BINDS. +ISOD_OVERLAY_DIR="$HOME/.local/share/opencodebox" +ISOD_OVERLAY_SRC="$ISOD_OVERLAY_DIR/disabled-service.json" +mkdir -p "$ISOD_OVERLAY_DIR" +printf '{\n "disabled": true\n}\n' > "$ISOD_OVERLAY_SRC" +for _iso_name in service.json service-local.json; do + BWRAP_ARGS+=(--ro-bind "$ISOD_OVERLAY_SRC" "$CONFIG_DIR/$_iso_name") +done +shopt -s nullglob +for _iso_existing in "$CONFIG_DIR"/service-*.json; do + case "${_iso_existing##*/}" in + service.json|service-local.json) ;; + *) BWRAP_ARGS+=(--ro-bind "$ISOD_OVERLAY_SRC" "$_iso_existing") ;; + esac +done +shopt -u nullglob +unset _iso_name _iso_existing # Execute bwrap with all arguments and the target command # mise activation runs inside sandbox only if host has mise installed diff --git a/tests/test_launcher.py b/tests/test_launcher.py index d51161c..34a26b3 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -1,6 +1,8 @@ """Smoke tests for current main behavior. All black-box: bwrap is mocked, argv is asserted. No sandbox ever runs here.""" +import pytest + from conftest import Launcher, has_seq, seqs @@ -85,12 +87,19 @@ def test_with_overrides_tmpfs_last_wins(launcher): def test_with_ro_custom_destination_is_last(launcher): - # EXTRA_BINDS is appended after --seccomp in the launcher, so user binds - # always form the tail regardless of conditional mounts. + # EXTRA_BINDS is appended after --seccomp, but the v2 isolation overlay + # binds come after EXTRA_BINDS (later binds win), so the overlay forms + # the tail and user binds sit immediately before it. extra = launcher.root / "extra" extra.mkdir() options, _ = launcher.captured(launcher.run_launcher("--with-ro", f"{extra}:/custom/path")) - assert options[-3:] == ["--ro-bind", str(extra), "/custom/path"] + overlay_src = str(launcher.home / ".local/share/opencodebox/disabled-service.json") + config = str(launcher.home / ".config/opencode") + tail = ["--ro-bind", overlay_src, f"{config}/service.json", + "--ro-bind", overlay_src, f"{config}/service-local.json"] + assert options[-len(tail):] == tail + triples = seqs(options, 3) + assert triples.index(["--ro-bind", str(extra), "/custom/path"]) < len(options) - len(tail) def test_seccomp_fd_forwarded_when_filter_present(launcher): @@ -207,3 +216,125 @@ def test_version_handles_v2_prefixed_output(launcher): lines = result.stdout.splitlines() assert lines[0] == "opencode v9.9.9-test" assert lines[1].startswith("opencodebox v") + + +def test_v2_overlay_forces_disabled_service(launcher): + options, _ = launcher.captured(launcher.run_launcher()) + overlay_src = str(launcher.home / ".local/share/opencodebox/disabled-service.json") + config = str(launcher.home / ".config/opencode") + assert has_seq(options, ["--ro-bind", overlay_src, f"{config}/service.json"]) + assert has_seq(options, ["--ro-bind", overlay_src, f"{config}/service-local.json"]) + overlay_path = launcher.home / ".local/share/opencodebox/disabled-service.json" + assert overlay_path.read_text() == '{\n "disabled": true\n}\n' + triples = seqs(options, 3) + config_ro = [s for s in triples if s == ["--ro-bind", config, config]] + assert config_ro, "config ro-bind must exist" + assert triples.index(["--ro-bind", overlay_src, f"{config}/service.json"]) > triples.index(config_ro[0]) + assert triples.index(["--ro-bind", overlay_src, f"{config}/service-local.json"]) > triples.index(config_ro[0]) + + +def test_v2_overlay_after_extra_bind(launcher): + extra = launcher.root / "extra" + extra.mkdir() + options, _ = launcher.captured(launcher.run_launcher("--with-ro", f"{extra}:/custom/path")) + overlay_src = str(launcher.home / ".local/share/opencodebox/disabled-service.json") + config = str(launcher.home / ".config/opencode") + triples = seqs(options, 3) + extra_idx = triples.index(["--ro-bind", str(extra), "/custom/path"]) + assert triples.index(["--ro-bind", overlay_src, f"{config}/service.json"]) > extra_idx + assert triples.index(["--ro-bind", overlay_src, f"{config}/service-local.json"]) > extra_idx + + +def test_overlay_seeds_missing_service_config(launcher): + config = launcher.home / ".config/opencode" + launcher.captured(launcher.run_launcher()) + for name in ("service.json", "service-local.json"): + path = config / name + assert path.exists(), f"launcher must seed missing {name}" + assert path.read_text() == '{}\n' + + +def test_overlay_preserves_existing_service_config(launcher): + config = launcher.home / ".config/opencode" + config.mkdir(parents=True) + (config / "service.json").write_text('{"password": "pw"}') + options, _ = launcher.captured(launcher.run_launcher()) + assert (config / "service.json").read_text() == '{"password": "pw"}' + overlay_src = str(launcher.home / ".local/share/opencodebox/disabled-service.json") + assert has_seq(options, ["--ro-bind", overlay_src, f"{config}/service.json"]) + + +@pytest.mark.parametrize("args", [ + ("pair",), + ("pair", "--url", "http://127.0.0.1:4097"), + ("pair", "--remote"), + ("service", "status"), + ("service", "start"), + ("upgrade",), + ("update",), + ("uninstall",), + ("serve", "--service"), + ("serve", "--stdio"), + ("--server", "http://127.0.0.1:1"), + ("models", "--server", "http://127.0.0.1:1"), + ("run", "--title", "--server"), +]) +def test_isolation_refusals(launcher, args): + result = launcher.run_launcher(*args) + assert result.returncode == 1 + assert "cannot run in isolation" in result.stderr + assert not launcher.capture.exists() + + +@pytest.mark.parametrize("args", [ + (), + ("run", "hi"), + ("models",), + ("models", "--standalone"), + ("mcp", "list"), + ("plugin", "list"), + ("debug", "agents"), + ("acp",), + ("serve", "--port", "4097"), +]) +def test_isolation_allows_server_commands(launcher, args): + _, command = launcher.captured(launcher.run_launcher(*args)) + assert command[3:] == ["--", *args] + + +def test_refusal_scanner_flag_values(launcher): + _, command = launcher.captured(launcher.run_launcher("--prompt", "serve")) + assert command[3:] == ["--", "--prompt", "serve"] + result = launcher.run_launcher("--session", "x", "pair") + assert result.returncode == 1 + assert "cannot run in isolation" in result.stderr + assert not launcher.capture.exists() + _, command = launcher.captured(launcher.run_launcher("mcp", "add", "f", "--", "--server", "x")) + assert command[3:] == ["--", "mcp", "add", "f", "--", "--server", "x"] + + +def test_config_dir_precedence(launcher): + import tempfile + with tempfile.TemporaryDirectory(dir=str(launcher.root)) as tmp: + launcher.env["XDG_CONFIG_HOME"] = tmp + options, _ = launcher.captured(launcher.run_launcher()) + overlay_src = str(launcher.home / ".local/share/opencodebox/disabled-service.json") + assert has_seq(options, ["--ro-bind", f"{tmp}/opencode", f"{tmp}/opencode"]) + assert has_seq(options, ["--ro-bind", overlay_src, f"{tmp}/opencode/service.json"]) + with tempfile.TemporaryDirectory(dir=str(launcher.root)) as tmp2: + launcher.env["XDG_CONFIG_HOME"] = tmp2 + launcher.env["OPENCODE_CONFIG_DIR"] = tmp2 + "/custom" + options, _ = launcher.captured(launcher.run_launcher()) + overlay_src = str(launcher.home / ".local/share/opencodebox/disabled-service.json") + assert has_seq(options, ["--ro-bind", overlay_src, f"{tmp2}/custom/service.json"]) + launcher.env.pop("XDG_CONFIG_HOME", None) + launcher.env["OPENCODE_CONFIG_DIR"] = str(launcher.home) + result = launcher.run_launcher() + assert result.returncode == 1 + assert "sensitive" in result.stderr + + +def test_help_mentions_isolation(launcher): + result = launcher.run_launcher("--help") + assert result.returncode == 0, result.stderr + assert "Isolation:" in result.stdout From cc5c4b3b31815a970569d4e4835cad4383135818 Mon Sep 17 00:00:00 2001 From: Fityan <63894003+fityannugroho@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:48:15 +0700 Subject: [PATCH 09/17] docs: describe enforced v2 isolation --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2bd4065..dee1934 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ The directory holding the `opencode` binary is mounted at `/opt/opencode` and ex - Self-upgrading or installing plugins from inside the sandbox is not supported for the read-only layout: `opencode upgrade` writes to the install tree, and `opencode plugin add` updates the global configuration under `~/.config/opencode` (mounted read-only) — do both on the host instead (e.g. `mise upgrade opencode`). - On first run the launcher creates opencode's config, agents, cache (including the helper `bin` directory) and data directories when they are missing, so a fresh host does not fail at the bind step. OpenCode 2 starts from there; OpenCode 1 may still need its config seeded on the host (it writes `.gitignore` into `~/.config/opencode`, which is mounted read-only). - Only `node_modules/.bin` shims are relocated. Global `bin` shims from `npm|pnpm|bun install -g` and symlinks whose target lies outside the mounted roots still fail with `not found` (exit 127). -- OpenCode 2 is client-server. With the normal HOME the sandboxed client reads `~/.local/state/opencode/service.json`: if a compatible host service is already running it reuses it over loopback and attaches, so service-backed commands run without extra flags - but the server, and therefore tool execution, then runs outside the sandbox. If no host service is running, the sandboxed client instead starts its own `serve --service` inside the sandbox (it binds 127.0.0.1:49374 only while the run lasts and dies with the sandbox), so tool execution stays confined. A client that cannot reuse an existing registration (a different HOME, or a version mismatch on the TUI path) while a host service is already up fails with `Managed service port 49374 on 127.0.0.1 is already in use`. To always keep the server inside the sandbox, run with a private server: `opencodebox --standalone` for the TUI, or `--standalone` after the subcommand where it is supported (`opencodebox models --standalone`); alternatively disable the shared service from outside opencodebox with `opencode service set disabled true` (it also stops the shared service and disables `pair`; `~/.config/opencode` is read-only inside the sandbox). Commands that do not accept `--standalone` (`mcp list`, `plugin list/...`, `debug agents/config`) need the disable flag. +- OpenCode 2 runs fully isolated by default: every command gets a private server inside the sandbox — the launcher overlays a generated `{"disabled": true}` service config so the client can never attach to, or register, a host background service — while sessions, credentials, and TUI state stay shared through the read-write data and state binds. Commands that inherently need the host service are refused with a redirect: `pair`, `service *`, `serve --service`/`--stdio`, `--server`, `upgrade`, `uninstall`. Web UI: `opencodebox serve --port N`, then open the printed URL in the host browser with the printed password. ### Conditional Tool Mounts (Requires Tool Installed on Host) From dee6b3dd05e200f37be2afaa87e6d5dab299a45c Mon Sep 17 00:00:00 2001 From: Fityan <63894003+fityannugroho@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:57:19 +0700 Subject: [PATCH 10/17] fix: cover prod-channel service config in v2 isolation --- README.md | 2 +- opencodebox | 6 +++--- tests/test_launcher.py | 27 ++++++++++++++++++++++++--- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index dee1934..39f1eec 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ The directory holding the `opencode` binary is mounted at `/opt/opencode` and ex - Self-upgrading or installing plugins from inside the sandbox is not supported for the read-only layout: `opencode upgrade` writes to the install tree, and `opencode plugin add` updates the global configuration under `~/.config/opencode` (mounted read-only) — do both on the host instead (e.g. `mise upgrade opencode`). - On first run the launcher creates opencode's config, agents, cache (including the helper `bin` directory) and data directories when they are missing, so a fresh host does not fail at the bind step. OpenCode 2 starts from there; OpenCode 1 may still need its config seeded on the host (it writes `.gitignore` into `~/.config/opencode`, which is mounted read-only). - Only `node_modules/.bin` shims are relocated. Global `bin` shims from `npm|pnpm|bun install -g` and symlinks whose target lies outside the mounted roots still fail with `not found` (exit 127). -- OpenCode 2 runs fully isolated by default: every command gets a private server inside the sandbox — the launcher overlays a generated `{"disabled": true}` service config so the client can never attach to, or register, a host background service — while sessions, credentials, and TUI state stay shared through the read-write data and state binds. Commands that inherently need the host service are refused with a redirect: `pair`, `service *`, `serve --service`/`--stdio`, `--server`, `upgrade`, `uninstall`. Web UI: `opencodebox serve --port N`, then open the printed URL in the host browser with the printed password. +- OpenCode 2 runs fully isolated by default: every command gets a private server inside the sandbox — the launcher overlays a generated `{"disabled": true}` service config over `service.json`, `service-local.json`, `service-prod.json` (plus any existing `service-*.json`) so the client can never attach to, or register, a host background service — while sessions, credentials, and TUI state stay shared through the read-write data and state binds. Commands that inherently need the host service are refused with a redirect: `pair`, `service *`, `serve --service`/`--stdio`, `--server`, `upgrade`, `uninstall`. Web UI: `opencodebox serve --port N`, then open the printed URL in the host browser with the printed password. ### Conditional Tool Mounts (Requires Tool Installed on Host) diff --git a/opencodebox b/opencodebox index 5f85aac..03feaf4 100755 --- a/opencodebox +++ b/opencodebox @@ -387,7 +387,7 @@ mkdir -p "$HOME/.local/state/opencode" # on missing sources, and opencode (v1 and v2) mkdirs ~/.cache/opencode/bin at # startup even though the cache tree is mounted read-only. mkdir -p "$CONFIG_DIR" "$AGENTS_DIR" "$CACHE_DIR/bin" "$APP_DATA" -for _iso_seed in service.json service-local.json; do +for _iso_seed in service.json service-local.json service-prod.json; do if [[ ! -e "$CONFIG_DIR/$_iso_seed" ]]; then printf '{}\n' > "$CONFIG_DIR/$_iso_seed" fi @@ -596,13 +596,13 @@ ISOD_OVERLAY_DIR="$HOME/.local/share/opencodebox" ISOD_OVERLAY_SRC="$ISOD_OVERLAY_DIR/disabled-service.json" mkdir -p "$ISOD_OVERLAY_DIR" printf '{\n "disabled": true\n}\n' > "$ISOD_OVERLAY_SRC" -for _iso_name in service.json service-local.json; do +for _iso_name in service.json service-local.json service-prod.json; do BWRAP_ARGS+=(--ro-bind "$ISOD_OVERLAY_SRC" "$CONFIG_DIR/$_iso_name") done shopt -s nullglob for _iso_existing in "$CONFIG_DIR"/service-*.json; do case "${_iso_existing##*/}" in - service.json|service-local.json) ;; + service.json|service-local.json|service-prod.json) ;; *) BWRAP_ARGS+=(--ro-bind "$ISOD_OVERLAY_SRC" "$_iso_existing") ;; esac done diff --git a/tests/test_launcher.py b/tests/test_launcher.py index 34a26b3..52b4402 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -96,8 +96,8 @@ def test_with_ro_custom_destination_is_last(launcher): overlay_src = str(launcher.home / ".local/share/opencodebox/disabled-service.json") config = str(launcher.home / ".config/opencode") tail = ["--ro-bind", overlay_src, f"{config}/service.json", - "--ro-bind", overlay_src, f"{config}/service-local.json"] - assert options[-len(tail):] == tail + "--ro-bind", overlay_src, f"{config}/service-local.json", + "--ro-bind", overlay_src, f"{config}/service-prod.json"] triples = seqs(options, 3) assert triples.index(["--ro-bind", str(extra), "/custom/path"]) < len(options) - len(tail) @@ -224,6 +224,7 @@ def test_v2_overlay_forces_disabled_service(launcher): config = str(launcher.home / ".config/opencode") assert has_seq(options, ["--ro-bind", overlay_src, f"{config}/service.json"]) assert has_seq(options, ["--ro-bind", overlay_src, f"{config}/service-local.json"]) + assert has_seq(options, ["--ro-bind", overlay_src, f"{config}/service-prod.json"]) overlay_path = launcher.home / ".local/share/opencodebox/disabled-service.json" assert overlay_path.read_text() == '{\n "disabled": true\n}\n' triples = seqs(options, 3) @@ -231,6 +232,7 @@ def test_v2_overlay_forces_disabled_service(launcher): assert config_ro, "config ro-bind must exist" assert triples.index(["--ro-bind", overlay_src, f"{config}/service.json"]) > triples.index(config_ro[0]) assert triples.index(["--ro-bind", overlay_src, f"{config}/service-local.json"]) > triples.index(config_ro[0]) + assert triples.index(["--ro-bind", overlay_src, f"{config}/service-prod.json"]) > triples.index(config_ro[0]) def test_v2_overlay_after_extra_bind(launcher): @@ -243,12 +245,13 @@ def test_v2_overlay_after_extra_bind(launcher): extra_idx = triples.index(["--ro-bind", str(extra), "/custom/path"]) assert triples.index(["--ro-bind", overlay_src, f"{config}/service.json"]) > extra_idx assert triples.index(["--ro-bind", overlay_src, f"{config}/service-local.json"]) > extra_idx + assert triples.index(["--ro-bind", overlay_src, f"{config}/service-prod.json"]) > extra_idx def test_overlay_seeds_missing_service_config(launcher): config = launcher.home / ".config/opencode" launcher.captured(launcher.run_launcher()) - for name in ("service.json", "service-local.json"): + for name in ("service.json", "service-local.json", "service-prod.json"): path = config / name assert path.exists(), f"launcher must seed missing {name}" assert path.read_text() == '{}\n' @@ -264,6 +267,24 @@ def test_overlay_preserves_existing_service_config(launcher): assert has_seq(options, ["--ro-bind", overlay_src, f"{config}/service.json"]) +def test_overlay_prod_channel_covered(launcher): + # A prod-channel binary reads service-prod.json; on a fresh host the file + # is absent upstream, which reads as {} (not disabled) and would take the + # managed path in-sandbox. The overlay must cover it exactly once. + options, _ = launcher.captured(launcher.run_launcher()) + config = launcher.home / ".config/opencode" + prod = config / "service-prod.json" + assert prod.exists(), "launcher must seed missing service-prod.json" + assert prod.read_text() == '{}\n' + overlay_src = str(launcher.home / ".local/share/opencodebox/disabled-service.json") + triples = seqs(options, 3) + prod_seq = ["--ro-bind", overlay_src, f"{config}/service-prod.json"] + assert triples.count(prod_seq) == 1, "prod overlay must appear exactly once" + config_ro = [s for s in triples if s == ["--ro-bind", str(config), str(config)]] + assert config_ro, "config ro-bind must exist" + assert triples.index(prod_seq) > triples.index(config_ro[0]) + + @pytest.mark.parametrize("args", [ ("pair",), ("pair", "--url", "http://127.0.0.1:4097"), From 0a164e8797959b6374cd402fe039a3c6179dece6 Mon Sep 17 00:00:00 2001 From: Fityan <63894003+fityannugroho@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:42:09 +0700 Subject: [PATCH 11/17] docs: trim v2 isolation README bullet --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 39f1eec..d7dce79 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ The directory holding the `opencode` binary is mounted at `/opt/opencode` and ex - Self-upgrading or installing plugins from inside the sandbox is not supported for the read-only layout: `opencode upgrade` writes to the install tree, and `opencode plugin add` updates the global configuration under `~/.config/opencode` (mounted read-only) — do both on the host instead (e.g. `mise upgrade opencode`). - On first run the launcher creates opencode's config, agents, cache (including the helper `bin` directory) and data directories when they are missing, so a fresh host does not fail at the bind step. OpenCode 2 starts from there; OpenCode 1 may still need its config seeded on the host (it writes `.gitignore` into `~/.config/opencode`, which is mounted read-only). - Only `node_modules/.bin` shims are relocated. Global `bin` shims from `npm|pnpm|bun install -g` and symlinks whose target lies outside the mounted roots still fail with `not found` (exit 127). -- OpenCode 2 runs fully isolated by default: every command gets a private server inside the sandbox — the launcher overlays a generated `{"disabled": true}` service config over `service.json`, `service-local.json`, `service-prod.json` (plus any existing `service-*.json`) so the client can never attach to, or register, a host background service — while sessions, credentials, and TUI state stay shared through the read-write data and state binds. Commands that inherently need the host service are refused with a redirect: `pair`, `service *`, `serve --service`/`--stdio`, `--server`, `upgrade`, `uninstall`. Web UI: `opencodebox serve --port N`, then open the printed URL in the host browser with the printed password. +- OpenCode 2 runs fully isolated: every command gets a private in-sandbox server, while sessions, credentials, and TUI state stay shared. Commands needing the host service (`pair`, `service`, `serve --service`/`--stdio`, `--server`, `upgrade`, `uninstall`) are refused with a redirect. ### Conditional Tool Mounts (Requires Tool Installed on Host) From 192d3c305dfedf05dbe726243b8c2124c4a86928 Mon Sep 17 00:00:00 2001 From: Fityan <63894003+fityannugroho@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:56:18 +0700 Subject: [PATCH 12/17] docs: correct README claims to match the launcher --- README.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index d7dce79..c419fec 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ opencodebox --version ## Usage -`opencodebox` is a wrapper for the `opencode` command. All arguments are passed through to `opencode` inside the sandbox. +`opencodebox` is a wrapper for the `opencode` command. All arguments are passed through to `opencode` inside the sandbox, except the commands OpenCode 2 refuses in isolation (see `### OpenCode Launcher Mount`). ```bash opencodebox [OPTIONS] [OPENCODE_ARGS...] @@ -102,8 +102,8 @@ opencodebox --with /data --with-ro /config serve - `$HOME/.cache/opencode` - OpenCode cache - `$HOME/.ssh/*.pub` - Sanitized OpenSSH public key material, when `$HOME/.ssh` is not a symlink - `$HOME/.ssh/known_hosts` - SSH host key, read-only, for Git-over-SSH host verification -- `gpg.ssh.allowedSignersFile` - Configured SSH allowed signers file (if configured) -- OpenCode: `.config/opencode`, `.agents` +- `gpg.ssh.allowedSignersFile` - Configured SSH allowed signers file (if configured and under `$HOME`) +- OpenCode: config dir (`$OPENCODE_CONFIG_DIR`, else `$XDG_CONFIG_HOME/opencode`, else `~/.config/opencode`), `.agents` **Read-Write:** - Current project directory (`$PWD`) @@ -119,12 +119,12 @@ opencodebox --with /data --with-ro /config serve The directory holding the `opencode` binary is mounted at `/opt/opencode` and executed from there (read-write, as before). When `opencode` is an npm-style shim inside a `<...>/node_modules/.bin` directory, the sandbox instead mounts that `node_modules` directory **read-only** and executes the shim at `/opt/opencode/.bin/opencode`, because such shims resolve their target relative to their own location. -- The shim must resolve its target with exactly one `..` level (as with mise's npm backend/aube). -- A shim outside `node_modules` (for example `$HOME/.bin`) is not relocated: it keeps the old mount, never binds `$HOME`, and prints a warning; expect `not found` (exit 127) for relative-target shims. +- The shim's target must resolve with exactly one `..` level (as with mise's npm backend/aube); a deeper path escapes the mounted root and fails. +- A shim outside `node_modules` (for example `$HOME/.bin`) is not relocated: it keeps the old mount, which binds only the shim's own directory, and prints a warning; expect `not found` (exit 127) for relative-target shims. - Self-upgrading or installing plugins from inside the sandbox is not supported for the read-only layout: `opencode upgrade` writes to the install tree, and `opencode plugin add` updates the global configuration under `~/.config/opencode` (mounted read-only) — do both on the host instead (e.g. `mise upgrade opencode`). -- On first run the launcher creates opencode's config, agents, cache (including the helper `bin` directory) and data directories when they are missing, so a fresh host does not fail at the bind step. OpenCode 2 starts from there; OpenCode 1 may still need its config seeded on the host (it writes `.gitignore` into `~/.config/opencode`, which is mounted read-only). +- On first run the launcher creates opencode's config, agents, cache (including the helper `bin` directory), data and state directories when they are missing, so a fresh host does not fail at the bind step. OpenCode 2 starts from there; OpenCode 1 may still need its config seeded on the host (it writes `.gitignore` into `~/.config/opencode`, which is mounted read-only). - Only `node_modules/.bin` shims are relocated. Global `bin` shims from `npm|pnpm|bun install -g` and symlinks whose target lies outside the mounted roots still fail with `not found` (exit 127). -- OpenCode 2 runs fully isolated: every command gets a private in-sandbox server, while sessions, credentials, and TUI state stay shared. Commands needing the host service (`pair`, `service`, `serve --service`/`--stdio`, `--server`, `upgrade`, `uninstall`) are refused with a redirect. +- OpenCode 2 runs fully isolated: every command gets a private in-sandbox server — the launcher overlays a generated `{"disabled": true}` service config so the client can never attach to a host service — while sessions, credentials, and TUI state stay shared (the config directory is shared read-only). Commands needing the host service (`pair`, `service`, `serve --service`/`--stdio`, `--server`, `upgrade`, `update`, `uninstall`) are refused; each error names the alternative. ### Conditional Tool Mounts (Requires Tool Installed on Host) @@ -139,7 +139,7 @@ Each tool is mounted only when `command -v ` succeeds on the host. If the | **pipenv** | — | `~/.local/share/virtualenvs` | | **Rust/Cargo** | `~/.rustup`, `~/.cargo/bin`, `~/.cargo/config.toml` | `~/.cargo/registry` | | **Git** | `~/.gitconfig` | — | -| **Mise** | `~/.config/mise`, `~/.local/share/mise`, `~/.cache/mise` | — | +| **Mise** | `~/.config/mise`, `~/.local/share/mise`, `~/.cache/mise` (or `MISE_CONFIG_DIR`/`MISE_DATA_DIR`/`MISE_CACHE_DIR` when set; a sensitive directory is skipped with a warning) | — | ## Security Restrictions @@ -153,15 +153,15 @@ Each tool is mounted only when `command -v ` succeeds on the host. If the If `SSH_AUTH_SOCK` points to a valid socket, `opencodebox` forwards that socket into the sandbox. This allows SSH commit signing with keys already loaded by `ssh-add` on the host. This feature does not mount private SSH keys into the sandbox. -For Git SSH signing, use a public key path such as `~/.ssh/id_ed25519.pub`, or an inline `key::ssh-ed25519 ...` value. Validates and sanitizes `.pub` files (rejects symlinks, hardlinks, multi-line files; validates key type, base64 format, and OpenSSH key structure with `ssh-keygen`). The sandbox receives sanitized key material only (` `), so comments or extra file content are not exposed. +For Git SSH signing, use a public key path such as `~/.ssh/id_ed25519.pub`, or an inline `key::ssh-ed25519 ...` value. Validates and sanitizes `.pub` files (rejects hardlinks, multi-line files; validates key type, base64 format, and OpenSSH key structure with `ssh-keygen`). The sandbox receives sanitized key material only (` `), so comments or extra file content are not exposed. -For Git-over-SSH network operations, `known_hosts` is mounted read-only when available. This allows host verification without exposing private keys. `~/.ssh/config` is not mounted by default because it can contain broader host-specific behavior; bind it explicitly with `--with-ro ~/.ssh/config` only when needed. +For Git-over-SSH network operations, `known_hosts` is mounted read-only when available. This allows host verification without exposing private keys. `~/.ssh/config` is not mounted, and no path under `~/.ssh` can be bound (sensitive-path guard). -For local SSH signature verification, the configured `gpg.ssh.allowedSignersFile` is mounted read-only when it is an absolute regular file. +For local SSH signature verification, the configured `gpg.ssh.allowedSignersFile` is mounted read-only when it is an absolute regular file under `$HOME`; otherwise it is skipped with a warning. -> **Note:** `.pub` validation occurs at script startup. There is a small TOCTOU window between reading and validating each `.pub` file; this is an accepted limitation of shell scripting. +> **Note:** `.pub` files are validated on the already-open file descriptor (via `/proc/self/fd`), so the validated content cannot be swapped afterwards. -Git-over-SSH network operations may still need explicit read-only binds for files such as `~/.ssh/config` in custom setups. User-provided binds and the current project bind can expose private keys if they include those files, so avoid binding `~/.ssh` wholesale. +Git-over-SSH network operations may still need explicit read-only binds for other files in custom setups, but every path under `~/.ssh` is rejected by the sensitive-path guard. User-provided binds and the current project bind can expose private keys if they include such files, so avoid binding `~/.ssh` wholesale. Forwarding an agent still lets sandboxed processes ask the agent to authenticate or sign while the socket is available. Use a dedicated signing key and consider `ssh-add -c -t 1h ~/.ssh/signing_key` for confirmation and expiry. From 5811e2caf253b3f7f795d1ee710854c1832a20cc Mon Sep 17 00:00:00 2001 From: Fityan <63894003+fityannugroho@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:56:18 +0700 Subject: [PATCH 13/17] fix: list every refused command in --help --- opencodebox | 7 ++++--- tests/test_launcher.py | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/opencodebox b/opencodebox index 03feaf4..fa8991c 100755 --- a/opencodebox +++ b/opencodebox @@ -33,9 +33,10 @@ Options: Isolation: OpenCode 2 always runs fully isolated: every command gets a private server inside the sandbox. 'pair', 'service', 'serve --service', - 'serve --stdio', '--server', 'upgrade' and 'uninstall' are refused; - run them with 'opencode' on the host instead (web UI: 'opencodebox - serve --port N', then open the printed URL in the host browser). + 'serve --stdio', '--server', 'upgrade', 'update' and 'uninstall' are + refused; the error message explains why and, where there is one, + names the alternative (web UI: 'opencodebox serve --port N', then open + the printed URL in the host browser). Examples: opencodebox --with ../app-backend diff --git a/tests/test_launcher.py b/tests/test_launcher.py index 52b4402..a7e63b2 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -359,3 +359,24 @@ def test_help_mentions_isolation(launcher): result = launcher.run_launcher("--help") assert result.returncode == 0, result.stderr assert "Isolation:" in result.stdout + + +# Every command/flag the refusal scanner rejects must also be listed in the +# help text: a refusal the user cannot discover up front is a help bug. +REFUSED_BY_SCANNER = [ + "pair", + "service", + "serve --service", + "serve --stdio", + "--server", + "upgrade", + "update", + "uninstall", +] + + +def test_help_lists_every_refused_command(launcher): + result = launcher.run_launcher("--help") + assert result.returncode == 0, result.stderr + missing = [name for name in REFUSED_BY_SCANNER if f"'{name}'" not in result.stdout] + assert not missing, f"help does not mention refused command(s): {missing}" From eb1bb3d6d70ee5c1a1d30a9aeae3cf8b74cefc94 Mon Sep 17 00:00:00 2001 From: Fityan <63894003+fityannugroho@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:40:30 +0700 Subject: [PATCH 14/17] fix: mask the host service registration inside the sandbox --- opencodebox | 57 +++++++++++++++++++++-------- tests/test_launcher.py | 83 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 120 insertions(+), 20 deletions(-) diff --git a/opencodebox b/opencodebox index fa8991c..7f3e96f 100755 --- a/opencodebox +++ b/opencodebox @@ -46,6 +46,7 @@ Examples: EOF exit 0 } + # Helper to check if a path is sensitive (or an ancestor of one) is_sensitive_path() { local path="$1" @@ -294,7 +295,7 @@ for _iso_tok in "$@"; do case "$_iso_tok" in --) break ;; --server|--server=*) - echo "Error: '--server' cannot run in isolation: it connects to a server outside the sandbox." >&2 + echo "Error: '--server' cannot run in isolation: it connects to a server outside the sandbox. Run the command with 'opencode' on the host instead." >&2 exit 1 ;; --session|--prompt|-s) _iso_skip_next=1 ;; --service|--service=*) _iso_serve_service=1 ;; @@ -349,7 +350,11 @@ else CONFIG_DIR="$HOME/.config/opencode" fi CONFIG_DIR="$(realpath -ms "$CONFIG_DIR")" -if is_sensitive_path "$CONFIG_DIR"; then +# The lexical path is what gets mounted (the CLI resolves the same way), but a +# symlink could still point at a sensitive location. Check the resolved path +# too and refuse when either one is sensitive. +CONFIG_DIR_RESOLVED="$(realpath -m "$CONFIG_DIR" 2>/dev/null || printf '%s' "$CONFIG_DIR")" +if is_sensitive_path "$CONFIG_DIR" || is_sensitive_path "$CONFIG_DIR_RESOLVED"; then echo "Error: service config directory '$CONFIG_DIR' points to or encloses a sensitive location." >&2 exit 1 fi @@ -382,20 +387,30 @@ OPCODE_SANDBOX_PATH="/opt/opencode/$OPCODE_LAUNCHER" # bwrap --bind hard-fails if the source is missing; opencode v2 keeps state under # ~/.local/state/opencode, which v1 users and fresh hosts may not have created yet. -mkdir -p "$HOME/.local/state/opencode" +STATE_DIR="$HOME/.local/state/opencode" +mkdir -p "$STATE_DIR" # A fresh host has none of opencode's directories: bwrap --ro-bind/--bind fails # on missing sources, and opencode (v1 and v2) mkdirs ~/.cache/opencode/bin at # startup even though the cache tree is mounted read-only. mkdir -p "$CONFIG_DIR" "$AGENTS_DIR" "$CACHE_DIR/bin" "$APP_DATA" for _iso_seed in service.json service-local.json service-prod.json; do - if [[ ! -e "$CONFIG_DIR/$_iso_seed" ]]; then + # A dangling symlink must not be written through: it could create the file + # outside the config dir. + if [[ ! -e "$CONFIG_DIR/$_iso_seed" && ! -L "$CONFIG_DIR/$_iso_seed" ]]; then printf '{}\n' > "$CONFIG_DIR/$_iso_seed" fi done unset _iso_seed -# Later bindings override earlier ones (--with binds come last = highest priority) +# The host service registration in the state dir is masked with a ro-bind below. +# bwrap creates a missing bind destination as an empty read-only file, which +# would leave the host without a usable registration, so seed it up front. +if [[ ! -e "$STATE_DIR/service.json" && ! -L "$STATE_DIR/service.json" ]]; then + printf '{}\n' > "$STATE_DIR/service.json" +fi + +# Later bindings override earlier ones (the isolation overlay is appended last) declare -a BWRAP_ARGS=( # --- Isolation & Namespaces --- # Unshare PID/IPC/UTS namespaces for process isolation @@ -443,7 +458,7 @@ declare -a BWRAP_ARGS=( # --- User Local Data --- --ro-bind "$HOME/.local" "$HOME/.local" --tmpfs "$HOME/.cache" # Writable cache (universal for uv, cargo, etc.) - --bind "$HOME/.local/state/opencode" "$HOME/.local/state/opencode" # Writable state (locks, etc.) + --bind "$STATE_DIR" "$STATE_DIR" # Writable state (locks, etc.) # --- OpenCode Specific (read-only) --- --ro-bind "$CONFIG_DIR" "$CONFIG_DIR" @@ -589,26 +604,38 @@ if command -v mise >/dev/null 2>&1; then done fi -# Add extra binds from --with/--with-ro last so user choices have highest priority. +# User binds are added first; the isolation overlay binds below come after them +# and therefore win, so the overlay always masks the service registration. BWRAP_ARGS+=("${EXTRA_BINDS[@]}") # OpenCode v2 isolation: force a private in-sandbox server for every command. # Later binds win, so the overlay is appended after EXTRA_BINDS. -ISOD_OVERLAY_DIR="$HOME/.local/share/opencodebox" -ISOD_OVERLAY_SRC="$ISOD_OVERLAY_DIR/disabled-service.json" -mkdir -p "$ISOD_OVERLAY_DIR" -printf '{\n "disabled": true\n}\n' > "$ISOD_OVERLAY_SRC" +ISOLATION_OVERLAY_DIR="$HOME/.local/share/opencodebox" +ISOLATION_OVERLAY_SRC="$ISOLATION_OVERLAY_DIR/disabled-service.json" +ISOLATION_STATE_MASK_SRC="$ISOLATION_OVERLAY_DIR/masked-service-state.json" +mkdir -p "$ISOLATION_OVERLAY_DIR" +printf '{\n "disabled": true\n}\n' > "$ISOLATION_OVERLAY_SRC" +# The host background-service registration lives in the state dir, which is +# mounted read-write. Mask it so no sandbox process can learn the host server +# URL/password and drive the host server from inside the sandbox. +printf '{}\n' > "$ISOLATION_STATE_MASK_SRC" for _iso_name in service.json service-local.json service-prod.json; do - BWRAP_ARGS+=(--ro-bind "$ISOD_OVERLAY_SRC" "$CONFIG_DIR/$_iso_name") + BWRAP_ARGS+=(--ro-bind "$ISOLATION_OVERLAY_SRC" "$CONFIG_DIR/$_iso_name") done shopt -s nullglob for _iso_existing in "$CONFIG_DIR"/service-*.json; do case "${_iso_existing##*/}" in - service.json|service-local.json|service-prod.json) ;; - *) BWRAP_ARGS+=(--ro-bind "$ISOD_OVERLAY_SRC" "$_iso_existing") ;; + service-local.json|service-prod.json) ;; + *) BWRAP_ARGS+=(--ro-bind "$ISOLATION_OVERLAY_SRC" "$_iso_existing") ;; esac done +# Mask the host service registration in the state dir as well. +BWRAP_ARGS+=(--ro-bind "$ISOLATION_STATE_MASK_SRC" "$STATE_DIR/service.json") +for _iso_state_existing in "$STATE_DIR"/service*.json; do + [[ "${_iso_state_existing##*/}" == "service.json" ]] && continue + BWRAP_ARGS+=(--ro-bind "$ISOLATION_STATE_MASK_SRC" "$_iso_state_existing") +done shopt -u nullglob -unset _iso_name _iso_existing +unset _iso_name _iso_existing _iso_state_existing # Execute bwrap with all arguments and the target command # mise activation runs inside sandbox only if host has mise installed diff --git a/tests/test_launcher.py b/tests/test_launcher.py index a7e63b2..8250dff 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -94,12 +94,19 @@ def test_with_ro_custom_destination_is_last(launcher): extra.mkdir() options, _ = launcher.captured(launcher.run_launcher("--with-ro", f"{extra}:/custom/path")) overlay_src = str(launcher.home / ".local/share/opencodebox/disabled-service.json") + state_mask_src = str(launcher.home / ".local/share/opencodebox/masked-service-state.json") config = str(launcher.home / ".config/opencode") - tail = ["--ro-bind", overlay_src, f"{config}/service.json", - "--ro-bind", overlay_src, f"{config}/service-local.json", - "--ro-bind", overlay_src, f"{config}/service-prod.json"] + state = str(launcher.home / ".local/state/opencode") + expected_tail = [ + "--ro-bind", overlay_src, f"{config}/service.json", + "--ro-bind", overlay_src, f"{config}/service-local.json", + "--ro-bind", overlay_src, f"{config}/service-prod.json", + "--ro-bind", state_mask_src, f"{state}/service.json", + ] + assert options[-len(expected_tail):] == expected_tail triples = seqs(options, 3) - assert triples.index(["--ro-bind", str(extra), "/custom/path"]) < len(options) - len(tail) + extra_idx = triples.index(["--ro-bind", str(extra), "/custom/path"]) + assert extra_idx < triples.index(["--ro-bind", overlay_src, f"{config}/service.json"]) def test_seccomp_fd_forwarded_when_filter_present(launcher): @@ -267,6 +274,37 @@ def test_overlay_preserves_existing_service_config(launcher): assert has_seq(options, ["--ro-bind", overlay_src, f"{config}/service.json"]) +def test_overlay_does_not_write_through_dangling_symlink(launcher): + # A dangling symlink at the destination must never be written through: + # the seeded file would land outside the config dir. + config = launcher.home / ".config/opencode" + config.mkdir(parents=True) + outside = launcher.root / "outside/service.json" + outside.parent.mkdir() + (config / "service.json").symlink_to(outside) + launcher.captured(launcher.run_launcher()) + assert not outside.exists(), "launcher must not write through a dangling symlink" + assert (config / "service.json").is_symlink(), "the symlink must be left alone" + + +def test_state_service_registration_is_masked(launcher): + # The host background-service registration lives in the state dir, which is + # mounted read-write. It must be masked, otherwise a sandbox process could + # read the host server URL/password and drive the host server. + options, _ = launcher.captured(launcher.run_launcher()) + state = launcher.home / ".local/state/opencode" + mask_src = launcher.home / ".local/share/opencodebox/masked-service-state.json" + assert mask_src.read_text() == '{}\n' + mask_seq = ["--ro-bind", str(mask_src), f"{state}/service.json"] + assert has_seq(options, mask_seq) + triples = seqs(options, 3) + state_bind = ["--bind", str(state), str(state)] + assert state_bind in triples + # Later binds win, so the mask has to come after the state dir bind. + assert triples.index(mask_seq) > triples.index(state_bind) + assert (state / "service.json").read_text() == '{}\n' + + def test_overlay_prod_channel_covered(launcher): # A prod-channel binary reads service-prod.json; on a fresh host the file # is absent upstream, which reads as {} (not disabled) and would take the @@ -297,8 +335,11 @@ def test_overlay_prod_channel_covered(launcher): ("serve", "--service"), ("serve", "--stdio"), ("--server", "http://127.0.0.1:1"), + ("--server=http://127.0.0.1:1",), ("models", "--server", "http://127.0.0.1:1"), ("run", "--title", "--server"), + ("serve", "--service=false"), + ("serve", "--stdio=false"), ]) def test_isolation_refusals(launcher, args): result = launcher.run_launcher(*args) @@ -319,8 +360,18 @@ def test_isolation_refusals(launcher, args): ("serve", "--port", "4097"), ]) def test_isolation_allows_server_commands(launcher, args): - _, command = launcher.captured(launcher.run_launcher(*args)) + options, command = launcher.captured(launcher.run_launcher(*args)) assert command[3:] == ["--", *args] + # Allowed commands are still isolated: the service registration must stay + # masked in the config dir and in the state dir. + overlay_src = str(launcher.home / ".local/share/opencodebox/disabled-service.json") + state_mask_src = str(launcher.home / ".local/share/opencodebox/masked-service-state.json") + config = str(launcher.home / ".config/opencode") + state = str(launcher.home / ".local/state/opencode") + assert has_seq(options, ["--ro-bind", overlay_src, f"{config}/service.json"]) + assert has_seq(options, ["--ro-bind", overlay_src, f"{config}/service-local.json"]) + assert has_seq(options, ["--ro-bind", overlay_src, f"{config}/service-prod.json"]) + assert has_seq(options, ["--ro-bind", state_mask_src, f"{state}/service.json"]) def test_refusal_scanner_flag_values(launcher): @@ -334,6 +385,14 @@ def test_refusal_scanner_flag_values(launcher): assert command[3:] == ["--", "mcp", "add", "f", "--", "--server", "x"] +def test_session_flag_value_is_not_a_subcommand(launcher): + # -s takes a session id: a refused word in that position must not be + # mistaken for a subcommand. + for value in ("pair", "service", "serve", "upgrade"): + _, command = launcher.captured(launcher.run_launcher("-s", value)) + assert command[3:] == ["--", "-s", value] + + def test_config_dir_precedence(launcher): import tempfile with tempfile.TemporaryDirectory(dir=str(launcher.root)) as tmp: @@ -355,6 +414,20 @@ def test_config_dir_precedence(launcher): assert "sensitive" in result.stderr +def test_config_dir_symlink_to_sensitive_path_rejected(launcher): + # The config dir is mounted lexically, but a symlink could still point at + # a sensitive location: the resolved path must be checked too. + ssh = launcher.home / ".ssh" + ssh.mkdir(mode=0o700) + link = launcher.home / "sshlink" + link.symlink_to(ssh) + launcher.env["OPENCODE_CONFIG_DIR"] = str(link) + result = launcher.run_launcher() + assert result.returncode == 1 + assert "sensitive" in result.stderr + assert not launcher.capture.exists() + + def test_help_mentions_isolation(launcher): result = launcher.run_launcher("--help") assert result.returncode == 0, result.stderr From 6cf29227750ff35019497830ecef3983088d58d8 Mon Sep 17 00:00:00 2001 From: Fityan <63894003+fityannugroho@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:40:30 +0700 Subject: [PATCH 15/17] docs: document the masked host service registration --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c419fec..8270616 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ The directory holding the `opencode` binary is mounted at `/opt/opencode` and ex - Self-upgrading or installing plugins from inside the sandbox is not supported for the read-only layout: `opencode upgrade` writes to the install tree, and `opencode plugin add` updates the global configuration under `~/.config/opencode` (mounted read-only) — do both on the host instead (e.g. `mise upgrade opencode`). - On first run the launcher creates opencode's config, agents, cache (including the helper `bin` directory), data and state directories when they are missing, so a fresh host does not fail at the bind step. OpenCode 2 starts from there; OpenCode 1 may still need its config seeded on the host (it writes `.gitignore` into `~/.config/opencode`, which is mounted read-only). - Only `node_modules/.bin` shims are relocated. Global `bin` shims from `npm|pnpm|bun install -g` and symlinks whose target lies outside the mounted roots still fail with `not found` (exit 127). -- OpenCode 2 runs fully isolated: every command gets a private in-sandbox server — the launcher overlays a generated `{"disabled": true}` service config so the client can never attach to a host service — while sessions, credentials, and TUI state stay shared (the config directory is shared read-only). Commands needing the host service (`pair`, `service`, `serve --service`/`--stdio`, `--server`, `upgrade`, `update`, `uninstall`) are refused; each error names the alternative. +- OpenCode 2 runs fully isolated: every command gets a private in-sandbox server — the launcher overlays a generated `{"disabled": true}` service config so the client can never attach to a host service — and masks the host service registration in the state directory, so nothing in the sandbox can read the host server URL/password — while sessions, credentials, and TUI state stay shared (the config directory is shared read-only). Commands needing the host service (`pair`, `service`, `serve --service`/`--stdio`, `--server`, `upgrade`, `update`, `uninstall`) are refused; each error names the alternative. ### Conditional Tool Mounts (Requires Tool Installed on Host) @@ -155,13 +155,13 @@ If `SSH_AUTH_SOCK` points to a valid socket, `opencodebox` forwards that socket For Git SSH signing, use a public key path such as `~/.ssh/id_ed25519.pub`, or an inline `key::ssh-ed25519 ...` value. Validates and sanitizes `.pub` files (rejects hardlinks, multi-line files; validates key type, base64 format, and OpenSSH key structure with `ssh-keygen`). The sandbox receives sanitized key material only (` `), so comments or extra file content are not exposed. -For Git-over-SSH network operations, `known_hosts` is mounted read-only when available. This allows host verification without exposing private keys. `~/.ssh/config` is not mounted, and no path under `~/.ssh` can be bound (sensitive-path guard). +For Git-over-SSH network operations, `known_hosts` is mounted read-only when available. This allows host verification without exposing private keys. `~/.ssh/config` is not mounted, and user-supplied binds under `~/.ssh` are rejected by the sensitive-path guard (the launcher still mounts `known_hosts` and sanitized `*.pub` itself). For local SSH signature verification, the configured `gpg.ssh.allowedSignersFile` is mounted read-only when it is an absolute regular file under `$HOME`; otherwise it is skipped with a warning. > **Note:** `.pub` files are validated on the already-open file descriptor (via `/proc/self/fd`), so the validated content cannot be swapped afterwards. -Git-over-SSH network operations may still need explicit read-only binds for other files in custom setups, but every path under `~/.ssh` is rejected by the sensitive-path guard. User-provided binds and the current project bind can expose private keys if they include such files, so avoid binding `~/.ssh` wholesale. +Git-over-SSH network operations may still need explicit read-only binds for other files in custom setups, but every user-supplied path under `~/.ssh` is rejected by the sensitive-path guard. User-provided binds and the current project bind can expose private keys if they include such files, so avoid binding `~/.ssh` wholesale. Forwarding an agent still lets sandboxed processes ask the agent to authenticate or sign while the socket is available. Use a dedicated signing key and consider `ssh-add -c -t 1h ~/.ssh/signing_key` for confirmation and expiry. From ee9c00a51d358632543109b7d5510783c6cba8c3 Mon Sep 17 00:00:00 2001 From: Fityan <63894003+fityannugroho@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:46:51 +0700 Subject: [PATCH 16/17] fix: refuse dangling symlinks and write overlay files atomically --- opencodebox | 50 ++++++++++++++++++++++++++++++-- tests/conftest.py | 3 +- tests/test_launcher.py | 66 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 113 insertions(+), 6 deletions(-) diff --git a/opencodebox b/opencodebox index 7f3e96f..71595ad 100755 --- a/opencodebox +++ b/opencodebox @@ -55,6 +55,32 @@ is_sensitive_path() { "$path" == "$HOME" || "$HOME" == "$path"/* || "$path" == "/" ]] } +# Helper to refuse a dangling symlink at an overlay destination: bwrap would +# abort with a cryptic error, and the symlink must not be written through +check_overlay_destination() { + local path="$1" + + if [[ -L "$path" && ! -e "$path" ]]; then + echo "Error: '$path' is a dangling symlink; remove it before running opencodebox." >&2 + exit 1 + fi +} + +# Helper to write a generated file without following a symlink at the +# destination: the content goes to a fresh temp file that is moved into place +write_generated_file() { + local path="$1" + local format="$2" + local tmp + + tmp="$(mktemp "$(dirname "$path")/.opencodebox.XXXXXX")" || return 1 + if ! printf "$format" > "$tmp"; then + rm -f "$tmp" + return 1 + fi + mv -f "$tmp" "$path" +} + # Prerequisite checks - fail fast with helpful messages check_prerequisites() { local missing=0 @@ -390,6 +416,20 @@ OPCODE_SANDBOX_PATH="/opt/opencode/$OPCODE_LAUNCHER" STATE_DIR="$HOME/.local/state/opencode" mkdir -p "$STATE_DIR" +# The overlay binds replace these paths inside the sandbox; a dangling +# symlink there would make bwrap abort, so refuse it up front. +check_overlay_destination "$CONFIG_DIR/service.json" +check_overlay_destination "$CONFIG_DIR/service-local.json" +check_overlay_destination "$CONFIG_DIR/service-prod.json" +check_overlay_destination "$STATE_DIR/service.json" +# The remaining masked destinations are picked up by glob below. +shopt -s nullglob +for _iso_masked in "$CONFIG_DIR"/service-*.json "$STATE_DIR"/service*.json; do + check_overlay_destination "$_iso_masked" +done +shopt -u nullglob +unset _iso_masked + # A fresh host has none of opencode's directories: bwrap --ro-bind/--bind fails # on missing sources, and opencode (v1 and v2) mkdirs ~/.cache/opencode/bin at # startup even though the cache tree is mounted read-only. @@ -613,11 +653,17 @@ ISOLATION_OVERLAY_DIR="$HOME/.local/share/opencodebox" ISOLATION_OVERLAY_SRC="$ISOLATION_OVERLAY_DIR/disabled-service.json" ISOLATION_STATE_MASK_SRC="$ISOLATION_OVERLAY_DIR/masked-service-state.json" mkdir -p "$ISOLATION_OVERLAY_DIR" -printf '{\n "disabled": true\n}\n' > "$ISOLATION_OVERLAY_SRC" +write_generated_file "$ISOLATION_OVERLAY_SRC" '{\n "disabled": true\n}\n' || { + echo "Error: cannot write '$ISOLATION_OVERLAY_SRC'." >&2 + exit 1 +} # The host background-service registration lives in the state dir, which is # mounted read-write. Mask it so no sandbox process can learn the host server # URL/password and drive the host server from inside the sandbox. -printf '{}\n' > "$ISOLATION_STATE_MASK_SRC" +write_generated_file "$ISOLATION_STATE_MASK_SRC" '{}\n' || { + echo "Error: cannot write '$ISOLATION_STATE_MASK_SRC'." >&2 + exit 1 +} for _iso_name in service.json service-local.json service-prod.json; do BWRAP_ARGS+=(--ro-bind "$ISOLATION_OVERLAY_SRC" "$CONFIG_DIR/$_iso_name") done diff --git a/tests/conftest.py b/tests/conftest.py index 2c80504..e6b99e9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,7 +35,8 @@ def __init__(self, root: Path, bin_dir: Path | None = None): "CAPTURE": str(self.capture), "CALLS": str(self.calls), } - for name in ("bash", "cat", "dirname", "uname", "id", "stat", "realpath", "mkdir"): + for name in ("bash", "cat", "dirname", "uname", "id", "stat", "realpath", "mkdir", + "mktemp", "mv", "rm"): target = shutil.which(name, path="/usr/bin:/bin") assert target is not None, name (self.bin / name).symlink_to(target) diff --git a/tests/test_launcher.py b/tests/test_launcher.py index 8250dff..cc4d6f5 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -275,18 +275,78 @@ def test_overlay_preserves_existing_service_config(launcher): def test_overlay_does_not_write_through_dangling_symlink(launcher): - # A dangling symlink at the destination must never be written through: - # the seeded file would land outside the config dir. + # A dangling symlink at an overlay destination would make bwrap abort with + # a cryptic error: the launcher refuses it up front and never writes + # through it. config = launcher.home / ".config/opencode" config.mkdir(parents=True) outside = launcher.root / "outside/service.json" outside.parent.mkdir() (config / "service.json").symlink_to(outside) - launcher.captured(launcher.run_launcher()) + result = launcher.run_launcher() + assert result.returncode == 1 + assert "symlink" in result.stderr + assert not launcher.capture.exists() assert not outside.exists(), "launcher must not write through a dangling symlink" assert (config / "service.json").is_symlink(), "the symlink must be left alone" +def test_state_dangling_symlink_is_refused(launcher): + # The state dir registration is masked the same way, so a dangling + # symlink there is refused instead of aborting bwrap. + state = launcher.home / ".local/state/opencode" + state.mkdir(parents=True) + outside = launcher.root / "outside/state-service.json" + (state / "service.json").symlink_to(outside) + result = launcher.run_launcher() + assert result.returncode == 1 + assert "symlink" in result.stderr + assert not launcher.capture.exists() + assert not outside.exists(), "launcher must not write through a dangling symlink" + assert (state / "service.json").is_symlink(), "the symlink must be left alone" + + +def test_masked_wildcard_dangling_symlink_is_refused(launcher): + # Every masked destination is checked, not only the known file names. + config = launcher.home / ".config/opencode" + config.mkdir(parents=True) + outside = launcher.root / "outside/service-old.json" + (config / "service-old.json").symlink_to(outside) + result = launcher.run_launcher() + assert result.returncode == 1 + assert "symlink" in result.stderr + assert not launcher.capture.exists() + assert not outside.exists(), "launcher must not write through a dangling symlink" + assert (config / "service-old.json").is_symlink(), "the symlink must be left alone" + + +@pytest.mark.parametrize("name", ["disabled-service.json", "masked-service-state.json"]) +def test_overlay_source_is_not_written_through_symlink(launcher, name): + # The generated overlay files are written atomically: a symlink at the + # destination is replaced, never written through. + overlay = launcher.home / ".local/share/opencodebox" + overlay.mkdir(parents=True) + outside = launcher.root / "outside/overlay.json" + (overlay / name).symlink_to(outside) + launcher.captured(launcher.run_launcher()) + assert not outside.exists(), "launcher must not write through the overlay symlink" + assert not (overlay / name).is_symlink(), "the symlink must be replaced" + assert (overlay / name).is_file() + + +def test_overlay_source_keeps_existing_target_content(launcher): + # An existing file behind the overlay symlink must keep its content. + overlay = launcher.home / ".local/share/opencodebox" + overlay.mkdir(parents=True) + outside = launcher.root / "outside/overlay.json" + outside.parent.mkdir() + outside.write_text("keep me\n") + (overlay / "disabled-service.json").symlink_to(outside) + launcher.captured(launcher.run_launcher()) + assert outside.read_text() == "keep me\n" + assert (overlay / "disabled-service.json").read_text() == '{\n "disabled": true\n}\n' + + def test_state_service_registration_is_masked(launcher): # The host background-service registration lives in the state dir, which is # mounted read-write. It must be masked, otherwise a sandbox process could From 8aea61f84a20c82de03007298ed7e11652649ff8 Mon Sep 17 00:00:00 2001 From: Fityan <63894003+fityannugroho@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:47:54 +0700 Subject: [PATCH 17/17] docs: note the shared state directory residual --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8270616..3632137 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,7 @@ Each tool is mounted only when `command -v ` succeeds on the host. If the - **Project directory**: Cannot run from `$HOME`, `~/.ssh`, `~/.gnupg`, or their ancestors. Use a dedicated project directory. - **Bind mounts**: `--with` and `--with-ro` reject paths that point to or enclose sensitive locations (`$HOME`, `~/.ssh`, `~/.gnupg`). +- **State directory**: `~/.local/state/opencode` is mounted read-write so that locks and TUI state stay shared between the sandbox and the host. The launcher masks the host background-service registration inside the sandbox, but a sandboxed process can still create files in that directory, and the host client reads some of them (including the pty runtime registration). Treat that directory as shared state, not as a trust boundary. - **SSH directory**: `~/.ssh` must have permissions `0700`. Fix with: `chmod 700 ~/.ssh` ## SSH Agent and Git Signing