Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,7 @@ Every community PR that lands in main is credited here — that's a project rule
- [@davekopecek](https://github.com/davekopecek) (Dave Kopecek) — committed the design-reference fixture so the design-token guard runs on every machine (#30)
- [@snapsynapse](https://github.com/snapsynapse) (Sam Rogers) — graceful shutdown on SIGINT/SIGTERM with worker-tree cleanup and finished state, plus the 14-test end-to-end CLI regression suite (#4)
- [@mlava](https://github.com/mlava) (Mark Lavercombe) — named setup failures across every diagnostic surface (#37) and `run --baseline`, the no-workers check preflight (#38)
- [@jeffhamons](https://github.com/jeffhamons) (Jeff Hamons) — linked-worktree Seatbelt `git add` support and regression coverage (#2)

Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for the philosophy and what gets a PR merged fast. The short version: small and scoped, rebased on current main, every claim backed by an executed test. Authorship is always preserved — where a maintainer pushes a mechanical fix to your branch, you remain the commit author.

Expand Down
87 changes: 76 additions & 11 deletions engines/opencode-sandboxed.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
# 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. The scratch root also carries a private per-run XDG_DATA_HOME,
# which is what keeps concurrent workers off one shared session database (see the
# comment on that export below — it is a correctness fix for parallel runs).
# writes confined to the task dir, a per-run scratch/cache dir, OpenCode's own
# state dirs, and — when the task dir is a linked Git worktree — that worktree's
# own gitdir plus the shared object store so `git add` can run.
# The scratch root also carries a private per-run XDG_DATA_HOME, which keeps
# concurrent workers off one shared session database.
#
# Usage (as a ringer engine bin):
# opencode-sandboxed.sh <taskdir> [--no-sandbox] <opencode args...>
Expand Down Expand Up @@ -44,6 +45,42 @@ fi

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

# A task dir that is a linked Git worktree keeps its index in a per-worktree
# gitdir and its new objects in the shared object store, both outside TASKDIR.
# Resolve those canonical paths with Git so `git add` can write them without
# opening the common gitdir (hooks/config/refs stay denied). Plain directories
# and non-worktree task dirs simply resolve to nothing.
WORKTREE_GITDIR=""
WORKTREE_OBJECTS=""
if command -v git >/dev/null 2>&1 \
&& [ "$(git -C "$TASKDIR_REAL" rev-parse --is-inside-work-tree 2>/dev/null || true)" = "true" ]; then
gitdir="$(git -C "$TASKDIR_REAL" rev-parse --absolute-git-dir 2>/dev/null || true)"
commondir="$(git -C "$TASKDIR_REAL" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)"
if [ -n "$gitdir" ] && [ -n "$commondir" ]; then
commondir_real="$(cd "$commondir" 2>/dev/null && pwd -P || true)"
# Only a registered linked worktree has a gitdir directly below the
# common directory's worktrees/ directory. A task-controlled .git file
# must not nominate an arbitrary gitdir or an unrelated object store.
# The back-pointer must identify this task directory as the worktree root.
worktrees="$commondir_real/worktrees"
if [ -n "$commondir_real" ] && [ ! -L "$gitdir" ] \
&& [ -d "$gitdir" ] && [ ! -L "$worktrees" ] \
&& [ -d "$worktrees" ] && [ -f "$gitdir/gitdir" ]; then
gitdir_real="$(cd "$gitdir" && pwd -P)"
worktrees_real="$(cd "$worktrees" && pwd -P)"
owner_gitfile="$(< "$gitdir/gitdir")"
if [ "$(dirname "$gitdir_real")" = "$worktrees_real" ] \
&& [ "$owner_gitfile" = "$TASKDIR_REAL/.git" ]; then
WORKTREE_GITDIR="$gitdir_real"
objects="$commondir/objects"
if [ ! -L "$objects" ] && [ -d "$objects" ]; then
WORKTREE_OBJECTS="$(cd "$objects" && pwd -P)"
fi
fi
fi
fi
fi

# 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.
# Resolve to the real path (/var/folders symlinks to /private/var/folders);
Expand All @@ -65,7 +102,25 @@ cat > "$PROFILE" <<'SBEOF'
(subpath (param "OC_SHARE"))
(subpath (param "OC_STATE"))
(subpath (param "OC_CONFIG"))
(subpath (param "OC_BASE")))
(subpath (param "OC_BASE"))
SBEOF

# Open the linked worktree's own gitdir and the shared object store for git
# writes. Each rule is emitted only when its canonical target resolved, and the
# common gitdir itself (hooks/config/info/packed-refs/refs) stays denied.
if [ -n "$WORKTREE_GITDIR" ]; then
cat >> "$PROFILE" <<'SBEOF'
(subpath (param "WORKTREE_GITDIR"))
SBEOF
fi
if [ -n "$WORKTREE_OBJECTS" ]; then
cat >> "$PROFILE" <<'SBEOF'
(subpath (param "WORKTREE_OBJECTS"))
SBEOF
fi

cat >> "$PROFILE" <<'SBEOF'
)
; /dev is needed for /dev/null, /dev/urandom, etc.; writes there can't create
; persistent files without root, so a few literals are allowed rather than via param.
(allow file-write-data
Expand Down Expand Up @@ -97,16 +152,26 @@ if [ -f "$HOME/.local/share/opencode/auth.json" ]; then
chmod 600 "$XDG_DATA_HOME/opencode/auth.json"
fi

SANDBOX_ARGS=(
-D "TASKDIR=$TASKDIR_REAL"
-D "SCRATCH=$SCRATCH"
-D "OC_SHARE=$HOME/.local/share/opencode"
-D "OC_STATE=$HOME/.local/state/opencode"
-D "OC_CONFIG=$HOME/.config/opencode"
-D "OC_BASE=$HOME/.opencode"
)
if [ -n "$WORKTREE_GITDIR" ]; then
SANDBOX_ARGS+=(-D "WORKTREE_GITDIR=$WORKTREE_GITDIR")
fi
if [ -n "$WORKTREE_OBJECTS" ]; then
SANDBOX_ARGS+=(-D "WORKTREE_OBJECTS=$WORKTREE_OBJECTS")
fi

# Run as a child (not exec) so the EXIT trap fires and cleans up the profile +
# scratch dir even on the success path; propagate the child's exit status.
set +e
/usr/bin/sandbox-exec \
-D "TASKDIR=$TASKDIR_REAL" \
-D "SCRATCH=$SCRATCH" \
-D "OC_SHARE=$HOME/.local/share/opencode" \
-D "OC_STATE=$HOME/.local/state/opencode" \
-D "OC_CONFIG=$HOME/.config/opencode" \
-D "OC_BASE=$HOME/.opencode" \
"${SANDBOX_ARGS[@]}" \
-f "$PROFILE" "$OPENCODE_BIN" "$@" < /dev/null
status=$?
set -e
Expand Down
Loading
Loading