From fa1fb672d2cb49a1afe1bf5ed57d85252060b98e Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Tue, 27 Oct 2026 13:14:42 -0400 Subject: [PATCH 1/8] feat(bwrap): tell the agent about its sandbox Agents guessed wrong about the sandbox, for example taking a read-only bind mount of a resolved symlink for a hard link. build_sandbox_context writes a short markdown description of the session from the final BWRAP_ARGS (mounts under $HOME with rw/ro/tmpfs, system mounts collapsed, what is not mounted, disabled tools, names of passed-in variables) and mounts it read-only at ~/.sandbox/AGENTS.md with --ro-bind-data on a file descriptor, so nothing is written to the host. BWRAP_SANDBOX and BWRAP_SANDBOX_CONTEXT identify the sandbox. --dry-run prints the file and --no-sandbox-context turns it off. bwopencode2 loads it through OPENCODE_CONFIG_CONTENT, whose instructions list is added to the user's own (tested with 2.1.4), so no user file is replaced. The masked binary list moves to file scope so the file can name it. Assisted-by: opencode:claude-sonnet-5-5 --- bin/bwopencode2 | 39 ++++ lib/bwrap_sandbox_lib.sh | 379 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 404 insertions(+), 14 deletions(-) diff --git a/bin/bwopencode2 b/bin/bwopencode2 index fc4919f..c5d82ab 100755 --- a/bin/bwopencode2 +++ b/bin/bwopencode2 @@ -103,6 +103,16 @@ # A nested --ro-path/--rw-path overrides the one it sits under, # so `--rw-path DIR --ro-path DIR/sub` protects DIR/sub. # --github-tokens Forward any GH_TOKEN_* environment variables into the sandbox. +# --no-sandbox-context +# Do not generate the sandbox context file (see below). +# +# Sandbox context: a short markdown file describing this sandbox (what is +# mounted read-only or read-write, what is lost at exit, which tools are +# disabled) is built from the final mounts and mounted read-only at +# ~/.sandbox/AGENTS.md, and BWRAP_SANDBOX / BWRAP_SANDBOX_CONTEXT are set. +# OpenCode 2 loads it through OPENCODE_CONFIG_CONTENT, whose "instructions" +# list is added to the user's own instructions (verified with 2.1.4); no user +# file is overwritten. Not added to a --exec command. # # There is no --init-auth: OpenCode 2 stores credentials in its database # (the `credential` table in opencode.db), which lives in the persistent @@ -154,6 +164,11 @@ parse_wrapper_args() { FORCE_NEW_SESSION=1 shift ;; + --no-sandbox-context) + # shellcheck disable=SC2034 # consumed by sourced bwrap_sandbox_lib.sh + SANDBOX_CONTEXT_ENABLED=0 + shift + ;; --ro-path) if [[ $# -lt 2 ]]; then echo "Error: --ro-path requires a path argument." >&2 @@ -708,6 +723,29 @@ build_opencode2_env() { fi } +# ── Sandbox context ────────────────────────────────────────────── +# Mount the context file (see build_sandbox_context) and make OpenCode 2 load +# it. OPENCODE_CONFIG_CONTENT is an inline config layered over the user's: +# its "instructions" list is added to theirs, not substituted for it. Any +# host OPENCODE_CONFIG_CONTENT is dropped by build_opencode2_env, so this is +# the only value the sandbox sees. Must run after build_opencode2_env. +build_opencode2_context() { + # shellcheck disable=SC2034 # consumed by sourced bwrap_sandbox_lib.sh + SANDBOX_CONTEXT_NOTES=( + "Only run \`opencode2\` with \`--standalone\`. Without it the CLI talks to a shared background service (port 49374) that runs tools OUTSIDE this sandbox." + "Plan mode writes plan files to \`~/.opencode/plan/\`, which is not mounted: they are lost at exit unless the plan-mirror plugin keeps them." + ) + build_sandbox_context bwopencode2 + [[ -n "${SANDBOX_CONTEXT_PATH}" && -z "${TEST_CMD}" ]] || return 0 + + local _path_json + if ! _path_json="$(_bwrap_json_quote "${SANDBOX_CONTEXT_PATH}")"; then + echo "Warning: bwopencode2: cannot pass the sandbox context file to OpenCode (unusual characters in the path)." >&2 + return 0 + fi + SANDBOX_ENV+=("OPENCODE_CONFIG_CONTENT={\"instructions\":[${_path_json}]}") +} + # ═══════════════════════════════════════════════════════════════════ # Main # ═══════════════════════════════════════════════════════════════════ @@ -773,6 +811,7 @@ main() { # Environment variables build_env_vars build_opencode2_env + build_opencode2_context # Mask dangerous binaries as the very last step: a mask is hidden by any # later bind that covers it (e.g. --ro-path /usr or /usr/bin). diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index d8a00be..05242d2 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -59,6 +59,18 @@ EXTRA_RO_PATHS=() # shellcheck disable=SC2034 # used by tool wrappers that source this library EXTRA_RW_PATHS=() +# Sandbox context file (see "Sandbox context" below). The launchers set +# SANDBOX_CONTEXT_ENABLED=0 for --no-sandbox-context and fill +# SANDBOX_CONTEXT_NOTES with tool-specific markdown lines before calling +# build_sandbox_context. SANDBOX_CONTEXT_PATH is the path of the file inside +# the sandbox once build_sandbox_context has emitted it (empty otherwise). +# shellcheck disable=SC2034 # used by tool wrappers that source this library +SANDBOX_CONTEXT_ENABLED=1 +# shellcheck disable=SC2034 # used by tool wrappers that source this library +SANDBOX_CONTEXT_NOTES=() +SANDBOX_CONTEXT_PATH="" +_SANDBOX_CONTEXT_TEXT="" + # Tool binary (set by bwrap_resolve_tool) _TOOL_BIN="" _TOOL_CMD=() @@ -1735,23 +1747,26 @@ build_base_sandbox() { # Keyring – kernel keyring manipulation # Priv-escalation – sudo/su/pkexec # Namespace – sandbox escape via nsenter/unshare/chroot +# +# _MASKED_BINS is also listed in the sandbox context file (see +# build_sandbox_context), so it lives at file scope. +_MASKED_BINS=( + # SSH + ssh scp sftp ssh-agent ssh-add ssh-keygen ssh-keyscan + # Network + telnet nc ncat netcat rsync rsh rlogin rexec + # Kerberos + kinit klist kdestroy kswitch + # Keyring + keyctl + # Privilege escalation + sudo su pkexec + # Namespace / sandbox escape + nsenter unshare chroot +) build_binary_masks() { [[ "${HAS_BIND_OVER_RO}" -eq 1 ]] || return 0 - local _MASKED_BINS=( - # SSH - ssh scp sftp ssh-agent ssh-add ssh-keygen ssh-keyscan - # Network - telnet nc ncat netcat rsync rsh rlogin rexec - # Kerberos - kinit klist kdestroy kswitch - # Keyring - keyctl - # Privilege escalation - sudo su pkexec - # Namespace / sandbox escape - nsenter unshare chroot - ) # Canonicalize + dedupe the search prefixes once. On merged-/usr systems # /bin, /sbin, /usr/bin, /usr/sbin resolve to the same directory; collapsing # them here avoids re-checking (and re-resolving) the same candidate under @@ -2656,6 +2671,337 @@ build_env_vars() { pass_through_if_set GIT_COMMITTER_EMAIL } +# ═══════════════════════════════════════════════════════════════════ +# Sandbox context +# ═══════════════════════════════════════════════════════════════════ +# An agent that does not know it runs in a sandbox guesses: it takes a +# read-only bind mount of a resolved symlink for a hard link, or expects +# ~/.ssh to exist. build_sandbox_context writes a short markdown description +# of THIS sandbox, built from the final BWRAP_ARGS so it always matches the +# real mounts, and mounts it read-only at ~/.sandbox/AGENTS.md. Each launcher +# then makes its tool load the file in addition to the user's own +# instruction files (never in place of them; no user file is touched). +# +# The file travels as --ro-bind-data on a file descriptor, so nothing is +# written to the host. Variables set inside the sandbox: +# BWRAP_SANDBOX launcher name (for example bwopencode2) +# BWRAP_SANDBOX_CONTEXT path of the context file + +# Row data collected from BWRAP_ARGS by _bwrap_context_collect, keyed by the +# destination path. _CTX_DESTS keeps first-seen order; a later mount at the +# same destination replaces the earlier one, as in bwrap. +declare -A _CTX_MODE=() +declare -A _CTX_SRC=() +_CTX_DESTS=() + +# _bwrap_context_collect +# Walk BWRAP_ARGS and record every bind, tmpfs and symlink destination. +# *-try binds whose source is missing are skipped (bwrap skips them too), as +# is anything bound from /dev/null (the tty mask and, later, the binary +# masks). +_bwrap_context_collect() { + _CTX_DESTS=() + _CTX_MODE=() + _CTX_SRC=() + local _i=0 _flag _a1 _a2 _mode _src _dest + while [[ ${_i} -lt ${#BWRAP_ARGS[@]} ]]; do + _flag="${BWRAP_ARGS[${_i}]}" + _bwrap_flag_arity "${_flag}" + _a1="${BWRAP_ARGS[$((_i + 1))]:-}" + _a2="${BWRAP_ARGS[$((_i + 2))]:-}" + _i=$((_i + 1 + _BWRAP_FLAG_ARITY)) + _src="" + case "${_flag}" in + --ro-bind | --ro-bind-try) + _mode=ro + _src="${_a1}" + _dest="${_a2}" + ;; + --bind | --bind-try) + _mode=rw + _src="${_a1}" + _dest="${_a2}" + ;; + --tmpfs) + _mode=tmpfs + _dest="${_a1}" + ;; + --symlink) + _mode="link" + _src="${_a1}" + _dest="${_a2}" + ;; + *) + continue + ;; + esac + case "${_flag}" in + *-try) [[ -e "${_src}" ]] || continue ;; + esac + [[ "${_src}" == /dev/null ]] && continue + [[ -n "${_CTX_MODE["${_dest}"]+x}" ]] || _CTX_DESTS+=("${_dest}") + _CTX_MODE["${_dest}"]="${_mode}" + _CTX_SRC["${_dest}"]="${_src}" + done +} + +# _bwrap_context_display PATH +# Print PATH with a leading $HOME shown as ~. +_bwrap_context_display() { + local _p="$1" + if [[ "${_p}" == "${_HOME}" ]]; then + printf '~' + elif [[ "${_p}" == "${_HOME}"/* ]]; then + # shellcheck disable=SC2088 # a literal tilde is wanted + printf '~/%s' "${_p#"${_HOME}"/}" + else + printf '%s' "${_p}" + fi +} + +# _bwrap_context_access MODE +# Print the wording used for a mount mode in the context file. +_bwrap_context_access() { + case "$1" in + ro) printf 'ro' ;; + rw) printf 'rw' ;; + tmpfs) printf 'tmpfs' ;; + link) printf 'symlink' ;; + esac +} + +# _bwrap_context_render TOOL DEST +# Build the context file text for launcher TOOL (to be mounted at DEST) in +# _SANDBOX_CONTEXT_TEXT, from BWRAP_ARGS, SANDBOX_ENV and the launcher's +# SANDBOX_CONTEXT_NOTES. Must run after every mount has been emitted +# except the binary masks (the masked names come from _MASKED_BINS). +_bwrap_context_render() { + local _tool="$1" _dest_file="$2" _nl=$'\n' + local _t="" _d _key _mode _line _n _c _tab=$'\t' _names _cnt + + _bwrap_context_collect + + local -a _sorted=() + while IFS= read -r _d; do + _sorted+=("${_d}") + done < <(printf '%s\n' "${_CTX_DESTS[@]+"${_CTX_DESTS[@]}"}" | LC_ALL=C sort) + + # Home rows, grouped by (parent directory, mode); other destinations. + local -a _gkeys=() _sys=() _etc=() _other=() + declare -A _gnames=() _gcount=() _gfirst=() _seen=() + local _par + for _d in "${_sorted[@]+"${_sorted[@]}"}"; do + [[ -n "${_d}" ]] || continue + _mode="${_CTX_MODE["${_d}"]}" + case "${_d}" in + "${_HOME}") continue ;; + "${_HOME}"/*) + _par="${_d%/*}" + _key="${_par}${_tab}${_mode}" + if [[ -z "${_gcount["${_key}"]:-}" ]]; then + _gkeys+=("${_key}") + _gcount["${_key}"]=0 + _gnames["${_key}"]="" + _gfirst["${_key}"]="${_d}" + fi + _gnames["${_key}"]+="${_gnames["${_key}"]:+,}${_d##*/}" + _c="${_gcount["${_key}"]}" + _gcount["${_key}"]=$((_c + 1)) + ;; + /proc | /dev | /dev/* | /tmp | /sys/*) ;; + /usr | /bin | /sbin | /lib | /lib64) _sys+=("${_d}") ;; + /usr/*) ;; + /etc/*) + _n="${_d#/etc/}" + _n="${_n%%/*}" + if [[ -z "${_seen["etc:${_n}"]:-}" ]]; then + _seen["etc:${_n}"]=1 + _etc+=("${_n}") + fi + ;; + *) _other+=("${_d}") ;; + esac + done + + _t+="# Sandbox context${_nl}${_nl}" + _t+="This session runs inside a bubblewrap (bwrap) sandbox started by \`${_tool}\`" + _t+=" at $(date -u +%Y-%m-%dT%H:%M:%SZ)." + _t+=" This file describes this session; trust it over assumptions about the host.${_nl}${_nl}" + + _t+="## What this means${_nl}${_nl}" + _t+="- \`\$HOME\` ($(_bwrap_context_display "${_HOME}") = ${_HOME}) is an empty tmpfs. Only the paths listed below exist in it;" + _t+=" anything else you write there is lost when the session ends.${_nl}" + _t+="- Read-only files under \`\$HOME\` that look like links are bind mounts, not hard links." + _t+=" The launcher resolves host symlinks and mounts the target at the expected path," + _t+=" so a path can be a read-only mount of a file that is a symlink on the host," + _t+=" and two paths can show the same inode." + _t+=" \`findmnt -T \` shows the source and \`ro\`/\`rw\`.${_nl}" + _t+="- Edit such a file at its source (for example a dotfiles repository), not at the mount point," + _t+=" and only if the source is mounted read-write below." + _t+=" \"Read-only file system\" on a mount point is expected.${_nl}" + _t+="- \`ln -s\` creates ordinary symlinks, in writable locations only.${_nl}" + _t+="- Read-write mounts are the real host files (often NFS): changes are visible on the host at once.${_nl}" + _t+="- The network is shared with the host. Host processes are not visible (private PID namespace)." + _t+=" There are no SSH or GPG agents and no sudo.${_nl}${_nl}" + + _t+="## Working directory${_nl}${_nl}" + _t+="\`$(_bwrap_context_display "${_PWD}")\`, read-write." + if [[ -n "${_GIT_ROOT}" ]]; then + if [[ "${_PWD}" == "${_GIT_ROOT}" ]]; then + _t+=" It is the root of a git repository, which is mounted whole;" + else + _t+=" It is inside the git repository \`$(_bwrap_context_display "${_GIT_ROOT}")\`, which is mounted whole;" + fi + _t+=" sibling directories of the repository are not mounted." + else + _t+=" It is not a git repository, so only this directory is mounted." + fi + _t+="${_nl}${_nl}" + + _t+="## Mounts under \`\$HOME\`${_nl}${_nl}" + _t+="\`rw\` is the real host files (often NFS): changes persist and show on the host at once." + _t+=" \`ro\` is read-only. \`tmpfs\` is empty and lost at exit, except the mounts listed under it." + _t+="${_nl}${_nl}| Path | Access |${_nl}| --- | --- |${_nl}" + if [[ ${#_gkeys[@]} -eq 0 ]]; then + _t+="| (none) | |${_nl}" + else + while IFS= read -r _key; do + _mode="${_key##*"${_tab}"}" + if [[ "${_gcount["${_key}"]}" -eq 1 ]]; then + _d="${_gfirst["${_key}"]}" + _line="| \`$(_bwrap_context_display "${_d}")\` | $(_bwrap_context_access "${_mode}")" + if [[ "${_mode}" == link ]]; then + _line+=" to \`${_CTX_SRC["${_d}"]}\`" + elif [[ -n "${_CTX_SRC["${_d}"]}" && "${_CTX_SRC["${_d}"]}" != "${_d}" ]]; then + _line+=" (host source \`$(_bwrap_context_display "${_CTX_SRC["${_d}"]}")\`)" + fi + else + _par="${_key%"${_tab}"*}" + _names="${_gnames["${_key}"]}" + _cnt="${_gcount["${_key}"]}" + if [[ "${_cnt}" -gt 4 ]]; then + _names="$(printf '%s' "${_names}" | cut -d, -f1-3),…+$((_cnt - 3))" + fi + _line="| \`$(_bwrap_context_display "${_par}")/{${_names}}\` | $(_bwrap_context_access "${_mode}")" + fi + _t+="${_line} |${_nl}" + done < <(printf '%s\n' "${_gkeys[@]}" | LC_ALL=C sort) + fi + _t+="${_nl}" + + _t+="## Other mounts${_nl}${_nl}" + if [[ ${#_sys[@]} -gt 0 ]]; then + _line="" + for _d in "${_sys[@]}"; do _line+="${_line:+, }\`${_d}\`"; done + _t+="- System, read-only: ${_line}.${_nl}" + fi + if [[ ${#_etc[@]} -gt 0 ]]; then + _line="" + for _n in "${_etc[@]}"; do _line+="${_line:+, }\`${_n}\`"; done + _t+="- \`/etc\`, read-only, selected entries only: ${_line}.${_nl}" + fi + for _d in "${_other[@]+"${_other[@]}"}"; do + _t+="- \`${_d}\`: $(_bwrap_context_access "${_CTX_MODE["${_d}"]}").${_nl}" + done + _t+="- \`/tmp\` is a private tmpfs (lost at exit); \`/proc\` and \`/dev\` are private to the sandbox.${_nl}${_nl}" + + _t+="## Not available${_nl}${_nl}" + _t+="- Not mounted: everything under \`\$HOME\` that is not listed above, including" + _t+=" \`~/.ssh\`, \`~/.gnupg\`, \`~/.aws\`, \`~/.kube\`, \`~/.docker\`, \`~/.netrc\`, \`~/.config/gh\` and shell rc files." + _t+="${_nl}" + if [[ "${HAS_BIND_OVER_RO}" -eq 1 ]]; then + _line="" + for _n in "${_MASKED_BINS[@]}"; do _line+="${_line:+, }\`${_n}\`"; done + _t+="- Disabled (replaced by an empty file, so they fail when run): ${_line}.${_nl}" + else + _t+="- The ssh, sudo and network tools are NOT disabled here (the bwrap on this host is too old to mask them).${_nl}" + fi + _t+="${_nl}" + + _t+="## Environment${_nl}${_nl}" + local -a _names=() + local _entry + for _entry in ${SANDBOX_ENV[@]+"${SANDBOX_ENV[@]}"}; do + _names+=("${_entry%%=*}") + done + if [[ ${#_names[@]} -gt 0 ]]; then + _line="" + while IFS= read -r _n; do _line+="${_line:+, }\`${_n}\`"; done \ + < <(printf '%s\n' "${_names[@]}" | LC_ALL=C sort -u) + _t+="Variables passed in from the host (names only): ${_line}.${_nl}" + else + _t+="No host variables are passed in beyond the standard ones (HOME, USER, PATH, TERM).${_nl}" + fi + _t+="\`BWRAP_SANDBOX=${_tool}\` and \`BWRAP_SANDBOX_CONTEXT=${_dest_file}\` identify this sandbox.${_nl}" + + if [[ ${#SANDBOX_CONTEXT_NOTES[@]} -gt 0 ]]; then + _t+="${_nl}## Notes for ${_tool}${_nl}${_nl}" + for _line in "${SANDBOX_CONTEXT_NOTES[@]}"; do + _t+="- ${_line}${_nl}" + done + fi + + _SANDBOX_CONTEXT_TEXT="${_t}" +} + +# build_sandbox_context TOOL +# Generate the context file for launcher TOOL and emit its mount. Call it +# after every other mount builder and after build_env_vars and the tool's +# own environment builder (it lists SANDBOX_ENV), and before +# build_binary_masks. Does nothing under --no-sandbox-context. Sets +# SANDBOX_CONTEXT_PATH (and _SANDBOX_CONTEXT_TEXT) when the file is mounted. +build_sandbox_context() { + local _tool="$1" + local _dir="${_HOME}/.sandbox" _file="${_HOME}/.sandbox/AGENTS.md" _fd + SANDBOX_CONTEXT_PATH="" + [[ "${SANDBOX_CONTEXT_ENABLED}" -eq 1 ]] || return 0 + + if _bwrap_dest_in_args "${_dir}"; then + echo "Warning: ${_dir} is already used by another mount; the sandbox context file is not provided." >&2 + return 0 + fi + + _bwrap_context_render "${_tool}" "${_file}" + + # The descriptor stays open in this shell until launch_sandbox execs + # bwrap, which reads it for --ro-bind-data and closes it. + exec {_fd}<<< "${_SANDBOX_CONTEXT_TEXT}" + BWRAP_ARGS+=( + --dir "${_dir}" + --ro-bind-data "${_fd}" "${_file}" + ) + SANDBOX_ENV+=( + "BWRAP_SANDBOX=${_tool}" + "BWRAP_SANDBOX_CONTEXT=${_file}" + ) + SANDBOX_CONTEXT_PATH="${_file}" +} + +# _bwrap_json_quote STRING +# Print STRING as a JSON string. Return 1 (printing nothing) when STRING +# holds a control character. +_bwrap_json_quote() { + local _s="$1" + [[ "${_s}" =~ [[:cntrl:]] ]] && return 1 + _s="${_s//\\/\\\\}" + _s="${_s//\"/\\\"}" + printf '"%s"' "${_s}" +} + +# _bwrap_toml_string STRING +# Print STRING as a single-line TOML basic string. Return 1 (printing +# nothing) when STRING holds a control character other than newline or tab. +_bwrap_toml_string() { + local _s="$1" + _s="${_s//$'\r'/}" + _s="${_s//\\/\\\\}" + _s="${_s//\"/\\\"}" + _s="${_s//$'\n'/\\n}" + _s="${_s//$'\t'/\\t}" + [[ "${_s}" =~ [[:cntrl:]] ]] && return 1 + printf '"%s"' "${_s}" +} + # ═══════════════════════════════════════════════════════════════════ # Shared launch functions # ═══════════════════════════════════════════════════════════════════ @@ -2790,6 +3136,11 @@ print_dry_run() { local _arg for _arg in ${TOOL_ARGS[@]+"${TOOL_ARGS[@]}"}; do printf " %q" "${_arg}"; done printf "\n" + if [[ -n "${SANDBOX_CONTEXT_PATH}" ]]; then + printf '\n# --ro-bind-data above reads a file descriptor that a real run fills with this\n' + printf '# sandbox context file, mounted at %s:\n#\n' "${SANDBOX_CONTEXT_PATH}" + printf '%s\n' "${_SANDBOX_CONTEXT_TEXT}" | sed 's/^/# /; s/ *$//' + fi exit 0 } From 192b699b23c2b0846a4c31ea6efde51bca89a27c Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Tue, 27 Oct 2026 13:14:48 -0400 Subject: [PATCH 2/8] feat(bwopencode): load the sandbox context file Same mechanism as bwopencode2: OPENCODE_CONFIG_CONTENT adds the context file to the user's instructions (tested with 1.19.4). Assisted-by: opencode:claude-sonnet-5-5 --- bin/bwopencode | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/bin/bwopencode b/bin/bwopencode index 61fb2a0..ec254ed 100755 --- a/bin/bwopencode +++ b/bin/bwopencode @@ -75,6 +75,16 @@ # accumulate multiple paths. Same blocked-path rules as --ro-path. # A nested --ro-path/--rw-path overrides the one it sits under, # so `--rw-path DIR --ro-path DIR/sub` protects DIR/sub. +# --no-sandbox-context +# Do not generate the sandbox context file (see below). +# +# Sandbox context: a short markdown file describing this sandbox (what is +# mounted read-only or read-write, what is lost at exit, which tools are +# disabled) is built from the final mounts and mounted read-only at +# ~/.sandbox/AGENTS.md, and BWRAP_SANDBOX / BWRAP_SANDBOX_CONTEXT are set. +# OpenCode loads it through OPENCODE_CONFIG_CONTENT, whose "instructions" +# list is added to the user's own instructions (verified with 1.19.4); no user +# file is overwritten. Not added to a --exec command. set -euo pipefail @@ -128,6 +138,11 @@ parse_wrapper_args() { FORCE_NEW_SESSION=1 shift ;; + --no-sandbox-context) + # shellcheck disable=SC2034 # consumed by sourced bwrap_sandbox_lib.sh + SANDBOX_CONTEXT_ENABLED=0 + shift + ;; --ro-path) if [[ $# -lt 2 ]]; then echo "Error: --ro-path requires a path argument." >&2 @@ -196,6 +211,12 @@ Wrapper options (consumed before opencode sees the command line): Off by default. When set, use per-command token selection: GH_TOKEN="$GH_TOKEN_NSLS2" gh pr list -R NSLS2/repo GH_TOKEN="$GH_TOKEN_PERSONAL" gh issue list -R user/repo + --no-sandbox-context + Do not tell the agent about the sandbox. By default a short + description of what is mounted (read-only or read-write), what + is lost at exit and which tools are disabled is mounted at + ~/.sandbox/AGENTS.md and added to OpenCode's instructions + (your own instructions are kept). Use --dry-run to see it. All other arguments are passed through to opencode unchanged. @@ -532,6 +553,26 @@ build_opencode_env() { # Main # ═══════════════════════════════════════════════════════════════════ +# ── Sandbox context ────────────────────────────────────────────── +# Mount the context file (see build_sandbox_context) and make OpenCode load +# it. OPENCODE_CONFIG_CONTENT is an inline config layered over the user's: +# its "instructions" list is added to theirs, not substituted for it. Any +# host OPENCODE_CONFIG_CONTENT is dropped by build_opencode_env, so this is +# the only value the sandbox sees. Must run after build_opencode_env. +build_opencode_context() { + # shellcheck disable=SC2034 # consumed by sourced bwrap_sandbox_lib.sh + SANDBOX_CONTEXT_NOTES=() + build_sandbox_context bwopencode + [[ -n "${SANDBOX_CONTEXT_PATH}" && -z "${TEST_CMD}" ]] || return 0 + + local _path_json + if ! _path_json="$(_bwrap_json_quote "${SANDBOX_CONTEXT_PATH}")"; then + echo "Warning: bwopencode: cannot pass the sandbox context file to OpenCode (unusual characters in the path)." >&2 + return 0 + fi + SANDBOX_ENV+=("OPENCODE_CONFIG_CONTENT={\"instructions\":[${_path_json}]}") +} + main() { bwrap_check_account parse_wrapper_args "$@" @@ -584,6 +625,7 @@ main() { # Environment variables build_env_vars build_opencode_env + build_opencode_context # Mask dangerous binaries as the very last step: a mask is hidden by any # later bind that covers it (e.g. --ro-path /usr or /usr/bin). From eaef40593a1b45fc1b34b640aa91347e531f930b Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Tue, 27 Oct 2026 13:49:34 -0400 Subject: [PATCH 3/8] feat(bwclaude): load the sandbox context file Claude Code gets the context file through --append-system-prompt-file, which adds to the default system prompt and leaves CLAUDE.md alone (tested with 2.1.310). A user-supplied --append-system-prompt[-file] wins; the file is then only mounted and a warning is printed. Assisted-by: opencode:claude-sonnet-5-5 --- bin/bwclaude | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/bin/bwclaude b/bin/bwclaude index 226e0bf..d71da17 100755 --- a/bin/bwclaude +++ b/bin/bwclaude @@ -81,6 +81,17 @@ # accumulate multiple paths. Same blocked-path rules as --ro-path. # A nested --ro-path/--rw-path overrides the one it sits under, # so `--rw-path DIR --ro-path DIR/sub` protects DIR/sub. +# --no-sandbox-context +# Do not generate the sandbox context file (see below). +# +# Sandbox context: a short markdown file describing this sandbox (what is +# mounted read-only or read-write, what is lost at exit, which tools are +# disabled) is built from the final mounts and mounted read-only at +# ~/.sandbox/AGENTS.md, and BWRAP_SANDBOX / BWRAP_SANDBOX_CONTEXT are set. +# Claude Code loads it with --append-system-prompt-file, which adds to the +# default system prompt and leaves CLAUDE.md alone (verified with 2.1.310); +# skipped, with a warning, when the user passes --append-system-prompt[-file]. +# Not added to a --exec command. set -euo pipefail @@ -138,6 +149,11 @@ parse_wrapper_args() { FORCE_NEW_SESSION=1 shift ;; + --no-sandbox-context) + # shellcheck disable=SC2034 # consumed by sourced bwrap_sandbox_lib.sh + SANDBOX_CONTEXT_ENABLED=0 + shift + ;; --ro-path) if [[ $# -lt 2 ]]; then echo "Error: --ro-path requires a path argument." >&2 @@ -212,6 +228,13 @@ Wrapper options (consumed before claude sees the command line): Off by default. When set, use per-command token selection: GH_TOKEN="$GH_TOKEN_NSLS2" gh pr list -R NSLS2/repo GH_TOKEN="$GH_TOKEN_PERSONAL" gh issue list -R user/repo + --no-sandbox-context + Do not tell the agent about the sandbox. By default a short + description of what is mounted (read-only or read-write), what + is lost at exit and which tools are disabled is mounted at + ~/.sandbox/AGENTS.md and is added to Claude Code's + system prompt (CLAUDE.md is kept). + Use --dry-run to see it. All other arguments are passed through to claude unchanged. @@ -513,6 +536,33 @@ build_claude_bin_mount() { # Main # ═══════════════════════════════════════════════════════════════════ +# ── Sandbox context ────────────────────────────────────────────── +# Mount the context file (see build_sandbox_context) and make Claude Code load +# it. --append-system-prompt-file adds to the default system prompt and leaves +# CLAUDE.md alone. The user's own --append-system-prompt[-file] wins: two +# appends are not known to combine, so the file is then only mounted. Must run +# after build_claude_env (which may prepend --debug --verbose). +build_claude_context() { + # shellcheck disable=SC2034 # consumed by sourced bwrap_sandbox_lib.sh + SANDBOX_CONTEXT_NOTES=() + build_sandbox_context bwclaude + [[ -n "${SANDBOX_CONTEXT_PATH}" && -z "${TEST_CMD}" ]] || return 0 + + local _arg + for _arg in ${TOOL_ARGS[@]+"${TOOL_ARGS[@]}"}; do + case "${_arg}" in + --) + break + ;; + --append-system-prompt | --append-system-prompt=* | --append-system-prompt-file | --append-system-prompt-file=*) + echo "Warning: bwclaude: ${_arg%%=*} was given, so Claude Code is not told to load ${SANDBOX_CONTEXT_PATH} (the file is still mounted)." >&2 + return 0 + ;; + esac + done + TOOL_ARGS=(--append-system-prompt-file "${SANDBOX_CONTEXT_PATH}" ${TOOL_ARGS[@]+"${TOOL_ARGS[@]}"}) +} + main() { bwrap_check_account parse_wrapper_args "$@" @@ -560,6 +610,7 @@ main() { # Environment variables build_env_vars build_claude_env + build_claude_context # Mask dangerous binaries as the very last step: a mask is hidden by any # later bind that covers it (e.g. --ro-path /usr or /usr/bin). From 84241534b82746b85007e9776c00509eefd5a7f6 Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Tue, 27 Oct 2026 13:49:36 -0400 Subject: [PATCH 4/8] feat(bwcodex): load the sandbox context file Codex gets the context through -c developer_instructions, which is a separate message and leaves AGENTS.md alone (tested with 0.164.0). It is one value, so it is skipped with a warning when a config.toml (system, user or project) or the command line already sets it. Assisted-by: opencode:claude-sonnet-5-5 --- bin/bwcodex | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/bin/bwcodex b/bin/bwcodex index c217ca8..eed77a2 100755 --- a/bin/bwcodex +++ b/bin/bwcodex @@ -98,6 +98,17 @@ # accumulate multiple paths. Same blocked-path rules as --ro-path. # A nested --ro-path/--rw-path overrides the one it sits under, # so `--rw-path DIR --ro-path DIR/sub` protects DIR/sub. +# --no-sandbox-context +# Do not generate the sandbox context file (see below). +# +# Sandbox context: a short markdown file describing this sandbox (what is +# mounted read-only or read-write, what is lost at exit, which tools are +# disabled) is built from the final mounts and mounted read-only at +# ~/.sandbox/AGENTS.md, and BWRAP_SANDBOX / BWRAP_SANDBOX_CONTEXT are set. +# Codex loads it through -c developer_instructions=..., which is sent as a +# separate message and leaves AGENTS.md alone (verified with 0.164.0); skipped, +# with a warning, when a config.toml or the command line already sets it. +# Not added to a --exec command. set -euo pipefail @@ -160,6 +171,11 @@ parse_wrapper_args() { FORCE_NEW_SESSION=1 shift ;; + --no-sandbox-context) + # shellcheck disable=SC2034 # consumed by sourced bwrap_sandbox_lib.sh + SANDBOX_CONTEXT_ENABLED=0 + shift + ;; --ro-path) if [[ $# -lt 2 ]]; then echo "Error: --ro-path requires a path argument." >&2 @@ -240,6 +256,13 @@ Wrapper options (consumed before codex sees the command line): Off by default. When set, use per-command token selection: GH_TOKEN="$GH_TOKEN_NSLS2" gh pr list -R NSLS2/repo GH_TOKEN="$GH_TOKEN_PERSONAL" gh issue list -R user/repo + --no-sandbox-context + Do not tell the agent about the sandbox. By default a short + description of what is mounted (read-only or read-write), what + is lost at exit and which tools are disabled is mounted at + ~/.sandbox/AGENTS.md and passed to Codex as + developer_instructions (AGENTS.md is kept). + Use --dry-run to see it. All other arguments are passed through to codex unchanged. @@ -517,6 +540,40 @@ build_codex_bin_mount() { # Main # ═══════════════════════════════════════════════════════════════════ +# ── Sandbox context ────────────────────────────────────────────── +# Mount the context file (see build_sandbox_context) and make Codex load it. +# -c developer_instructions=... is sent as a separate message and leaves +# AGENTS.md alone. It is a single value, so it is not set when the user's +# config.toml (system, user or project) or command line already sets it: that +# would replace theirs. The file is then only mounted. +build_codex_context() { + # shellcheck disable=SC2034 # consumed by sourced bwrap_sandbox_lib.sh + SANDBOX_CONTEXT_NOTES=() + build_sandbox_context bwcodex + [[ -n "${SANDBOX_CONTEXT_PATH}" && -z "${TEST_CMD}" ]] || return 0 + + local _f _arg _toml + for _f in "${SYSTEM_CODEX_CONFIG_FILE}" "${CODEX_CONFIG_FILE}" \ + "${_PWD}/.codex/config.toml" "${_GIT_ROOT:-${_PWD}}/.codex/config.toml"; do + if [[ -r "${_f}" ]] && grep -qE '^[[:space:]]*developer_instructions[[:space:]]*=' "${_f}"; then + echo "Warning: bwcodex: ${_f} sets developer_instructions, so Codex is not told to load ${SANDBOX_CONTEXT_PATH} (the file is still mounted)." >&2 + return 0 + fi + done + for _arg in ${TOOL_ARGS[@]+"${TOOL_ARGS[@]}"}; do + [[ "${_arg}" == -- ]] && break + if [[ "${_arg}" == *developer_instructions=* ]]; then + echo "Warning: bwcodex: developer_instructions was given on the command line, so Codex is not told to load ${SANDBOX_CONTEXT_PATH} (the file is still mounted)." >&2 + return 0 + fi + done + if ! _toml="$(_bwrap_toml_string "${_SANDBOX_CONTEXT_TEXT}")"; then + echo "Warning: bwcodex: cannot pass the sandbox context to Codex (unusual characters); the file is still mounted at ${SANDBOX_CONTEXT_PATH}." >&2 + return 0 + fi + TOOL_ARGS=(-c "developer_instructions=${_toml}" ${TOOL_ARGS[@]+"${TOOL_ARGS[@]}"}) +} + main() { bwrap_check_account parse_wrapper_args "$@" @@ -564,6 +621,7 @@ main() { # Environment variables build_env_vars build_codex_env + build_codex_context # Mask dangerous binaries as the very last step: a mask is hidden by any # later bind that covers it (e.g. --ro-path /usr or /usr/bin). From 9e0a6fe10d9ac7d00550828105f65773baadf163 Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Tue, 27 Oct 2026 13:49:38 -0400 Subject: [PATCH 5/8] feat(bwcopilot): load the sandbox context file Copilot reads the AGENTS.md in ~/.sandbox through COPILOT_CUSTOM_INSTRUCTIONS_DIRS, in addition to its usual instruction files. A host value of the variable was never forwarded, so nothing is replaced. Checked with `copilot instruction list`. Assisted-by: opencode:claude-sonnet-5-5 --- bin/bwcopilot | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/bin/bwcopilot b/bin/bwcopilot index 1f5840a..6f93671 100755 --- a/bin/bwcopilot +++ b/bin/bwcopilot @@ -66,6 +66,17 @@ # accumulate multiple paths. Same blocked-path rules as --ro-path. # A nested --ro-path/--rw-path overrides the one it sits under, # so `--rw-path DIR --ro-path DIR/sub` protects DIR/sub. +# --no-sandbox-context +# Do not generate the sandbox context file (see below). +# +# Sandbox context: a short markdown file describing this sandbox (what is +# mounted read-only or read-write, what is lost at exit, which tools are +# disabled) is built from the final mounts and mounted read-only at +# ~/.sandbox/AGENTS.md, and BWRAP_SANDBOX / BWRAP_SANDBOX_CONTEXT are set. +# Copilot loads it through COPILOT_CUSTOM_INSTRUCTIONS_DIRS=~/.sandbox, which +# adds that directory's AGENTS.md to the instruction files it already reads (a +# host value of the variable is not forwarded, so nothing is replaced). +# Not added to a --exec command. set -euo pipefail @@ -118,6 +129,11 @@ parse_wrapper_args() { FORCE_NEW_SESSION=1 shift ;; + --no-sandbox-context) + # shellcheck disable=SC2034 # consumed by sourced bwrap_sandbox_lib.sh + SANDBOX_CONTEXT_ENABLED=0 + shift + ;; --ro-path) if [[ $# -lt 2 ]]; then echo "Error: --ro-path requires a path argument." >&2 @@ -187,6 +203,13 @@ Wrapper options (consumed before copilot sees the command line): Off by default. When set, use per-command token selection: GH_TOKEN="$GH_TOKEN_NSLS2" gh pr list -R NSLS2/repo GH_TOKEN="$GH_TOKEN_PERSONAL" gh issue list -R user/repo + --no-sandbox-context + Do not tell the agent about the sandbox. By default a short + description of what is mounted (read-only or read-write), what + is lost at exit and which tools are disabled is mounted at + ~/.sandbox/AGENTS.md and added to Copilot's + instructions (your own instruction files are kept). + Use --dry-run to see it. All other arguments are passed through to copilot unchanged. @@ -412,6 +435,19 @@ build_copilot_env() { # Main # ═══════════════════════════════════════════════════════════════════ +# ── Sandbox context ────────────────────────────────────────────── +# Mount the context file (see build_sandbox_context) and make Copilot load it: +# COPILOT_CUSTOM_INSTRUCTIONS_DIRS lists extra directories whose AGENTS.md is +# read in addition to the usual instruction files. A host value of the +# variable is not forwarded (it never was), so nothing is replaced. +build_copilot_context() { + # shellcheck disable=SC2034 # consumed by sourced bwrap_sandbox_lib.sh + SANDBOX_CONTEXT_NOTES=() + build_sandbox_context bwcopilot + [[ -n "${SANDBOX_CONTEXT_PATH}" && -z "${TEST_CMD}" ]] || return 0 + SANDBOX_ENV+=("COPILOT_CUSTOM_INSTRUCTIONS_DIRS=${SANDBOX_CONTEXT_PATH%/*}") +} + main() { bwrap_check_account parse_wrapper_args "$@" @@ -467,6 +503,7 @@ main() { # Environment variables build_env_vars build_copilot_env + build_copilot_context # Mask dangerous binaries as the very last step: a mask is hidden by any # later bind that covers it (e.g. --ro-path /usr or /usr/bin). From 0f3dfd16e5ecee27c8f94da3fbc41be55ec168a2 Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Tue, 27 Oct 2026 13:50:08 -0400 Subject: [PATCH 6/8] docs(bwrap): describe the sandbox context file Assisted-by: opencode:claude-sonnet-5-5 --- README.md | 1 + docs/bw-wrappers.md | 49 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 78ec05a..b1b6f45 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ Common wrapper options: | `--new-session` | Force `bwrap --new-session` (stricter isolation; breaks SIGWINCH) | | `--ro-path PATH`, `--rw-path PATH` | Mount an existing path read-only / read-write (may be repeated and nested) | | `--github-tokens` | Forward `GH_TOKEN_*` variables so `gh` can authenticate inside the sandbox (off by default) | +| `--no-sandbox-context` | Do not tell the agent about the sandbox (by default a short description of the mounts is added to its instructions) | `bwclaude` also supports `--debug`. `bwopencode2` has no `--init-auth` (sign-in persists in its data directory), always runs OpenCode 2 with `--standalone` diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index dc04950..343df8c 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -93,6 +93,7 @@ Every `bw*` wrapper accepts these options: | `--ro-path PATH` | Read-only mount an existing path; its canonical target is mounted at the same canonical destination. Use only narrow paths. | | `--rw-path PATH` | Read-write mount an existing path; its canonical target is mounted at the same canonical destination. Use only narrow paths. | | `--github-tokens` | Forward every `GH_TOKEN_*` environment variable into the sandbox so the agent can authenticate `gh`. Off by default. In `--dry-run` mode, token values are printed as `REDACTED`. Use per-command token selection inside the sandbox: `GH_TOKEN="$GH_TOKEN_NSLS2" gh pr list -R NSLS2/repo` | +| `--no-sandbox-context` | Do not generate the [sandbox context file](#sandbox-context) that tells the agent what is mounted | `--ro-path` and `--rw-path` may be nested: a path nested under another overrides it, so `--rw-path DIR --ro-path DIR/sub` mounts `DIR` read-write but @@ -104,6 +105,51 @@ other mounts and the environment setup, so a path that covers their directory Tool-specific options are listed in each wrapper's section below. +## Sandbox context + +An agent that does not know it runs in a sandbox guesses: it takes a read-only +bind mount of a resolved symlink for a hard link, or expects `~/.ssh` to exist. +Every launcher therefore writes a short markdown description of the session +and makes the tool load it. + +- **Built from the real mounts.** `build_sandbox_context` walks the final + `bwrap` arguments, so the text always matches the session, including + `--ro-path` / `--rw-path` mounts and a working directory outside `$HOME`. +- **Mounted at `~/.sandbox/AGENTS.md`**, read-only, with `--ro-bind-data` on a + file descriptor. Nothing is written to the host, and no user file is + overwritten. `BWRAP_SANDBOX` (launcher name) and `BWRAP_SANDBOX_CONTEXT` + (the path) are set in the sandbox for scripts and skills. +- **Contents** (about 50 lines): `$HOME` is a tmpfs; same-inode files are bind + mounts, not hard links, and are edited at their source; the working directory + and whether it is a git root; a table of the mounts under `$HOME` marked + `rw` / `ro` / `tmpfs` with system mounts collapsed into one line each; what is + not mounted; the disabled binaries; the *names* of passed-in variables (never + values); and tool-specific notes (`bwopencode2`: use `--standalone`, plans in + `~/.opencode/plan` are lost). +- **Snapshot.** The file is written at launch; the mounts of a running sandbox + cannot change, so it stays correct for the whole session. + +How each tool loads the file, always in addition to the user's own instruction +files: + +| Launcher | Mechanism | If the user already sets the same thing | +| --- | --- | --- | +| `bwopencode`, `bwopencode2` | `OPENCODE_CONFIG_CONTENT='{"instructions":[""]}'`; the list is added to the user's `instructions` (tested with 1.19.4 and 2.1.4, including a project `opencode.json`). A host value of this variable is dropped, as before. | Not applicable: lists are combined | +| `bwclaude` | `--append-system-prompt-file `; adds to the default system prompt and leaves `CLAUDE.md` alone | A user `--append-system-prompt[-file]` wins; warning, file only mounted | +| `bwcodex` | `-c developer_instructions=`; a separate message that leaves `AGENTS.md` alone | `developer_instructions` in the system, user or project `config.toml`, or in a `-c` argument, wins; warning, file only mounted | +| `bwcopilot` | `COPILOT_CUSTOM_INSTRUCTIONS_DIRS=~/.sandbox`; Copilot reads that directory's `AGENTS.md` in addition to its usual files | A host value is not forwarded (it never was), so nothing is replaced | + +`--exec` mounts the file and sets the two variables but adds no loader, since +the command is not the agent. + +`--dry-run` prints the generated file after the command (the +`--ro-bind-data` descriptor number is only meaningful in a real run). +`--no-sandbox-context` removes the file, the variables and the loader. + +To check by hand, ask the agent a question only the file can answer ("is a +read-only file under `$HOME` with the same inode as a dotfiles file a hard +link?"), or for Copilot run `bwcopilot instruction list`. + ## Account check Every `bw*` launcher runs `/opt/nsls2/libexec/agent-account-check` first @@ -548,4 +594,5 @@ the entire Codex process tree, not just agent-issued commands. Shared sandbox-building primitives sourced by all `bw*` wrappers. Not intended to be executed directly. The library header documents the public function surface (sandbox construction, dynamic mount discovery, -binary masking, environment scrubbing). +binary masking, environment scrubbing, and `build_sandbox_context` with its +`SANDBOX_CONTEXT_*` variables; see [Sandbox context](#sandbox-context)). From 4a3762e048802f72af6135e5ecdfcda3db0ce77d Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Tue, 27 Oct 2026 14:09:13 -0400 Subject: [PATCH 7/8] fix(bwrap): make the sandbox context match the effective mounts Review feedback on the context file, each case checked with --dry-run: - Record --file and --bind-data destinations as writable copies (mode copy), so config files copied into an otherwise empty tmpfs are listed. - A later mount over a parent now hides the child mounts recorded earlier (for example --ro-path on the working directory covers .opencode). - Clean control characters, | and backticks from every path once, so a newline in a mount path no longer splits keys and aborts the launch under set -u, and the table cannot break. - Keep mount modes under /usr and /etc: a writable exception such as --rw-path /usr/local or /etc/hosts is listed on its own, not folded into the read-only summary. - Take the working directory's access from the effective mounts. - List every name in the home table; only system mounts are collapsed. - Name credential and rc files as not mounted only when no mount exposes them, so bwcopilot's gh hosts.yml and --ro-path ~/.zshrc are not denied. Assisted-by: opencode:claude-sonnet-5-5 --- docs/bw-wrappers.md | 16 ++-- lib/bwrap_sandbox_lib.sh | 163 ++++++++++++++++++++++++++++----------- 2 files changed, 130 insertions(+), 49 deletions(-) diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index 343df8c..9207dc9 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -121,11 +121,17 @@ and makes the tool load it. (the path) are set in the sandbox for scripts and skills. - **Contents** (about 50 lines): `$HOME` is a tmpfs; same-inode files are bind mounts, not hard links, and are edited at their source; the working directory - and whether it is a git root; a table of the mounts under `$HOME` marked - `rw` / `ro` / `tmpfs` with system mounts collapsed into one line each; what is - not mounted; the disabled binaries; the *names* of passed-in variables (never - values); and tool-specific notes (`bwopencode2`: use `--standalone`, plans in - `~/.opencode/plan` are lost). + (its access taken from the effective mounts) and whether it is a git root; a + table of every mount under `$HOME` marked `rw` / `ro` / `copy` (a writable + copy of a host file, lost at exit) / `tmpfs`; the system mounts collapsed + into one line each, with writable exceptions such as `--rw-path /usr/local` + listed on their own; what is not mounted (credential and rc files are named + only when no mount exposes them); the disabled binaries; the *names* of + passed-in variables (never values); and tool-specific notes (`bwopencode2`: + use `--standalone`, plans in `~/.opencode/plan` are lost). A mount hidden by + a later mount over its parent (for example `--ro-path` on the working + directory) is left out. Control characters, `|` and backticks in paths are + shown as `?`. - **Snapshot.** The file is written at launch; the mounts of a running sandbox cannot change, so it stays correct for the whole session. diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index 05242d2..10a2374 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -2688,22 +2688,30 @@ build_env_vars() { # BWRAP_SANDBOX_CONTEXT path of the context file # Row data collected from BWRAP_ARGS by _bwrap_context_collect, keyed by the -# destination path. _CTX_DESTS keeps first-seen order; a later mount at the -# same destination replaces the earlier one, as in bwrap. +# cleaned destination path. _CTX_DESTS keeps first-seen order. A later mount +# at a destination replaces the earlier one and hides everything mounted below +# it (mode "hidden"), as in bwrap. declare -A _CTX_MODE=() declare -A _CTX_SRC=() _CTX_DESTS=() +_CTX_HOME="" +_CTX_AT="" +# Characters that would break the markdown or the line-based processing below +# (control characters, | and a backtick) are shown as ? in every path. +_CTX_BAD='[[:cntrl:]|`]' # _bwrap_context_collect -# Walk BWRAP_ARGS and record every bind, tmpfs and symlink destination. -# *-try binds whose source is missing are skipped (bwrap skips them too), as -# is anything bound from /dev/null (the tty mask and, later, the binary -# masks). +# Walk BWRAP_ARGS and record every bind, file copy, tmpfs and symlink +# destination with its mode: ro, rw, copy (--file and --bind-data: a +# writable copy that is lost at exit), tmpfs or link. *-try binds whose +# source is missing are skipped (bwrap skips them too), as is anything bound +# from /dev/null (the tty mask and, later, the binary masks). _bwrap_context_collect() { _CTX_DESTS=() _CTX_MODE=() _CTX_SRC=() - local _i=0 _flag _a1 _a2 _mode _src _dest + _CTX_HOME="${_HOME//${_CTX_BAD}/?}" + local _i=0 _flag _a1 _a2 _mode _src _dest _e while [[ ${_i} -lt ${#BWRAP_ARGS[@]} ]]; do _flag="${BWRAP_ARGS[${_i}]}" _bwrap_flag_arity "${_flag}" @@ -2713,17 +2721,25 @@ _bwrap_context_collect() { _src="" case "${_flag}" in --ro-bind | --ro-bind-try) - _mode=ro + _mode="ro" _src="${_a1}" _dest="${_a2}" ;; --bind | --bind-try) - _mode=rw + _mode="rw" _src="${_a1}" _dest="${_a2}" ;; + --ro-bind-data) + _mode="ro" + _dest="${_a2}" + ;; + --file | --bind-data) + _mode="copy" + _dest="${_a2}" + ;; --tmpfs) - _mode=tmpfs + _mode="tmpfs" _dest="${_a1}" ;; --symlink) @@ -2739,6 +2755,10 @@ _bwrap_context_collect() { *-try) [[ -e "${_src}" ]] || continue ;; esac [[ "${_src}" == /dev/null ]] && continue + _dest="${_dest//${_CTX_BAD}/?}" + for _e in "${_CTX_DESTS[@]+"${_CTX_DESTS[@]}"}"; do + [[ "${_e}" == "${_dest}"/* ]] && _CTX_MODE["${_e}"]="hidden" + done [[ -n "${_CTX_MODE["${_dest}"]+x}" ]] || _CTX_DESTS+=("${_dest}") _CTX_MODE["${_dest}"]="${_mode}" _CTX_SRC["${_dest}"]="${_src}" @@ -2746,14 +2766,14 @@ _bwrap_context_collect() { } # _bwrap_context_display PATH -# Print PATH with a leading $HOME shown as ~. +# Print PATH cleaned, with a leading $HOME shown as ~. _bwrap_context_display() { - local _p="$1" - if [[ "${_p}" == "${_HOME}" ]]; then + local _p="${1//${_CTX_BAD}/?}" + if [[ "${_p}" == "${_CTX_HOME}" ]]; then printf '~' - elif [[ "${_p}" == "${_HOME}"/* ]]; then + elif [[ "${_p}" == "${_CTX_HOME}"/* ]]; then # shellcheck disable=SC2088 # a literal tilde is wanted - printf '~/%s' "${_p#"${_HOME}"/}" + printf '~/%s' "${_p#"${_CTX_HOME}"/}" else printf '%s' "${_p}" fi @@ -2765,11 +2785,43 @@ _bwrap_context_access() { case "$1" in ro) printf 'ro' ;; rw) printf 'rw' ;; + copy) printf 'copy' ;; tmpfs) printf 'tmpfs' ;; link) printf 'symlink' ;; esac } +# _bwrap_context_mode_at PATH +# Set _CTX_AT to the mode of the deepest visible mount that is PATH or one of +# its ancestors (empty when none). +_bwrap_context_mode_at() { + local _p="${1//${_CTX_BAD}/?}" + _CTX_AT="" + while [[ -n "${_p}" ]]; do + if [[ -n "${_CTX_MODE["${_p}"]+x}" && "${_CTX_MODE["${_p}"]}" != hidden ]]; then + _CTX_AT="${_CTX_MODE["${_p}"]}" + return 0 + fi + _p="${_p%/*}" + done +} + +# _bwrap_context_touched PATH +# Return 0 when a visible mount is PATH, lies below it, or is a bind that +# contains it (a tmpfs does not count: $HOME itself is one). +_bwrap_context_touched() { + local _p="$1" _d _m + for _d in "${_CTX_DESTS[@]+"${_CTX_DESTS[@]}"}"; do + _m="${_CTX_MODE["${_d}"]}" + [[ "${_m}" == hidden ]] && continue + [[ "${_d}" == "${_p}" || "${_d}" == "${_p}"/* ]] && return 0 + case "${_m}" in + ro | rw | copy) [[ "${_p}" == "${_d}"/* ]] && return 0 ;; + esac + done + return 1 +} + # _bwrap_context_render TOOL DEST # Build the context file text for launcher TOOL (to be mounted at DEST) in # _SANDBOX_CONTEXT_TEXT, from BWRAP_ARGS, SANDBOX_ENV and the launcher's @@ -2777,7 +2829,7 @@ _bwrap_context_access() { # except the binary masks (the masked names come from _MASKED_BINS). _bwrap_context_render() { local _tool="$1" _dest_file="$2" _nl=$'\n' - local _t="" _d _key _mode _line _n _c _tab=$'\t' _names _cnt + local _t="" _d _key _mode _line _n _c _tab=$'\t' _gn _acc _p _bwrap_context_collect @@ -2786,16 +2838,19 @@ _bwrap_context_render() { _sorted+=("${_d}") done < <(printf '%s\n' "${_CTX_DESTS[@]+"${_CTX_DESTS[@]}"}" | LC_ALL=C sort) - # Home rows, grouped by (parent directory, mode); other destinations. + # Home rows are grouped by (parent directory, mode). System mounts are + # collapsed; a mount outside $HOME that is not read-only, or not part of + # the system trees, is listed on its own. local -a _gkeys=() _sys=() _etc=() _other=() declare -A _gnames=() _gcount=() _gfirst=() _seen=() local _par for _d in "${_sorted[@]+"${_sorted[@]}"}"; do [[ -n "${_d}" ]] || continue _mode="${_CTX_MODE["${_d}"]}" + [[ "${_mode}" == hidden ]] && continue case "${_d}" in - "${_HOME}") continue ;; - "${_HOME}"/*) + "${_CTX_HOME}") continue ;; + "${_CTX_HOME}"/*) _par="${_d%/*}" _key="${_par}${_tab}${_mode}" if [[ -z "${_gcount["${_key}"]:-}" ]]; then @@ -2809,14 +2864,23 @@ _bwrap_context_render() { _gcount["${_key}"]=$((_c + 1)) ;; /proc | /dev | /dev/* | /tmp | /sys/*) ;; - /usr | /bin | /sbin | /lib | /lib64) _sys+=("${_d}") ;; - /usr/*) ;; - /etc/*) - _n="${_d#/etc/}" - _n="${_n%%/*}" - if [[ -z "${_seen["etc:${_n}"]:-}" ]]; then - _seen["etc:${_n}"]=1 - _etc+=("${_n}") + /usr | /bin | /sbin | /lib | /lib64) + if [[ "${_mode}" == rw || "${_mode}" == copy ]]; then + _other+=("${_d}") + else + _sys+=("${_d}") + fi + ;; + /usr/* | /etc/*) + if [[ "${_mode}" != ro ]]; then + _other+=("${_d}") + elif [[ "${_d}" == /etc/* ]]; then + _n="${_d#/etc/}" + _n="${_n%%/*}" + if [[ -z "${_seen["etc:${_n}"]:-}" ]]; then + _seen["etc:${_n}"]=1 + _etc+=("${_n}") + fi fi ;; *) _other+=("${_d}") ;; @@ -2829,7 +2893,7 @@ _bwrap_context_render() { _t+=" This file describes this session; trust it over assumptions about the host.${_nl}${_nl}" _t+="## What this means${_nl}${_nl}" - _t+="- \`\$HOME\` ($(_bwrap_context_display "${_HOME}") = ${_HOME}) is an empty tmpfs. Only the paths listed below exist in it;" + _t+="- \`\$HOME\` (~ = ${_CTX_HOME}) is an empty tmpfs. Only the paths listed below exist in it;" _t+=" anything else you write there is lost when the session ends.${_nl}" _t+="- Read-only files under \`\$HOME\` that look like links are bind mounts, not hard links." _t+=" The launcher resolves host symlinks and mounts the target at the expected path," @@ -2840,12 +2904,19 @@ _bwrap_context_render() { _t+=" and only if the source is mounted read-write below." _t+=" \"Read-only file system\" on a mount point is expected.${_nl}" _t+="- \`ln -s\` creates ordinary symlinks, in writable locations only.${_nl}" - _t+="- Read-write mounts are the real host files (often NFS): changes are visible on the host at once.${_nl}" _t+="- The network is shared with the host. Host processes are not visible (private PID namespace)." _t+=" There are no SSH or GPG agents and no sudo.${_nl}${_nl}" + _bwrap_context_mode_at "${_PWD}" + case "${_CTX_AT}" in + rw) _acc="read-write" ;; + ro) _acc="read-only" ;; + copy) _acc="a writable copy (lost at exit)" ;; + tmpfs) _acc="an empty tmpfs (lost at exit)" ;; + *) _acc="not mounted" ;; + esac _t+="## Working directory${_nl}${_nl}" - _t+="\`$(_bwrap_context_display "${_PWD}")\`, read-write." + _t+="\`$(_bwrap_context_display "${_PWD}")\`, ${_acc}." if [[ -n "${_GIT_ROOT}" ]]; then if [[ "${_PWD}" == "${_GIT_ROOT}" ]]; then _t+=" It is the root of a git repository, which is mounted whole;" @@ -2858,10 +2929,13 @@ _bwrap_context_render() { fi _t+="${_nl}${_nl}" - _t+="## Mounts under \`\$HOME\`${_nl}${_nl}" + _t+="## Mounts${_nl}${_nl}" _t+="\`rw\` is the real host files (often NFS): changes persist and show on the host at once." - _t+=" \`ro\` is read-only. \`tmpfs\` is empty and lost at exit, except the mounts listed under it." - _t+="${_nl}${_nl}| Path | Access |${_nl}| --- | --- |${_nl}" + _t+=" \`ro\` is read-only. \`copy\` is a writable copy of a host file: changes are lost at exit." + _t+=" \`tmpfs\` is empty and lost at exit, except the mounts listed under it.${_nl}${_nl}" + + _t+="### Under \`\$HOME\`${_nl}${_nl}" + _t+="| Path | Access |${_nl}| --- | --- |${_nl}" if [[ ${#_gkeys[@]} -eq 0 ]]; then _t+="| (none) | |${_nl}" else @@ -2871,25 +2945,21 @@ _bwrap_context_render() { _d="${_gfirst["${_key}"]}" _line="| \`$(_bwrap_context_display "${_d}")\` | $(_bwrap_context_access "${_mode}")" if [[ "${_mode}" == link ]]; then - _line+=" to \`${_CTX_SRC["${_d}"]}\`" + _line+=" to \`$(_bwrap_context_display "${_CTX_SRC["${_d}"]}")\`" elif [[ -n "${_CTX_SRC["${_d}"]}" && "${_CTX_SRC["${_d}"]}" != "${_d}" ]]; then _line+=" (host source \`$(_bwrap_context_display "${_CTX_SRC["${_d}"]}")\`)" fi else _par="${_key%"${_tab}"*}" - _names="${_gnames["${_key}"]}" - _cnt="${_gcount["${_key}"]}" - if [[ "${_cnt}" -gt 4 ]]; then - _names="$(printf '%s' "${_names}" | cut -d, -f1-3),…+$((_cnt - 3))" - fi - _line="| \`$(_bwrap_context_display "${_par}")/{${_names}}\` | $(_bwrap_context_access "${_mode}")" + _gn="${_gnames["${_key}"]}" + _line="| \`$(_bwrap_context_display "${_par}")/{${_gn}}\` | $(_bwrap_context_access "${_mode}")" fi _t+="${_line} |${_nl}" done < <(printf '%s\n' "${_gkeys[@]}" | LC_ALL=C sort) fi _t+="${_nl}" - _t+="## Other mounts${_nl}${_nl}" + _t+="### Elsewhere${_nl}${_nl}" if [[ ${#_sys[@]} -gt 0 ]]; then _line="" for _d in "${_sys[@]}"; do _line+="${_line:+, }\`${_d}\`"; done @@ -2906,9 +2976,14 @@ _bwrap_context_render() { _t+="- \`/tmp\` is a private tmpfs (lost at exit); \`/proc\` and \`/dev\` are private to the sandbox.${_nl}${_nl}" _t+="## Not available${_nl}${_nl}" - _t+="- Not mounted: everything under \`\$HOME\` that is not listed above, including" - _t+=" \`~/.ssh\`, \`~/.gnupg\`, \`~/.aws\`, \`~/.kube\`, \`~/.docker\`, \`~/.netrc\`, \`~/.config/gh\` and shell rc files." - _t+="${_nl}" + _line="" + for _n in .ssh .gnupg .aws .kube .docker .netrc .config/gh .bashrc .bash_profile .zshrc .profile; do + _p="${_CTX_HOME}/${_n}" + _bwrap_context_touched "${_p}" || _line+="${_line:+, }\`~/${_n}\`" + done + _t+="- Not mounted: everything under \`\$HOME\` that is not listed above" + [[ -z "${_line}" ]] || _t+=", for example ${_line}" + _t+=".${_nl}" if [[ "${HAS_BIND_OVER_RO}" -eq 1 ]]; then _line="" for _n in "${_MASKED_BINS[@]}"; do _line+="${_line:+, }\`${_n}\`"; done From c1d8f2865fee6ee4084ae50585418e729205c8b5 Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Tue, 27 Oct 2026 14:23:09 -0400 Subject: [PATCH 8/8] fix(bwrap): list read-only exceptions and describe /tmp from its mount Second round of review feedback, each case checked with --dry-run: - A read-only mount below a writable system mount (--rw-path /usr/local with --ro-path /usr/local/bin) is listed as an exception instead of being dropped with the other read-only entries under /usr and /etc. - Describe /tmp from its effective mount: --rw-path /tmp now says it is not private. Binds over /dev, /proc and /sys are listed too. - bwcodex: detect developer_instructions in any form (spaces around =, quoted, dotted or profile keys, inline tables, any project config from the repository root down, any argument mentioning it), so a user's instructions are not replaced by the context. Assisted-by: opencode:claude-sonnet-5-5 --- bin/bwcodex | 26 +++++++++++++++++++++----- docs/bw-wrappers.md | 5 +++-- lib/bwrap_sandbox_lib.sh | 30 +++++++++++++++++++++++++++--- 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/bin/bwcodex b/bin/bwcodex index eed77a2..18f6bf2 100755 --- a/bin/bwcodex +++ b/bin/bwcodex @@ -546,23 +546,39 @@ build_codex_bin_mount() { # AGENTS.md alone. It is a single value, so it is not set when the user's # config.toml (system, user or project) or command line already sets it: that # would replace theirs. The file is then only mounted. +# _codex_sets_developer_instructions FILE +# Return 0 when the TOML FILE sets developer_instructions anywhere: as a key +# at the start of a line, a quoted or dotted key (profiles.x.developer_instructions) +# or inside an inline table. Comment lines are ignored. +_codex_sets_developer_instructions() { + [[ -r "$1" ]] || return 1 + grep -vE '^[[:space:]]*#' "$1" | grep -qE "developer_instructions[\"']?[[:space:]]*=" +} + build_codex_context() { # shellcheck disable=SC2034 # consumed by sourced bwrap_sandbox_lib.sh SANDBOX_CONTEXT_NOTES=() build_sandbox_context bwcodex [[ -n "${SANDBOX_CONTEXT_PATH}" && -z "${TEST_CMD}" ]] || return 0 - local _f _arg _toml - for _f in "${SYSTEM_CODEX_CONFIG_FILE}" "${CODEX_CONFIG_FILE}" \ - "${_PWD}/.codex/config.toml" "${_GIT_ROOT:-${_PWD}}/.codex/config.toml"; do - if [[ -r "${_f}" ]] && grep -qE '^[[:space:]]*developer_instructions[[:space:]]*=' "${_f}"; then + # Project configs are read from the repository root down to the working + # directory, so check every directory on that path. + local _f _arg _toml _dir="${_PWD}" + local -a _files=("${SYSTEM_CODEX_CONFIG_FILE}" "${CODEX_CONFIG_FILE}") + while :; do + _files+=("${_dir}/.codex/config.toml") + [[ "${_dir}" == "${_GIT_ROOT:-${_PWD}}" || "${_dir}" == / || -z "${_dir}" ]] && break + _dir="${_dir%/*}" + done + for _f in "${_files[@]}"; do + if _codex_sets_developer_instructions "${_f}"; then echo "Warning: bwcodex: ${_f} sets developer_instructions, so Codex is not told to load ${SANDBOX_CONTEXT_PATH} (the file is still mounted)." >&2 return 0 fi done for _arg in ${TOOL_ARGS[@]+"${TOOL_ARGS[@]}"}; do [[ "${_arg}" == -- ]] && break - if [[ "${_arg}" == *developer_instructions=* ]]; then + if [[ "${_arg}" == *developer_instructions* ]]; then echo "Warning: bwcodex: developer_instructions was given on the command line, so Codex is not told to load ${SANDBOX_CONTEXT_PATH} (the file is still mounted)." >&2 return 0 fi diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index 9207dc9..f9ce674 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -125,7 +125,8 @@ and makes the tool load it. table of every mount under `$HOME` marked `rw` / `ro` / `copy` (a writable copy of a host file, lost at exit) / `tmpfs`; the system mounts collapsed into one line each, with writable exceptions such as `--rw-path /usr/local` - listed on their own; what is not mounted (credential and rc files are named + (and read-only exceptions below them) listed on their own, and `/tmp` + described from its effective mount (`--rw-path /tmp` makes it the host's); what is not mounted (credential and rc files are named only when no mount exposes them); the disabled binaries; the *names* of passed-in variables (never values); and tool-specific notes (`bwopencode2`: use `--standalone`, plans in `~/.opencode/plan` are lost). A mount hidden by @@ -142,7 +143,7 @@ files: | --- | --- | --- | | `bwopencode`, `bwopencode2` | `OPENCODE_CONFIG_CONTENT='{"instructions":[""]}'`; the list is added to the user's `instructions` (tested with 1.19.4 and 2.1.4, including a project `opencode.json`). A host value of this variable is dropped, as before. | Not applicable: lists are combined | | `bwclaude` | `--append-system-prompt-file `; adds to the default system prompt and leaves `CLAUDE.md` alone | A user `--append-system-prompt[-file]` wins; warning, file only mounted | -| `bwcodex` | `-c developer_instructions=`; a separate message that leaves `AGENTS.md` alone | `developer_instructions` in the system, user or project `config.toml`, or in a `-c` argument, wins; warning, file only mounted | +| `bwcodex` | `-c developer_instructions=`; a separate message that leaves `AGENTS.md` alone | `developer_instructions` in any form (also a quoted, dotted or profile key, or an inline table) in the system, user or any project `config.toml` from the repository root down, or anywhere in the arguments, wins; warning, file only mounted | | `bwcopilot` | `COPILOT_CUSTOM_INSTRUCTIONS_DIRS=~/.sandbox`; Copilot reads that directory's `AGENTS.md` in addition to its usual files | A host value is not forwarded (it never was), so nothing is replaced | `--exec` mounts the file and sets the two variables but adds no loader, since diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index 10a2374..4fdae40 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -2822,6 +2822,19 @@ _bwrap_context_touched() { return 1 } +# _bwrap_context_under_writable PATH +# Return 0 when a visible rw, copy or tmpfs mount is an ancestor of PATH: a +# read-only mount there is an exception to it and must be listed. +_bwrap_context_under_writable() { + local _p="$1" _d + for _d in "${_CTX_DESTS[@]+"${_CTX_DESTS[@]}"}"; do + case "${_CTX_MODE["${_d}"]}" in + rw | copy | tmpfs) [[ "${_p}" == "${_d}"/* ]] && return 0 ;; + esac + done + return 1 +} + # _bwrap_context_render TOOL DEST # Build the context file text for launcher TOOL (to be mounted at DEST) in # _SANDBOX_CONTEXT_TEXT, from BWRAP_ARGS, SANDBOX_ENV and the launcher's @@ -2863,7 +2876,12 @@ _bwrap_context_render() { _c="${_gcount["${_key}"]}" _gcount["${_key}"]=$((_c + 1)) ;; - /proc | /dev | /dev/* | /tmp | /sys/*) ;; + /tmp) + [[ "${_mode}" == tmpfs ]] || _other+=("${_d}") + ;; + /sys/*) + [[ "${_mode}" == ro ]] || _other+=("${_d}") + ;; /usr | /bin | /sbin | /lib | /lib64) if [[ "${_mode}" == rw || "${_mode}" == copy ]]; then _other+=("${_d}") @@ -2872,7 +2890,7 @@ _bwrap_context_render() { fi ;; /usr/* | /etc/*) - if [[ "${_mode}" != ro ]]; then + if [[ "${_mode}" != ro ]] || _bwrap_context_under_writable "${_d}"; then _other+=("${_d}") elif [[ "${_d}" == /etc/* ]]; then _n="${_d#/etc/}" @@ -2973,7 +2991,13 @@ _bwrap_context_render() { for _d in "${_other[@]+"${_other[@]}"}"; do _t+="- \`${_d}\`: $(_bwrap_context_access "${_CTX_MODE["${_d}"]}").${_nl}" done - _t+="- \`/tmp\` is a private tmpfs (lost at exit); \`/proc\` and \`/dev\` are private to the sandbox.${_nl}${_nl}" + _bwrap_context_mode_at /tmp + if [[ "${_CTX_AT}" == tmpfs ]]; then + _t+="- \`/tmp\` is a private tmpfs (lost at exit)." + else + _t+="- \`/tmp\` is NOT private here (see its mount above): what you write there may persist on the host." + fi + _t+=" \`/proc\` and \`/dev\` are private to the sandbox unless listed above.${_nl}${_nl}" _t+="## Not available${_nl}${_nl}" _line=""