Protect host-run files from the agent; trust registry; git audit
- read-only in the sandbox: .opencode-sandbox/, .cc-safety-net/, .git/hooks, .git/config, an in-project core.hooksPath, the egress rules and the safety-net home; .git is pinned by a bind onto itself so it cannot be swapped - only trusted projects are loaded (init / trust): a .opencode-sandbox/ in a worktree or a subdirectory is no longer sourced on the host - run (before and after the session) and doctor report planted .git entries, redirected worktrees and per-worktree overrides - hide Firefox, Claude, VS Code, Cursor and Codex configs
This commit is contained in:
+31
-5
@@ -55,19 +55,44 @@ ocsb_defaults() {
|
||||
OCSB_HIDE_HOME=(.gnupg .aws .azure .config/gcloud .kube/config .docker/config.json .netrc
|
||||
.git-credentials .config/gh/hosts.yml .password-store .local/share/keyrings .mozilla
|
||||
.config/google-chrome .config/chromium .config/BraveSoftware .claude .codex
|
||||
.config/opencode .local/share/opencode .Xauthority)
|
||||
.config/opencode .local/share/opencode .Xauthority .config/mozilla .config/Claude .config/Code
|
||||
.config/Cursor .config/Codex)
|
||||
OCSB_SKILLS=(opencode-sandbox) # skills mounted for the agent (names of skill directories)
|
||||
OCSB_SKILL_DIRS=() # extra directories to look for skills in
|
||||
OCSB_PLUGINS=(skill-compact-resurrect/skill-compact-resurrect.v2.ts) # files in plugins/
|
||||
OCSB_ENV=() # extra KEY=VALUE for OpenCode
|
||||
}
|
||||
|
||||
# Trusted projects (init / trust), one physical path per line. Only these are loaded:
|
||||
# sandbox.conf is bash run on the host, and the agent can write anywhere in the project
|
||||
# tree, so a .opencode-sandbox/ found in a worktree or a subdirectory proves nothing.
|
||||
ocsb_trust_file() { echo "${XDG_CONFIG_HOME:-${HOME}/.config}/opencode-sandbox/projects"; }
|
||||
ocsb_trusted() { [[ -f $(ocsb_trust_file) ]] && grep -Fxq -- "$1" "$(ocsb_trust_file)"; }
|
||||
ocsb_trust() {
|
||||
local dir f
|
||||
dir=$(cd "${1:-.}" && pwd -P) || ocsb_die "no such directory: ${1}"
|
||||
[[ -f ${dir}/.opencode-sandbox/sandbox.conf ]] || ocsb_die "no ${dir}/.opencode-sandbox/sandbox.conf"
|
||||
[[ -z ${OCSB_IN_SANDBOX:-} ]] || ocsb_die "trust is a host command"
|
||||
ocsb_trusted "${dir}" && { echo "already trusted: ${dir}"; return 0; }
|
||||
f=$(ocsb_trust_file)
|
||||
mkdir -p "$(dirname "${f}")"
|
||||
echo "${dir}" >>"${f}"
|
||||
echo "✓ trusted: ${dir}"
|
||||
}
|
||||
|
||||
# Nearest trusted project at or above the directory
|
||||
ocsb_find_project() {
|
||||
local dir=${1:-$PWD}
|
||||
local dir untrusted=""
|
||||
dir=$(cd "${1:-.}" && pwd -P)
|
||||
while [[ ${dir} != / ]]; do
|
||||
[[ -f ${dir}/.opencode-sandbox/sandbox.conf ]] && { echo "${dir}"; return 0; }
|
||||
if [[ -f ${dir}/.opencode-sandbox/sandbox.conf ]]; then
|
||||
ocsb_trusted "${dir}" && { echo "${dir}"; return 0; }
|
||||
untrusted=${untrusted:-${dir}}
|
||||
fi
|
||||
dir=$(dirname "${dir}")
|
||||
done
|
||||
[[ -z ${untrusted} ]] ||
|
||||
ocsb_warn "${untrusted} is not a trusted project: read its .opencode-sandbox/ (sandbox.conf and hooks run on the host), then: opencode-sandbox trust ${untrusted}"
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -82,12 +107,13 @@ ocsb_load_project() {
|
||||
|
||||
OCSB_PROJECT_DIR=""
|
||||
if [[ -n ${OCSB_PROJECT:-} ]]; then
|
||||
OCSB_PROJECT_DIR=$(cd "${OCSB_PROJECT}" && pwd)
|
||||
OCSB_PROJECT_DIR=$(cd "${OCSB_PROJECT}" && pwd -P)
|
||||
ocsb_trusted "${OCSB_PROJECT_DIR}" || ocsb_die "OCSB_PROJECT=${OCSB_PROJECT} is not a trusted project (opencode-sandbox trust)"
|
||||
else
|
||||
OCSB_PROJECT_DIR=$(ocsb_find_project) || true
|
||||
fi
|
||||
if [[ -z ${OCSB_PROJECT_DIR} || ! -f ${OCSB_PROJECT_DIR}/.opencode-sandbox/sandbox.conf ]]; then
|
||||
[[ ${optional} == 1 ]] || ocsb_die "no .opencode-sandbox/sandbox.conf here or above (run: opencode-sandbox init)"
|
||||
[[ ${optional} == 1 ]] || ocsb_die "no trusted .opencode-sandbox/ here or above (new project: opencode-sandbox init)"
|
||||
fi
|
||||
export OCSB_PROJECT_DIR
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ ocsb_doctor() {
|
||||
echo "Project: ${OCSB_PROJECT_DIR} (name ${OCSB_NAME}, state ${OCSB_STATE_DIR})"
|
||||
source "${OCSB_HOME}/lib/egress.sh"
|
||||
ocsb_egress status
|
||||
source "${OCSB_HOME}/lib/run.sh"
|
||||
if ocsb_git_audit; then echo " ✓ no planted git metadata in the project and worktrees"; else fail=1; fi
|
||||
else
|
||||
echo "Project: none here (opencode-sandbox init)"
|
||||
fi
|
||||
|
||||
@@ -17,12 +17,14 @@ ocsb_init() {
|
||||
mkdir -p "${root}/.cc-safety-net"
|
||||
cp "${t}/cc-safety-net-policy.json" "${root}/.cc-safety-net/policy.json"
|
||||
fi
|
||||
ocsb_trust "${root}" >/dev/null
|
||||
cat <<EOF
|
||||
✓ created ${root}/.opencode-sandbox/
|
||||
sandbox.conf settings (ports, writable paths, skills, worktrees) — commit it
|
||||
egress.conf domains for this project — commit it
|
||||
hooks/ worktree hooks: drop .example and chmod +x to enable
|
||||
local/ personal overrides (sandbox.conf, egress.conf), git-ignored
|
||||
The project is trusted on this machine; a clone elsewhere needs: opencode-sandbox trust
|
||||
|
||||
Next:
|
||||
opencode-sandbox doctor check the host
|
||||
|
||||
+57
-1
@@ -117,6 +117,59 @@ ocsb_start_dbus_proxy() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Files the host trusts and runs outside the sandbox stay read-only for the agent: the
|
||||
# sandbox config and hooks, the safety-net policies, the egress rules, git hooks and config
|
||||
# (core.fsmonitor runs on any git status — a shell prompt is enough). .git is first bound
|
||||
# onto itself: a mount point cannot be renamed, so it cannot be swapped for a planted one.
|
||||
ocsb_protect_args() {
|
||||
local d=${OCSB_PROJECT_DIR} p hooks
|
||||
mkdir -p "${d}/.cc-safety-net" "${OCSB_STATE_DIR}/cc-safety-net"
|
||||
for p in "${d}/.opencode-sandbox" "${d}/.cc-safety-net" "${OCSB_STATE_DIR}/cc-safety-net" "${OCSB_STATE_DIR}/egress"; do
|
||||
OCSB_BWRAP+=(--ro-bind "${p}" "${p}")
|
||||
done
|
||||
if [[ -d ${d}/.git ]]; then
|
||||
mkdir -p "${d}/.git/hooks"
|
||||
OCSB_BWRAP+=(--bind "${d}/.git" "${d}/.git")
|
||||
for p in "${d}"/.git/{hooks,config} "${d}"/.git/modules/*/{hooks,config}; do
|
||||
[[ -e ${p} ]] && OCSB_BWRAP+=(--ro-bind "${p}" "${p}")
|
||||
done
|
||||
fi
|
||||
# core.hooksPath inside the project (husky and the like)
|
||||
hooks=$(git -C "${d}" config --get core.hooksPath 2>/dev/null) || true
|
||||
if [[ -n ${hooks} ]]; then
|
||||
hooks=$(cd "${d}" && realpath -m -- "${hooks/#\~/${HOME}}")
|
||||
[[ ${hooks} == "${d}"/* && -e ${hooks} ]] && OCSB_BWRAP+=(--ro-bind "${hooks}" "${hooks}")
|
||||
fi
|
||||
}
|
||||
|
||||
# Git repositories planted for the host's git. The main .git is read-only for the agent, but
|
||||
# it can still create a .git anywhere it writes, point a worktree's .git elsewhere, or add
|
||||
# per-worktree overrides — and a repository's config can run commands. Report all of that.
|
||||
ocsb_git_audit() {
|
||||
local main=${OCSB_PROJECT_DIR}/.git f gitdir found=() roots=()
|
||||
[[ -d ${main} ]] || return 0
|
||||
roots=("${OCSB_PROJECT_DIR}")
|
||||
[[ -d ${OCSB_WORKTREES_DIR} ]] && roots+=("$(realpath -- "${OCSB_WORKTREES_DIR}")")
|
||||
while IFS= read -r -d '' f; do
|
||||
[[ ${f} == "${main}" ]] && continue
|
||||
if [[ -f ${f} && ! -L ${f} ]]; then
|
||||
gitdir=$(sed -n '1s/^gitdir: //p' "${f}")
|
||||
[[ ${gitdir} == "${main}/worktrees/"* && ${gitdir} != */..* && $(cat "${gitdir}/gitdir" 2>/dev/null) == "${f}" ]] && continue
|
||||
fi
|
||||
found+=("${f}")
|
||||
done < <(find "${roots[@]}" -name .git -prune -print0 2>/dev/null)
|
||||
for f in "${main}"/worktrees/*/; do
|
||||
[[ -d ${f} ]] || continue
|
||||
[[ $(cat "${f}commondir" 2>/dev/null) == ../.. ]] || found+=("${f}commondir")
|
||||
[[ -e ${f}config.worktree ]] && found+=("${f}config.worktree")
|
||||
done
|
||||
[[ -e ${main}/commondir ]] && found+=("${main}/commondir")
|
||||
((${#found[@]} == 0)) && return 0
|
||||
ocsb_warn "unexpected git metadata — check it before running git there (a repository config can run commands):"
|
||||
printf ' %s\n' "${found[@]}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# bubblewrap arguments for the sandbox (variant: read-only host, writable project)
|
||||
ocsb_bwrap_args() {
|
||||
local p f uid
|
||||
@@ -174,6 +227,7 @@ ocsb_bwrap_args() {
|
||||
OCSB_BWRAP+=(--ro-bind /dev/null "${p}")
|
||||
fi
|
||||
done
|
||||
ocsb_protect_args
|
||||
if [[ -n ${OCSB_DBUS_PROXY_PID:-} && -S $(ocsb_dbus_sock) ]]; then
|
||||
OCSB_BWRAP+=(--ro-bind "$(ocsb_dbus_sock)" "/run/user/${uid}/bus")
|
||||
fi
|
||||
@@ -250,12 +304,13 @@ ocsb_run() {
|
||||
ssh-add -l >/dev/null 2>&1 || ocsb_warn "ssh-agent has no keys — git over SSH will fail in the sandbox (ssh-add)"
|
||||
if ocsb_start_dbus_proxy; then
|
||||
env+=("DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus")
|
||||
trap 'kill "${OCSB_DBUS_PROXY_PID}" 2>/dev/null || true' EXIT
|
||||
else
|
||||
unset OCSB_DBUS_PROXY_PID
|
||||
env+=("DBUS_SESSION_BUS_ADDRESS=")
|
||||
fi
|
||||
ocsb_bwrap_args
|
||||
# On any exit (Ctrl-C too): stop the bus proxy, look for what the session planted
|
||||
trap 'kill "${OCSB_DBUS_PROXY_PID:-}" 2>/dev/null; ocsb_git_audit || true' EXIT
|
||||
cmd=(bwrap "${OCSB_BWRAP[@]}" -- "${cmd[@]}")
|
||||
# No permission prompts in the sandbox: permission rules from all configs form one
|
||||
# list, the last match wins, and OPENCODE_CONFIG_CONTENT is the last document.
|
||||
@@ -282,6 +337,7 @@ ocsb_run() {
|
||||
for kv in "${env[@]}"; do inner+=" $(printf '%q' "${kv}")"; done
|
||||
for kv in "${cmd[@]}"; do inner+=" $(printf '%q' "${kv}")"; done
|
||||
|
||||
[[ ${OCSB_SANDBOX} == 1 ]] && { ocsb_git_audit || true; }
|
||||
local opened=0 line
|
||||
cd "${OCSB_PROJECT_DIR}" || return
|
||||
sg "${OCSB_NETLOCK_GROUP}" -c "${inner}" 2>&1 | while IFS= read -r line; do
|
||||
|
||||
Reference in New Issue
Block a user