Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ Per-task `"engine": "mymodel"` routes work to it — the invariants (stdin close

Unless a model ships its own first-class harness (Codex does), OpenCode is the harness that runs it — one engine block covers every OpenRouter-served model. `config.sample.toml` includes a ready-to-uncomment engine whose `{model}` placeholder is filled per task from the manifest's `"model"` field, with `model_default` as the fallback. The shipped default is OpenRouter's `z-ai/glm-5.2` — roughly $0.74/M input and $2.33/M output (2026-07), about 20-30x cheaper output than frontier coding models; a complete write-code-and-pass-the-check task lands around a penny.

OpenCode ships no OS sandbox, so the engine's `bin` points at an absolute path to `engines/opencode-sandboxed.sh` (ringer does not resolve engine bins relative to the repo): a macOS Seatbelt wrapper that leaves network and reads open but confines writes to the task dir, a per-run scratch dir (wired as the agent's `TMPDIR`/`XDG_CACHE_HOME`), and OpenCode's own state/config dirs. Its `--dangerously-skip-permissions` flag only silences OpenCode's interactive prompts; Seatbelt is the actual containment. Task paths reach the profile as `sandbox-exec -D` parameters rather than string interpolation, so a task dir with quotes or parens can't inject sandbox rules. `--no-sandbox` is wired as the engine's `full_access_args`, so ringer's `allow_full_access` gate still governs escapes. Non-macOS installs need their own sandbox (or full-access mode).
OpenCode ships no OS sandbox, so the engine's `bin` points at an absolute path to `engines/opencode-sandboxed.sh` (ringer does not resolve engine bins relative to the repo): a macOS Seatbelt wrapper that leaves network and external reads open, confines run-local reads to the current task directory, and confines writes to the task dir, a per-run scratch dir (wired as the agent's `TMPDIR`/`XDG_CACHE_HOME`), and OpenCode's own state/config dirs. A worker can still read reference material elsewhere on the machine when its spec names it, but cannot inspect sibling workers under the same run workdir. Its `--dangerously-skip-permissions` flag only silences OpenCode's interactive prompts; Seatbelt is the actual containment. Task and workdir paths reach the profile as `sandbox-exec -D` parameters rather than string interpolation, so a task dir with quotes or parens can't inject sandbox rules. `--no-sandbox` is wired as the engine's `full_access_args`, so ringer's `allow_full_access` gate still governs escapes. Non-macOS installs need their own sandbox (or full-access mode).

Setting it up takes about five minutes:

Expand Down
5 changes: 3 additions & 2 deletions config.sample.toml
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,9 @@ model_report_regex = "(?m)^model:[ \\t]*([^ \\t\\r\\n]+)[ \\t]*\\r?$"
# default: GLM-5.2 (z-ai/glm-5.2, roughly $0.74/M input, $2.33/M output as of
# 2026-07) — the cheap-intelligence lane.
# OpenCode has no OS sandbox, so `bin` points at engines/opencode-sandboxed.sh
# (macOS Seatbelt: network + reads open, writes confined to the task dir, a
# per-run scratch dir, and OpenCode's state/config dirs). Auth: put your
# (macOS Seatbelt: network + external reads open, run-local reads confined to
# the current task, and writes confined to the task dir, a per-run scratch dir,
# and OpenCode's state/config dirs). Auth: put your
# OpenRouter key where your OpenCode version expects it — commonly
# ~/.local/share/opencode/auth.json ({"openrouter": {"type": "api", "key": "..."}});
# confirm the path with your installed CLI.
Expand Down
15 changes: 12 additions & 3 deletions engines/opencode-sandboxed.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
#
# OpenCode has no OS-level sandbox of its own — its --dangerously-skip-permissions
# flag (required for headless runs) disables ALL of its interactive approval
# prompts. This wrapper supplies the real containment: full network and reads,
# writes confined to the task dir, a per-run scratch/cache dir, and OpenCode's
# own state dirs.
# prompts. This wrapper supplies the real containment: full network, reads open
# outside the run workdir, reads inside the run confined to the current task,
# and writes confined to the task dir, a per-run scratch/cache dir, and
# OpenCode's own state dirs.
#
# Usage (as a ringer engine bin):
# opencode-sandboxed.sh <taskdir> [--no-sandbox] <opencode args...>
Expand Down Expand Up @@ -38,6 +39,7 @@ if [ ! -x /usr/bin/sandbox-exec ]; then
fi

TASKDIR_REAL="$(cd "$TASKDIR" && pwd -P)"
WORKDIR_REAL="$(dirname "$TASKDIR_REAL")"

# Per-run scratch root — becomes both TMPDIR and XDG_CACHE_HOME for OpenCode, so
# we never have to open all of /private/tmp or ~/.cache to the sandboxed agent.
Expand All @@ -53,6 +55,12 @@ trap cleanup EXIT
cat > "$PROFILE" <<'SBEOF'
(version 1)
(allow default)
(deny file-read* (subpath (param "WORKDIR")))
; Node resolves an entry module by statting its direct parent. Metadata on the
; workdir root is sufficient for that resolution but does not permit sibling
; file content reads.
(allow file-read-metadata (literal (param "WORKDIR")))
(allow file-read* (subpath (param "TASKDIR")))
(deny file-write*)
(allow file-write*
(subpath (param "TASKDIR"))
Expand All @@ -77,6 +85,7 @@ mkdir -p "$XDG_CACHE_HOME"
set +e
/usr/bin/sandbox-exec \
-D "TASKDIR=$TASKDIR_REAL" \
-D "WORKDIR=$WORKDIR_REAL" \
-D "SCRATCH=$SCRATCH" \
-D "OC_SHARE=$HOME/.local/share/opencode" \
-D "OC_STATE=$HOME/.local/state/opencode" \
Expand Down
109 changes: 109 additions & 0 deletions tests/test_opencode_sandbox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
from __future__ import annotations

import os
import platform
import stat
import subprocess
import tempfile
import unittest
from pathlib import Path


ROOT = Path(__file__).resolve().parents[1]
WRAPPER = ROOT / "engines" / "opencode-sandboxed.sh"
SANDBOX_EXEC = Path("/usr/bin/sandbox-exec")


@unittest.skipUnless(
platform.system() == "Darwin" and SANDBOX_EXEC.is_file(),
"requires macOS sandbox-exec",
)
class OpenCodeSandboxTests(unittest.TestCase):
def test_task_can_read_itself_and_external_inputs_but_not_siblings(self) -> None:
with tempfile.TemporaryDirectory() as root_text:
root = (Path(root_text).resolve() / "root (quoted')")
root.mkdir()
workdir = root / "work"
taskdir = workdir / "task-a"
sibling = workdir / "task-b"
external = root / "reference"
fake_bin = root / "bin"
for path in (taskdir, sibling, external, fake_bin):
path.mkdir(parents=True)

own_file = taskdir / "own.txt"
sibling_file = sibling / "peer.txt"
external_file = external / "reference.txt"
own_file.write_text("own\n", encoding="utf-8")
sibling_file.write_text("peer\n", encoding="utf-8")
external_file.write_text("reference\n", encoding="utf-8")
sibling_link = taskdir / "peer-link.txt"
sibling_link.symlink_to(sibling_file)
node_probe = taskdir / "node-probe.mjs"
node_probe.write_text(
'import assert from "node:assert/strict"; assert.equal(1, 1);\n',
encoding="utf-8",
)

fake_opencode = fake_bin / "opencode"
fake_opencode.write_text(
"""#!/bin/bash
set -u
taskdir="$1"
sibling_file="$2"
external_file="$3"
sibling_link="$4"

cat "$taskdir/own.txt" || exit 10
cat "$external_file" || exit 11
node --test "$taskdir/node-probe.mjs" || exit 13
printf 'created\\n' > "$taskdir/created.txt" || exit 12

if cat "$sibling_file" >/dev/null 2>&1; then
echo sibling-read-leaked >&2
exit 20
fi
if cat "$sibling_link" >/dev/null 2>&1; then
echo symlink-read-leaked >&2
exit 21
fi
if printf 'escaped\\n' > "$(dirname "$sibling_file")/escaped.txt" 2>/dev/null; then
echo sibling-write-leaked >&2
exit 22
fi

printf 'sandbox-ok\\n'
""",
encoding="utf-8",
)
fake_opencode.chmod(fake_opencode.stat().st_mode | stat.S_IXUSR)

env = os.environ.copy()
env["PATH"] = os.pathsep.join((str(fake_bin), env.get("PATH", "")))
result = subprocess.run(
[
str(WRAPPER),
str(taskdir),
str(taskdir),
str(sibling_file),
str(external_file),
str(sibling_link),
],
cwd=taskdir,
env=env,
text=True,
capture_output=True,
timeout=30,
check=False,
)

self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("own", result.stdout)
self.assertIn("reference", result.stdout)
self.assertIn("sandbox-ok", result.stdout)
self.assertEqual((taskdir / "created.txt").read_text(), "created\n")
self.assertFalse((sibling / "escaped.txt").exists())


if __name__ == "__main__":
unittest.main()