From a08aee044c7483b70403b7a21b66a0dc0c758e73 Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Sun, 18 Oct 2026 15:25:58 -0400 Subject: [PATCH 01/10] feat: add bwopencode2 sandbox launcher for OpenCode 2 Parallel to bwopencode for the opencode2 command. Uses separate opencode2 XDG dirs, forces --standalone and refuses server-related options so tools cannot run in an unsandboxed background service. Assisted-by: opencode:claude-sonnet-5-5 --- AI_DISCLOSURE.md | 4 +- README.md | 8 +- bin/bwopencode2 | 657 ++++++++++++++++++++++++++++++++++++++++++++ docs/bw-wrappers.md | 83 +++++- 4 files changed, 744 insertions(+), 8 deletions(-) create mode 100755 bin/bwopencode2 diff --git a/AI_DISCLOSURE.md b/AI_DISCLOSURE.md index 1e01efd..4dd3246 100644 --- a/AI_DISCLOSURE.md +++ b/AI_DISCLOSURE.md @@ -49,7 +49,7 @@ human contributors. **Source code in this repository is largely AI-generated:** AI tools produce the initial implementation of the sandboxed AI CLI wrapper -scripts (`bin/bwopencode`, `bin/bwcodex`, `bin/bwclaude`, +scripts (`bin/bwopencode`, `bin/bwopencode2`, `bin/bwcodex`, `bin/bwclaude`, `bin/bwcopilot`) and the shared `lib/bwrap_sandbox_lib.sh` sandbox library, as well as `bin/gh-protect-branch`, `bin/azoidcapp`, `bin/pemdecompose`, `lib/gpg-passwd.sh`, associated documentation, and @@ -73,7 +73,7 @@ which model contributed to a specific change. AI tools are used to: - Draft and refactor the bubblewrap-sandboxed AI CLI wrapper scripts - (`bwopencode`, `bwcodex`, `bwclaude`, `bwcopilot`) and the shared + (`bwopencode`, `bwopencode2`, `bwcodex`, `bwclaude`, `bwcopilot`) and the shared `bwrap_sandbox_lib.sh` library. - Draft and refactor `gh-protect-branch`, `azoidcapp`, `pemdecompose`, and `gpg-passwd.sh`. diff --git a/README.md b/README.md index aed71d3..6d010fd 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ docs/ Per-script and per-library documentation | `bwcodex` | OpenAI Codex CLI in a bubblewrap sandbox | [docs/bw-wrappers.md](docs/bw-wrappers.md) | | `bwcopilot` | GitHub Copilot CLI in a bubblewrap sandbox | [docs/bw-wrappers.md](docs/bw-wrappers.md) | | `bwopencode` | OpenCode in a bubblewrap sandbox | [docs/bw-wrappers.md](docs/bw-wrappers.md) | +| `bwopencode2` | OpenCode 2 (`opencode2`) in a bubblewrap sandbox | [docs/bw-wrappers.md](docs/bw-wrappers.md) | | `gh-protect-branch` | Apply NSLS-II standard branch protection to a GitHub repo (all branches); enables secret scanning and push protection; optionally restricts branch creation to named patterns | [docs/gh-protect-branch.md](docs/gh-protect-branch.md) | | `pemdecompose` | List and verify certificates in a PEM file | [docs/pemdecompose.md](docs/pemdecompose.md) | @@ -81,6 +82,7 @@ Add those three exports to your `~/.bashrc` or `~/.zshrc` for persistence. ```text bwopencode [bwopencode-options] [opencode arguments...] +bwopencode2 [bwopencode2-options] [opencode2 arguments...] bwclaude [bwclaude-options] [claude arguments...] bwcopilot [bwcopilot-options] [copilot arguments...] bwcodex [bwcodex-options] [codex arguments...] @@ -96,8 +98,10 @@ Common wrapper options: | `--init-auth` | Persist auth credentials to the host (first-time setup) | | `--new-session` | Force `bwrap --new-session` (stricter isolation; breaks SIGWINCH) | -`bwclaude` also supports `--debug`. See each script's `--help` for the -authoritative option list. +`bwclaude` also supports `--debug`. `bwopencode2` has no `--init-auth` (sign-in +persists in its data directory) and always runs OpenCode 2 with `--standalone`; +see [docs/bw-wrappers.md](docs/bw-wrappers.md). See each script's `--help` for +the authoritative option list. ### `gh-protect-branch` diff --git a/bin/bwopencode2 b/bin/bwopencode2 new file mode 100755 index 0000000..5f584a7 --- /dev/null +++ b/bin/bwopencode2 @@ -0,0 +1,657 @@ +#!/usr/bin/env bash +# bwopencode2 — Launch OpenCode 2 (opencode2) inside a bubblewrap (bwrap) sandbox. +# +# Requires: bwrap >= 0.4.0 (RHEL 8) +# - 0.4.0: base functionality +# - 0.5.0+: --clearenv (fallback: manual --unsetenv for each host var) +# - 0.6.3+: bind over ro-bind (fallback: skip binary masking) +# +# Requires the `opencode2` command (npm @opencode/cli), which ansible installs +# when base_opencode2_enable is true. OpenCode 2 is a separate product from +# OpenCode 1 (see bwopencode) and never shares its directories. +# +# The sandbox provides filesystem isolation: +# - Current directory: read-write +# - OpenCode 2 config (~/.config/opencode2/opencode): read-only (symlinks resolved) +# - OpenCode 2 data (~/.local/share/opencode2/opencode): read-write, the +# whole directory persists (database incl. saved sign-in, logs, ...) +# - OpenCode 2 cache (~/.cache/opencode2/opencode): read-write +# - OpenCode 2 state (~/.local/state/opencode2/opencode): ephemeral +# (last-used model and prompt history are not remembered) +# - Managed config: /etc/opencode/opencode.json{,c} and +# /etc/opencode/{agents,commands,plugins,skills}, read-only +# - Project .opencode/: persistent copy kept outside the repository +# - XDG_{CONFIG,DATA,STATE,CACHE}_HOME point at the opencode2 directories so +# OpenCode 2 can never touch OpenCode 1 data (which it would migrate +# one way) +# - Git config + includes: read-only (also mirrored under the opencode2 +# XDG config home, where git looks inside the sandbox) +# - Pixi cache: read-write +# - Pixi config: read-only +# - ccache config: read-only +# - ccache cache: read-write +# - npm config: read-only +# - npm cache: read-write +# - System and user binaries: read-only +# - SSH binaries: masked (ssh, scp, sftp, ssh-agent, etc.) +# - Network tools: masked (telnet, nc/ncat, socat, rsync, rsh) +# - Kerberos tools: masked (kinit, klist, kdestroy, kswitch) +# - Privilege escalation: masked (sudo, su, pkexec) +# - Namespace tools: masked (nsenter, unshare, chroot) +# - Keyring tools: masked (keyctl) +# - SSH/GPG agent env vars: stripped +# - ~/.bashrc and other shell rc files: not mounted +# +# Sandbox-escape guard: OpenCode 2 normally talks to a shared background +# service (fixed port 49374) that runs tools OUTSIDE any sandbox. The wrapper +# therefore forces `--standalone` (a private in-process server) for the +# commands that accept it and refuses --server and the server-related +# commands (service, serve, api, pair, reload). See --help. +# +# Usage: +# bwopencode2 [bwopencode2-options] [opencode2 arguments...] +# bwopencode2 --help +# +# Options: +# --help, -h Show this help (wrapper options + opencode2's own help). +# --dry-run Print the bwrap command that would be executed without running it. +# --exec CMD Run CMD inside the sandbox instead of opencode2. +# Useful for running one-off commands, scripts, or diagnostic tools +# inside the fully-configured sandbox environment. +# --new-session Force bwrap --new-session even on kernels with the TIOCSTI cap +# guard. Detaches the controlling tty for stricter isolation at the +# cost of SIGWINCH (terminal resize) propagation. +# --ro-path PATH Mount PATH read-only into the sandbox. May be repeated to +# accumulate multiple paths. Dangerous paths are blocked: +# $HOME and ancestors, ~/.ssh, ~/.gnupg, ~/.aws, ~/.kube, +# ~/.docker, ~/.netrc, ~/.pypirc, ~/.rattler, ~/.yarnrc, +# ~/.yarnrc.yml, ~/.yarn, ~/.config/gh, +# $XDG_DATA_HOME/uv/credentials, /root, /etc/shadow, +# /etc/sudoers{,.d}. +# --rw-path PATH Mount PATH read-write into the sandbox. May be repeated to +# accumulate multiple paths. Same blocked-path rules as --ro-path. +# --github-tokens Forward any GH_TOKEN_* environment variables into the sandbox. +# +# There is no --init-auth: OpenCode 2 stores credentials in its database +# (the `credential` table in opencode.db), which lives in the persistent +# data directory, so sign-in (`opencode2 auth login`) persists on its own. + +set -euo pipefail + +# Source the shared sandbox library. +SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" +# shellcheck source=../lib/bwrap_sandbox_lib.sh +source "${SCRIPT_DIR}/../lib/bwrap_sandbox_lib.sh" + +# ═══════════════════════════════════════════════════════════════════ +# Tool-specific state +# ═══════════════════════════════════════════════════════════════════ + +FORWARD_GH_TOKENS=0 +OC2_CONFIG_BINDS=() + +# ═══════════════════════════════════════════════════════════════════ +# Tool-specific functions +# ═══════════════════════════════════════════════════════════════════ + +parse_wrapper_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --help | -h) + SHOW_HELP=1 + shift + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + --exec) + if [[ $# -lt 2 ]]; then + echo "Error: --exec requires a command argument." >&2 + exit 1 + fi + TEST_CMD="$2" + shift 2 + ;; + --github-tokens) + FORWARD_GH_TOKENS=1 + shift + ;; + --new-session) + # shellcheck disable=SC2034 # consumed by sourced bwrap_sandbox_lib.sh (sandboxed_exec) + FORCE_NEW_SESSION=1 + shift + ;; + --ro-path) + if [[ $# -lt 2 ]]; then + echo "Error: --ro-path requires a path argument." >&2 + exit 1 + fi + EXTRA_RO_PATHS+=("$2") + shift 2 + ;; + --rw-path) + if [[ $# -lt 2 ]]; then + echo "Error: --rw-path requires a path argument." >&2 + exit 1 + fi + EXTRA_RW_PATHS+=("$2") + shift 2 + ;; + *) + break + ;; + esac + done + # shellcheck disable=SC2034 # consumed by library's print_dry_run/launch_sandbox + TOOL_ARGS=("$@") +} + +# Find the absolute path to the opencode2 binary on the host so we do +# not depend on PATH lookup inside the sandbox. +# Sets globals: _TOOL_BIN, _TOOL_CMD +resolve_tool_binary() { + _TOOL_BIN="$(bwrap_find_tool opencode2 2> /dev/null || true)" + if [[ -z "${_TOOL_BIN}" ]]; then + echo "Error: 'opencode2' not found in PATH (OpenCode 2 is installed by ansible when base_opencode2_enable is true)." >&2 + exit 1 + fi + + if [[ -n "${TEST_CMD}" ]]; then + # Split TEST_CMD into array so multi-word commands work correctly. + read -ra _TOOL_CMD <<< "${TEST_CMD}" + else + _TOOL_CMD=("${_TOOL_BIN}") + fi +} + +show_help() { + cat << 'HELP_EOF' +bwopencode2 — OpenCode 2 (opencode2) inside a bubblewrap sandbox + +Wrapper options (consumed before opencode2 sees the command line): + -h, --help Show this message followed by opencode2's own help. + --dry-run Print the bwrap command that would be run, then exit. + --exec CMD Run CMD inside the fully-configured sandbox instead of opencode2. + Use this to run scripts, diagnostic tools, or any command with + the same filesystem isolation and environment as opencode2 itself. + Example: bwopencode2 --exec bash + bwopencode2 --exec 'env | sort' + --new-session Force bwrap to call setsid(2), detaching the sandbox from the + controlling terminal. This is the default on older kernels + (< 5.14); on newer kernels we omit it so that SIGWINCH (terminal + resize) propagates from tmux/screen. Use this flag to opt back + into stricter terminal isolation when SIGWINCH is not important. + --ro-path PATH Mount PATH (file or directory) read-only into the sandbox. + May be repeated to accumulate multiple extra paths. + Blocked: $HOME and ancestors, ~/.ssh, ~/.gnupg, ~/.aws, + ~/.kube, ~/.docker, ~/.netrc, ~/.pypirc, ~/.rattler, + ~/.yarnrc, ~/.yarnrc.yml, ~/.yarn, ~/.config/gh, + $XDG_DATA_HOME/uv/credentials, /root, /etc/shadow, + /etc/sudoers{,.d}. + Symlinks are resolved before the safety check is applied. + --rw-path PATH Mount PATH (file or directory) read-write into the sandbox. + Same blocked-path rules as --ro-path. May be repeated. + --github-tokens Forward any GH_TOKEN_* environment variables into the + sandbox so the agent can authenticate the gh(1) CLI. + 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 + +OpenCode 2 data lives under ~/.config|.local/share|.local/state|.cache/opencode2 +(never the OpenCode 1 directories). Sign-in is stored in the database in the +persistent data directory, so there is no --init-auth. + +Sandbox-escape guard: OpenCode 2 normally connects to a shared background +service that runs tools OUTSIDE the sandbox. To keep tools sandboxed: + - `--standalone` (a private in-process server) is added automatically when + you run opencode2 with no subcommand or with run, mini, models or stats. + - `--server`, `--standalone=...` and `--no-standalone` are refused, as are + the service, serve, api, pair and reload subcommands. + - All other subcommands (auth, debug, mcp, plugin, session, upgrade, ...) + are passed through unchanged. +Limitation: the first non-option argument is taken as the subcommand; options +that take a value are only recognised for --session/-s, --prompt, --log-level +and --completions. Arguments after `--` are never inspected. + +All other arguments are passed through to opencode2 unchanged. + +HELP_EOF + # Delegate to opencode2 --help inside the sandbox so the user sees the full + # opencode2 option set in the same invocation. We re-exec this script with + # --exec so the sandbox is fully constructed; opencode2 --help is passed + # as the sandbox command. + exec "$0" --exec "${_TOOL_BIN} --help" +} + +# ── Sandbox-escape guard (arguments) ───────────────────────────── +# The OpenCode 2 background service listens on a fixed, shared port and runs +# tools outside any sandbox. Because the sandbox shares the host network, a +# CLI started without --standalone would delegate tool execution to it. +# +# guard_tool_args scans TOOL_ARGS and either refuses the invocation or +# injects --standalone: +# - refuse: --server[=...], --standalone=..., --no-standalone anywhere before +# a bare `--`; subcommands service, serve, api, pair, reload. +# - inject --standalone: no subcommand (or a directory argument), run, mini, +# models, stats (the commands whose `--help` lists the flag). +# - the known subcommands auth, debug, mcp, plugin, session, upgrade/update, +# uninstall, acp do not accept --standalone and are passed through. +# +# Known limitations: the subcommand is the first non-option argument; only the +# root flags --session/-s, --prompt, --log-level and --completions are known to +# consume the next argument. Combined short flags are not split. A bare `--` +# ends option scanning, so a prompt after `--` can never trigger a refusal. +guard_tool_args() { + local _n=${#TOOL_ARGS[@]} _i _arg _sub="" _sub_idx=-1 _has_standalone=0 _inject_front=0 + + # Pass 1: options that would bypass the private server (all positions). + for ((_i = 0; _i < _n; _i++)); do + _arg="${TOOL_ARGS[_i]}" + case "${_arg}" in + --) + break + ;; + --server | --server=*) + echo "Error: bwopencode2 refuses '--server': it would connect to a server" >&2 + echo " that runs tools outside the sandbox." >&2 + exit 1 + ;; + --standalone=* | --no-standalone) + echo "Error: bwopencode2 refuses '${_arg}': the sandbox requires --standalone." >&2 + exit 1 + ;; + --standalone) + _has_standalone=1 + ;; + esac + done + + # Pass 2: find the subcommand (first non-option argument). + _i=0 + while ((_i < _n)); do + _arg="${TOOL_ARGS[_i]}" + case "${_arg}" in + --) + break + ;; + --session | -s | --prompt | --log-level | --completions) + _i=$((_i + 2)) + ;; + -*) + _i=$((_i + 1)) + ;; + *) + _sub="${_arg}" + _sub_idx=${_i} + break + ;; + esac + done + + case "${_sub}" in + service | serve | api | pair | reload) + echo "Error: bwopencode2 refuses '${_sub}': it starts or talks to a server that" >&2 + echo " runs outside the sandbox." >&2 + exit 1 + ;; + "") + # No subcommand: interactive TUI. + _inject_front=1 + ;; + run | mini | models | stats) + if [[ "${_has_standalone}" -eq 0 ]]; then + # Insert right after the subcommand word. + local -a _new_args=() + for ((_i = 0; _i < _n; _i++)); do + _new_args+=("${TOOL_ARGS[_i]}") + if [[ "${_i}" -eq "${_sub_idx}" ]]; then + _new_args+=(--standalone) + fi + done + TOOL_ARGS=("${_new_args[@]}") + fi + ;; + auth | debug | mcp | plugin | session | upgrade | update | uninstall | acp) + : # known subcommands without --standalone: unchanged + ;; + *) + # Not a known subcommand: the TUI's positional directory argument + # (e.g. `opencode2 ~/proj`), which also needs --standalone. + _inject_front=1 + ;; + esac + + if [[ "${_inject_front}" -eq 1 && "${_has_standalone}" -eq 0 ]]; then + TOOL_ARGS=(--standalone ${TOOL_ARGS[@]+"${TOOL_ARGS[@]}"}) + fi +} + +resolve_opencode2_paths() { + # Derived from the resolved $HOME, never from the (possibly overridden) + # XDG_* variables, so they match the ansible `opencode2` wrapper, which + # exports XDG_*_HOME=$HOME/.{config,local/share,local/state,cache}/opencode2. + # OpenCode 2 appends "opencode" to each of those. + OC2_XDG_CONFIG_HOME="${_HOME}/.config/opencode2" + OC2_XDG_DATA_HOME="${_HOME}/.local/share/opencode2" + OC2_XDG_STATE_HOME="${_HOME}/.local/state/opencode2" + OC2_XDG_CACHE_HOME="${_HOME}/.cache/opencode2" + OC2_CONFIG_DIR="${OC2_XDG_CONFIG_HOME}/opencode" + OC2_DATA_DIR="${OC2_XDG_DATA_HOME}/opencode" + OC2_STATE_DIR="${OC2_XDG_STATE_HOME}/opencode" + OC2_CACHE_DIR="${OC2_XDG_CACHE_HOME}/opencode" + + # The fixed paths cannot reach a blocked path unless $HOME itself does; + # the checks mirror bwopencode and also catch symlinked directories. + _check_dir_safe "${OC2_DATA_DIR}" "opencode2 data dir" + _check_dir_safe "${OC2_CACHE_DIR}" "opencode2 cache dir" +} + +# Ensure writable directories exist on host. The data dir is bound whole +# because the OpenCode 2 layout is not documented (database + -shm/-wal, log, +# repos, shell, ...), so no file list could be kept in sync. +ensure_host_dirs() { + mkdir -p "${OC2_DATA_DIR}" "${OC2_CACHE_DIR}" +} + +# Resolve opencode2 config symlinks. +# We resolve each entry in ~/.config/opencode2/opencode/ so that symlinks +# pointing outside (e.g. into a dotfiles repo) are followed, and we +# bind the real file at the expected path. This avoids exposing the +# dotfiles tree inside the sandbox. +# +# Each resolved target is passed through _check_path_safe before being +# added to OC2_CONFIG_BINDS. This blocks symlinks that point into +# sensitive subtrees (e.g. config/creds -> ~/.aws/credentials). +resolve_config_symlinks() { + OC2_CONFIG_BINDS=() + [[ -d "${OC2_CONFIG_DIR}" ]] || return 0 + + local entry local_resolved + for entry in "${OC2_CONFIG_DIR}"/*; do + [[ -e "${entry}" ]] || continue + if [[ -L "${entry}" ]]; then + local_resolved="$(readlink -f "${entry}")" + else + local_resolved="${entry}" + fi + _check_path_safe "${local_resolved}" "opencode2 config entry '${entry}'" + OC2_CONFIG_BINDS+=("--ro-bind" "${local_resolved}" "${entry}") + done +} + +# ── Managed OpenCode config (read-only, highest priority) ──────── +# If a managed config exists at /etc/opencode/ on the host (deployed +# by ansible), bind-mount it RO into the sandbox. OpenCode 2 reads +# managed settings from the same /etc/opencode path as OpenCode 1; it +# takes precedence over all other config sources and config in the +# project cannot override it. +# +# Both opencode.json and opencode.jsonc are mounted when present, so +# opencode2 can pick whichever format it prefers. +# +# On hosts without /etc/opencode/, nothing is bound here — opencode2 +# falls back to user config as usual. A warning is emitted to stderr +# in that case so misconfigured managed hosts (e.g. ansible deployment +# failure) are noticed. +build_managed_config() { + local _has_json=0 _has_jsonc=0 + [[ -f /etc/opencode/opencode.json ]] && _has_json=1 + [[ -f /etc/opencode/opencode.jsonc ]] && _has_jsonc=1 + + if [[ $((_has_json + _has_jsonc)) -gt 0 ]]; then + # At least one host-managed config exists — bind whichever are present. + BWRAP_ARGS+=(--dir /etc/opencode) + if [[ "${_has_json}" -eq 1 ]]; then + BWRAP_ARGS+=(--ro-bind /etc/opencode/opencode.json /etc/opencode/opencode.json) + fi + if [[ "${_has_jsonc}" -eq 1 ]]; then + BWRAP_ARGS+=(--ro-bind /etc/opencode/opencode.jsonc /etc/opencode/opencode.jsonc) + fi + else + printf 'bwopencode2: warning: no /etc/opencode/opencode.json{,c} found on host.\n' >&2 + printf ' Falling back to user config at ~/.config/opencode2/opencode/.\n' >&2 + fi +} + +# The host-managed skills alias may point into /opt/nsls2/skills. Bind +# its resolved source at OpenCode's documented managed-skills location so the +# alias target and every supporting file remain available inside the sandbox. +build_managed_skills() { + [[ -d /etc/opencode/skills && -r /etc/opencode/skills ]] || return 0 + BWRAP_ARGS+=(--dir /etc/opencode) + build_managed_ro_bind /etc/opencode/skills /etc/opencode/skills +} + +# OpenCode 2 also reads managed agents, commands, and plugins from +# /etc/opencode. Bind each one read-only when it exists on the host. +build_managed_extras() { + local _name + for _name in agents commands plugins; do + [[ -d "/etc/opencode/${_name}" && -r "/etc/opencode/${_name}" ]] || continue + BWRAP_ARGS+=(--dir /etc/opencode) + build_managed_ro_bind "/etc/opencode/${_name}" "/etc/opencode/${_name}" + done +} + +# ── Persistent .opencode/ directory ────────────────────────────── +# OpenCode creates .opencode/ inside the repo root for project-level +# config, rules, and plugins. We store this in a persistent location +# outside the repo so it survives `git clean -xfd` on the host, and so +# repository-supplied .opencode/ content cannot inject config or plugins. +# +# The persistent directory is keyed by a hash of the bind dir path, +# stored under ~/.local/share/opencode2/opencode/project-dotfiles//. +# Inside the sandbox, this is bind-mounted on top of /.opencode/. +build_persistent_dotdir() { + [[ -n "${_GIT_ROOT}" ]] || return 0 + + local _repo_hash + _repo_hash="$(printf '%s' "${_GIT_ROOT}" | sha256sum)" + _repo_hash="${_repo_hash:0:16}" + local _persist_dir="${OC2_DATA_DIR}/project-dotfiles/${_repo_hash}" + mkdir -p "${_persist_dir}" + + BWRAP_ARGS+=( + --bind "${_persist_dir}" "${_GIT_ROOT}/.opencode" + ) +} + +# ── OpenCode 2 config (read-only, symlinks resolved) ───────────── +build_opencode2_config_mount() { + if [[ ${#OC2_CONFIG_BINDS[@]} -gt 0 ]]; then + BWRAP_ARGS+=( + --dir "${OC2_CONFIG_DIR}" + "${OC2_CONFIG_BINDS[@]}" + ) + fi +} + +# ── OpenCode 2 data (read-write, whole directory) ──────────────── +# Credentials live in opencode.db (the `credential` table), so persisting +# the data directory persists sign-in. There is no tmpfs + selective +# persistence scheme (as in bwopencode) because the data layout is +# undocumented. +build_opencode2_data_mount() { + BWRAP_ARGS+=( + --bind "${OC2_DATA_DIR}" "${OC2_DATA_DIR}" + ) +} + +# ── OpenCode 2 cache (read-write) ──────────────────────────────── +# Provider npm packages are installed here, so it must be writable. +build_opencode2_cache_mount() { + BWRAP_ARGS+=( + --bind "${OC2_CACHE_DIR}" "${OC2_CACHE_DIR}" + ) +} + +# ── OpenCode 2 state (ephemeral) ───────────────────────────────── +# $HOME is a tmpfs, so state is discarded on exit; this avoids lock +# clashes on NFS homes. Create the directory chain so OpenCode 2 can +# write into it without having to create parents. +build_opencode2_state_dir() { + BWRAP_ARGS+=( + --dir "${OC2_STATE_DIR}" + ) +} + +# ── XDG mirror for tools that read $XDG_CONFIG_HOME / $XDG_CACHE_HOME ─ +# Inside the sandbox XDG_CONFIG_HOME and XDG_CACHE_HOME point at the +# opencode2 directories, so git, pixi and ccache would no longer find +# their configuration at the host locations the shared library mounts. +# Mirror whatever the host has under its XDG config/cache home at the +# same relative location under the opencode2 homes (read-only for +# config, read-write for the ccache cache). ~/.gitconfig and other +# $HOME-relative files are unaffected. +build_xdg_mirror_mounts() { + local _mirror_cfg="${OC2_XDG_CONFIG_HOME}" + + # Skip the mirror when the host XDG dirs already ARE the opencode2 ones. + if [[ "${XDG_CONFIG_HOME}" != "${_mirror_cfg}" ]]; then + if [[ -d "${XDG_CONFIG_HOME}/git" ]]; then + BWRAP_ARGS+=(--ro-bind "${XDG_CONFIG_HOME}/git" "${_mirror_cfg}/git") + fi + if [[ -f "${XDG_CONFIG_HOME}/pixi/config.toml" ]]; then + BWRAP_ARGS+=( + --dir "${_mirror_cfg}/pixi" + --ro-bind "${XDG_CONFIG_HOME}/pixi/config.toml" "${_mirror_cfg}/pixi/config.toml" + ) + fi + # ccache config is only XDG-relative when the legacy ~/.ccache is absent. + if [[ "${CCACHE_CONFIG_DIR}" == "${XDG_CONFIG_HOME}/ccache" && -d "${CCACHE_CONFIG_DIR}" ]]; then + BWRAP_ARGS+=(--ro-bind "${CCACHE_CONFIG_DIR}" "${_mirror_cfg}/ccache") + fi + fi + if [[ "${XDG_CACHE_HOME}" != "${OC2_XDG_CACHE_HOME}" ]]; then + if [[ "${CCACHE_CACHE_DIR}" == "${XDG_CACHE_HOME}/ccache" && -d "${CCACHE_CACHE_DIR}" ]]; then + BWRAP_ARGS+=(--bind "${CCACHE_CACHE_DIR}" "${OC2_XDG_CACHE_HOME}/ccache") + fi + fi +} + +# ── OpenCode env vars ──────────────────────────────────────────── +# Pass through any OPENCODE_* env vars (config overrides, etc.) +# Also pass through AIFAPIM_* env vars for NSLS-II Hermes gateway. +# With --github-tokens, also forward GH_TOKEN_* vars so the agent +# can authenticate gh(1) via per-command: +# GH_TOKEN="$GH_TOKEN_NSLS2" gh +# +# Must run AFTER build_env_vars: the XDG_* values set here override the +# host pass-through from build_env_vars (bwrap --setenv: last one wins), +# so OpenCode 2 uses its own directories even if `opencode2` on PATH is a +# plain binary rather than the ansible wrapper. +build_opencode2_env() { + # AIFAPIM gateway config + pass_through_if_set AIFAPIM_HOST + # The API key is replaced with REDACTED in --dry-run output so that it is + # not printed to the terminal (pass_through_if_set would print it). + if [[ -n "${AIFAPIM_API_KEY:-}" ]]; then + if [[ "${DRY_RUN}" -eq 1 ]]; then + BWRAP_ARGS+=(--setenv AIFAPIM_API_KEY REDACTED) + else + BWRAP_ARGS+=(--setenv AIFAPIM_API_KEY "${AIFAPIM_API_KEY}") + fi + fi + + local _key + while IFS= read -r _key; do + if [[ "${_key}" == OPENCODE_* ]]; then + BWRAP_ARGS+=(--setenv "${_key}" "${!_key}") + fi + done < <(compgen -e) + + # GitHub CLI token passthrough (opt-in via --github-tokens) + # In --dry-run mode the value is replaced with REDACTED so that tokens + # are not printed to the terminal in the bwrap command output. + if [[ "${FORWARD_GH_TOKENS}" -eq 1 ]]; then + local _gh_val + while IFS= read -r _key; do + if [[ "${_key}" == GH_TOKEN_* ]]; then + if [[ "${DRY_RUN}" -eq 1 ]]; then + _gh_val="REDACTED" + else + _gh_val="${!_key}" + fi + BWRAP_ARGS+=(--setenv "${_key}" "${_gh_val}") + fi + done < <(compgen -e) + fi + + # Separate XDG homes for OpenCode 2 (see build_xdg_mirror_mounts for the + # git/pixi/ccache side effect). + BWRAP_ARGS+=( + --setenv XDG_CONFIG_HOME "${OC2_XDG_CONFIG_HOME}" + --setenv XDG_DATA_HOME "${OC2_XDG_DATA_HOME}" + --setenv XDG_STATE_HOME "${OC2_XDG_STATE_HOME}" + --setenv XDG_CACHE_HOME "${OC2_XDG_CACHE_HOME}" + ) +} + +# ═══════════════════════════════════════════════════════════════════ +# Main +# ═══════════════════════════════════════════════════════════════════ + +main() { + parse_wrapper_args "$@" + resolve_tool_binary + + if [[ "${SHOW_HELP}" -eq 1 ]]; then + show_help + # show_help does exec, never returns + fi + + # --exec runs an arbitrary command, so its arguments are not opencode2's. + if [[ -z "${TEST_CMD}" ]]; then + guard_tool_args + fi + + detect_bwrap_capabilities + detect_kernel_capabilities + resolve_common_paths + resolve_opencode2_paths + detect_shell + ensure_host_dirs + parse_all_git_includes + resolve_config_symlinks + init_sandbox_path + + # Build bwrap argument list + build_base_sandbox + build_binary_masks + build_proc_dev_tmp + build_sys_mounts + build_etc_mounts + build_managed_config + build_managed_skills + build_managed_extras + build_home_tmpfs + build_workdir_mount + build_persistent_dotdir + build_opencode2_config_mount + build_opencode2_data_mount + build_opencode2_cache_mount + build_opencode2_state_dir + build_git_mounts + build_pixi_mounts + build_ccache_mounts + build_npm_mounts + build_xdg_mirror_mounts + build_user_bin_mount + build_nsls2_mount + build_dynamic_tool_mounts + build_extra_path_mounts + + # Environment variables + build_env_vars + build_opencode2_env + + # Launch + if [[ "${DRY_RUN}" -eq 1 ]]; then + print_dry_run + else + launch_sandbox + fi +} + +main "$@" diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index f7534c3..1e4e73d 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -6,7 +6,7 @@ masked privileged binaries, ephemeral auth on shared accounts, and filesystem isolation from the rest of the host. See the NSLS-II coding-agents documentation for the policy these wrappers implement. -All four wrappers source the shared library `lib/bwrap_sandbox_lib.sh` +All five wrappers source the shared library `lib/bwrap_sandbox_lib.sh` for sandbox construction. Each wrapper's `--help` is the authoritative option list; the tables below summarise the common surface. @@ -26,6 +26,8 @@ read-only mounts. They do not expose the rest of the corresponding `/etc` configuration directories. - OpenCode: `/etc/opencode/skills` +- OpenCode 2 (`bwopencode2`): `/etc/opencode/skills`, `/etc/opencode/agents`, + `/etc/opencode/commands`, and `/etc/opencode/plugins` - Codex: `/etc/codex/skills` - Claude: `/etc/claude-code/.claude/skills` and `/etc/claude-code/managed-settings.json` @@ -36,8 +38,8 @@ configuration directories. Shared agent skills are a deployment-managed skill collection. The deployment defines the source repository or checkout and manages read-only aliases at the -paths above. When those aliases are present, `bwopencode`, `bwcodex`, and -`bwclaude` mount them read-only into their sandboxes. A wrapper configured for +paths above. When those aliases are present, `bwopencode`, `bwopencode2`, +`bwcodex`, and `bwclaude` mount them read-only into their sandboxes. A wrapper configured for Copilot likewise mounts its deployment-defined shared-skill directory read-only. @@ -53,7 +55,7 @@ Every `bw*` wrapper accepts these options: | `--help`, `-h` | Show wrapper help plus the underlying tool's help | | `--dry-run` | Print the `bwrap` command without executing it | | `--exec CMD` | Run `CMD` inside the sandbox instead of the tool | -| `--init-auth` | Persist auth credentials to the host (first-time setup) | +| `--init-auth` | Persist auth credentials to the host (first-time setup; not available in `bwopencode2`) | | `--new-session` | Force `bwrap --new-session` (stricter isolation; breaks SIGWINCH) | | `--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. | @@ -77,6 +79,79 @@ personal accounts). | --- | --- | | `--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` | +## `bwopencode2` + +Launches [OpenCode 2](https://opencode.ai) (`opencode2`, npm `@opencode/cli`) +inside a bubblewrap sandbox. It is a separate wrapper from `bwopencode` +because OpenCode 2 uses different directories and has a background service +that must not be reachable from the sandbox. + +```text +bwopencode2 [bwopencode2-options] [opencode2 arguments...] +``` + +Requires the `opencode2` command on `PATH`, which ansible installs when +`base_opencode2_enable` is true. + +Accepts the common options above except `--init-auth`, plus `--github-tokens` +(same behavior as `bwopencode`). + +### Isolation from OpenCode 1 + +OpenCode 2 migrates the OpenCode 1 session database one way and cannot load +OpenCode 1 plugins, so it always uses its own directories, matching the +ansible-installed `opencode2` wrapper. Inside the sandbox the wrapper sets +`XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`, and `XDG_CACHE_HOME` to +`~/.config/opencode2`, `~/.local/share/opencode2`, `~/.local/state/opencode2`, +and `~/.cache/opencode2`, even if the `opencode2` on `PATH` is a plain binary. +OpenCode 2 appends `opencode` to each, and the OpenCode 1 directories are never +mounted. + +| Directory | Mount | +| --- | --- | +| `~/.config/opencode2/opencode` | read-only, symlinks resolved | +| `~/.local/share/opencode2/opencode` | read-write, the whole directory persists | +| `~/.cache/opencode2/opencode` | read-write (provider npm packages install here) | +| `~/.local/state/opencode2/opencode` | not persisted (ephemeral) | +| `/.opencode` | persistent copy under the data dir `project-dotfiles/`, so repository content cannot inject config or plugins | +| `/etc/opencode/opencode.json{,c}`, `skills`, `agents`, `commands`, `plugins` | read-only, when present | + +The whole data directory is bound because the OpenCode 2 layout (database with +`-shm`/`-wal`, `log`, `repos`, `shell`, ...) is not documented. Credentials are +stored in the `credential` table of its database, so `opencode2 auth login` +persists across sessions without anything like `--init-auth` (OpenCode 2's +legacy `auth.json` is only an import source). State is not persisted, so the +last-used model and prompt history are not remembered between sessions. + +Because `XDG_CONFIG_HOME` and `XDG_CACHE_HOME` point at the opencode2 directories, +the wrapper mirrors the host's `$XDG_CONFIG_HOME/git`, `pixi/config.toml`, +`ccache` config, and `$XDG_CACHE_HOME/ccache` cache under the same relative +location in the opencode2 directories so git, pixi, and ccache keep working. +`~/.gitconfig` and other `$HOME`-relative files are unaffected. + +### `--standalone` is enforced + +By default OpenCode 2 connects to a shared background service (fixed port +49374) that survives the CLI and runs tools **outside** any sandbox. The +sandbox shares the host network, so a CLI started normally would hand tool +execution to that unsandboxed server. `bwopencode2` therefore: + +- adds `--standalone` (a private in-process server inside the sandbox) when + `opencode2` is started with no subcommand or a directory argument, or with + `run`, `mini`, `models`, or `stats`; +- refuses (exit status 1) `--server`, `--standalone=...`, `--no-standalone`, + and the `service`, `serve`, `api`, `pair`, and `reload` subcommands; +- passes the other subcommands (`auth`, `debug`, `mcp`, `plugin`, `session`, + `upgrade`, `uninstall`, `acp`) through unchanged, since they do not accept + `--standalone`. + +The argument scan takes the first non-option argument as the subcommand; only +`--session`/`-s`, `--prompt`, `--log-level`, and `--completions` are known to +take a value, and nothing after `--` is inspected. `--exec` bypasses the scan +because its arguments belong to the command you run. The config directory is +read-only, so commands that write global config (for example `plugin add`) +fail; edit config on the host. + ## `bwclaude` Launches the [Claude CLI](https://docs.claude.com/en/docs/claude-code/overview) inside a bubblewrap sandbox. From 75f171b0f24383b4cb02f36d12f61176ac54b1f3 Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Sun, 18 Oct 2026 16:50:54 -0400 Subject: [PATCH 02/10] feat(bw): harden launchers, add account check, address review bwopencode: allowlist OPENCODE_* env. Library: mask git credentials, redact secret-like values in --dry-run. All launchers: run /opt/nsls2/libexec/agent-account-check when installed and report an inaccessible command clearly. bwopencode2: allowlist OPENCODE_* env, leave service*.json unmounted. Assisted-by: opencode:claude-sonnet-5-5 --- README.md | 14 ++++- bin/bwclaude | 5 ++ bin/bwcodex | 5 ++ bin/bwcopilot | 5 ++ bin/bwopencode | 90 ++++++++++++++++++++++++++-- bin/bwopencode2 | 103 +++++++++++++++++++++++++++----- docs/bw-wrappers.md | 99 ++++++++++++++++++++++++++++++- lib/bwrap_sandbox_lib.sh | 125 +++++++++++++++++++++++++++++++++++---- 8 files changed, 409 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 6d010fd..a255996 100644 --- a/README.md +++ b/README.md @@ -99,10 +99,20 @@ Common wrapper options: | `--new-session` | Force `bwrap --new-session` (stricter isolation; breaks SIGWINCH) | `bwclaude` also supports `--debug`. `bwopencode2` has no `--init-auth` (sign-in -persists in its data directory) and always runs OpenCode 2 with `--standalone`; -see [docs/bw-wrappers.md](docs/bw-wrappers.md). See each script's `--help` for +persists in its data directory), always runs OpenCode 2 with `--standalone` +(`--exec` bypasses that guard by design), passes only an allowlist of +`OPENCODE_*` environment variables, does not mount `service.json`, and masks +the mirrored git `credentials` file; see [docs/bw-wrappers.md](docs/bw-wrappers.md). See each script's `--help` for the authoritative option list. +Security notes: every launcher runs `/opt/nsls2/libexec/agent-account-check` +first when installed (refuses root and shared/service accounts); +`bwopencode` forwards only an allowlist of `OPENCODE_*` behaviour toggles (all +other host `OPENCODE_*` variables are dropped); `${XDG_CONFIG_HOME}/git/credentials` +is masked inside every sandbox; and `--dry-run` prints values of secret-looking +`--setenv` variables (names containing `KEY`, `TOKEN`, `PASSWORD`, `SECRET`, or +`CREDENTIAL`) as `REDACTED`. See `docs/bw-wrappers.md`. + ### `gh-protect-branch` Apply NSLS-II standard branch protection to a GitHub repository. Enforces diff --git a/bin/bwclaude b/bin/bwclaude index baa3cd6..642895d 100755 --- a/bin/bwclaude +++ b/bin/bwclaude @@ -156,6 +156,10 @@ parse_wrapper_args() { resolve_tool_binary() { _TOOL_BIN="$(bwrap_find_tool claude 2> /dev/null || true)" if [[ -z "${_TOOL_BIN}" ]]; then + if bwrap_tool_is_denied claude; then + echo "Error: you do not have access to 'claude' (it is restricted for shared/service accounts). Log in as yourself." >&2 + exit 1 + fi echo "Error: 'claude' not found in PATH." >&2 exit 1 fi @@ -472,6 +476,7 @@ build_claude_bin_mount() { # ═══════════════════════════════════════════════════════════════════ main() { + bwrap_check_account parse_wrapper_args "$@" resolve_tool_binary diff --git a/bin/bwcodex b/bin/bwcodex index ab69350..96d4c91 100755 --- a/bin/bwcodex +++ b/bin/bwcodex @@ -184,6 +184,10 @@ parse_wrapper_args() { resolve_tool_binary() { _TOOL_BIN="$(bwrap_find_tool codex 2> /dev/null || true)" if [[ -z "${_TOOL_BIN}" ]]; then + if bwrap_tool_is_denied codex; then + echo "Error: you do not have access to 'codex' (it is restricted for shared/service accounts). Log in as yourself." >&2 + exit 1 + fi echo "Error: 'codex' not found in PATH." >&2 exit 1 fi @@ -472,6 +476,7 @@ build_codex_bin_mount() { # ═══════════════════════════════════════════════════════════════════ main() { + bwrap_check_account parse_wrapper_args "$@" resolve_tool_binary diff --git a/bin/bwcopilot b/bin/bwcopilot index baeaefc..bbb49a8 100755 --- a/bin/bwcopilot +++ b/bin/bwcopilot @@ -148,6 +148,10 @@ parse_wrapper_args() { resolve_tool_binary() { _TOOL_BIN="$(bwrap_find_tool copilot 2> /dev/null || true)" if [[ -z "${_TOOL_BIN}" ]]; then + if bwrap_tool_is_denied copilot; then + echo "Error: you do not have access to 'copilot' (it is restricted for shared/service accounts). Log in as yourself." >&2 + exit 1 + fi echo "Error: 'copilot' not found in PATH." >&2 echo "Install the standalone GitHub Copilot CLI from https://github.com/github/copilot-cli" >&2 exit 1 @@ -424,6 +428,7 @@ build_copilot_env() { # ═══════════════════════════════════════════════════════════════════ main() { + bwrap_check_account parse_wrapper_args "$@" resolve_tool_binary diff --git a/bin/bwopencode b/bin/bwopencode index 09c7f82..8d3142c 100755 --- a/bin/bwopencode +++ b/bin/bwopencode @@ -142,6 +142,10 @@ parse_wrapper_args() { resolve_tool_binary() { _TOOL_BIN="$(bwrap_find_tool opencode 2> /dev/null || true)" if [[ -z "${_TOOL_BIN}" ]]; then + if bwrap_tool_is_denied opencode; then + echo "Error: you do not have access to 'opencode' (it is restricted for shared/service accounts). Log in as yourself." >&2 + exit 1 + fi echo "Error: 'opencode' not found in PATH." >&2 exit 1 fi @@ -383,8 +387,69 @@ build_opencode_cache_mount() { } # ── OpenCode env vars ──────────────────────────────────────────── -# Pass through any OPENCODE_* env vars (config overrides, etc.) -# Also pass through AIFAPIM_* env vars for NSLS-II Hermes gateway. +# Pass through an ALLOWLIST of harmless OPENCODE_* behaviour toggles, and +# AIFAPIM_* env vars for the NSLS-II Hermes gateway. Other host OPENCODE_* +# variables are dropped: some are secrets (OPENCODE_SERVER_PASSWORD would +# let the agent, which shares the host network, drive an unsandboxed +# opencode server) and others redirect config/data/DB/model/server locations +# or inject config (OPENCODE_CONFIG*, OPENCODE_PERMISSION) and so would +# bypass the managed config. In --dry-run the NAMES (never values) of +# dropped variables are printed to stderr. +# +# Names were enumerated from the installed opencode v1 binary +# (strings of OPENCODE_*) and the docs env-var table. Add a name here only +# if it is a pure on/off or numeric tuning toggle: never anything with +# PASSWORD, TOKEN, SECRET, KEY, URL, PORT, SERVER, DB, HOME, CONFIG, PATH or +# a *_CONTENT payload. +_OPENCODE_ENV_ALLOWLIST=( + OPENCODE_ALWAYS_NOTIFY_UPDATE + OPENCODE_DISABLE_AUTOCOMPACT + OPENCODE_DISABLE_AUTOUPDATE + OPENCODE_DISABLE_CLAUDE_CODE + OPENCODE_DISABLE_CLAUDE_CODE_PROMPT + OPENCODE_DISABLE_CLAUDE_CODE_SKILLS + OPENCODE_DISABLE_DEFAULT_PLUGINS + OPENCODE_DISABLE_EMBEDDED_WEB_UI + OPENCODE_DISABLE_EXTERNAL_SKILLS + OPENCODE_DISABLE_FFF + OPENCODE_DISABLE_LSP_DOWNLOAD + OPENCODE_DISABLE_MODELS_FETCH + OPENCODE_DISABLE_MOUSE + OPENCODE_DISABLE_PROJECT_CONFIG + OPENCODE_DISABLE_PRUNE + OPENCODE_DISABLE_TERMINAL_TITLE + OPENCODE_ENABLE_EXA + OPENCODE_ENABLE_EXPERIMENTAL_MODELS + OPENCODE_ENABLE_PARALLEL + OPENCODE_ENABLE_QUESTION_TOOL + OPENCODE_EXPERIMENTAL + OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS + OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS + OPENCODE_EXPERIMENTAL_CODE_MODE + OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER + OPENCODE_EXPERIMENTAL_EVENT_SYSTEM + OPENCODE_EXPERIMENTAL_EXA + OPENCODE_EXPERIMENTAL_FILEWATCHER + OPENCODE_EXPERIMENTAL_ICON_DISCOVERY + OPENCODE_EXPERIMENTAL_LSP_TOOL + OPENCODE_EXPERIMENTAL_LSP_TY + OPENCODE_EXPERIMENTAL_NATIVE_LLM + OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX + OPENCODE_EXPERIMENTAL_OXFMT + OPENCODE_EXPERIMENTAL_PARALLEL + OPENCODE_EXPERIMENTAL_PLAN_MODE + OPENCODE_EXPERIMENTAL_REFERENCES + OPENCODE_EXPERIMENTAL_SCOUT + OPENCODE_EXPERIMENTAL_WEBSOCKETS + OPENCODE_EXPERIMENTAL_WORKSPACES + OPENCODE_FAST_BOOT + OPENCODE_LOG_LEVEL + OPENCODE_PRINT_LOGS + OPENCODE_PURE + OPENCODE_SHOW_TTFD +) + # With --github-tokens, also forward GH_TOKEN_* vars so the agent # can authenticate gh(1) via per-command: # GH_TOKEN="$GH_TOKEN_NSLS2" gh @@ -393,13 +458,29 @@ build_opencode_env() { pass_through_if_set AIFAPIM_HOST pass_through_if_set AIFAPIM_API_KEY - local _key + local _key _allowed _ok + local _dropped=() while IFS= read -r _key; do if [[ "${_key}" == OPENCODE_* ]]; then - BWRAP_ARGS+=(--setenv "${_key}" "${!_key}") + _ok=0 + for _allowed in "${_OPENCODE_ENV_ALLOWLIST[@]}"; do + if [[ "${_key}" == "${_allowed}" ]]; then + _ok=1 + break + fi + done + if [[ "${_ok}" -eq 1 ]]; then + BWRAP_ARGS+=(--setenv "${_key}" "${!_key}") + else + _dropped+=("${_key}") + fi fi done < <(compgen -e) + if [[ "${DRY_RUN}" -eq 1 && ${#_dropped[@]} -gt 0 ]]; then + echo "Note: dropped OPENCODE_* env vars not on the allowlist (names only): ${_dropped[*]}" >&2 + fi + # GitHub CLI token passthrough (opt-in via --github-tokens) # In --dry-run mode the value is replaced with REDACTED so that tokens # are not printed to the terminal in the bwrap command output. @@ -423,6 +504,7 @@ build_opencode_env() { # ═══════════════════════════════════════════════════════════════════ main() { + bwrap_check_account parse_wrapper_args "$@" resolve_tool_binary diff --git a/bin/bwopencode2 b/bin/bwopencode2 index 5f584a7..d5b53a8 100755 --- a/bin/bwopencode2 +++ b/bin/bwopencode2 @@ -12,7 +12,8 @@ # # The sandbox provides filesystem isolation: # - Current directory: read-write -# - OpenCode 2 config (~/.config/opencode2/opencode): read-only (symlinks resolved) +# - OpenCode 2 config (~/.config/opencode2/opencode): read-only (symlinks resolved); +# service.json / service-*.json (background-service settings) are not mounted # - OpenCode 2 data (~/.local/share/opencode2/opencode): read-write, the # whole directory persists (database incl. saved sign-in, logs, ...) # - OpenCode 2 cache (~/.cache/opencode2/opencode): read-write @@ -25,7 +26,10 @@ # OpenCode 2 can never touch OpenCode 1 data (which it would migrate # one way) # - Git config + includes: read-only (also mirrored under the opencode2 -# XDG config home, where git looks inside the sandbox) +# XDG config home, where git looks inside the sandbox; the mirrored +# git/credentials file is masked with /dev/null) +# - OPENCODE_* env vars: only an allowlist of harmless switches is passed +# (see build_opencode2_env); OPENCODE_SERVER_PASSWORD etc. are dropped # - Pixi cache: read-write # - Pixi config: read-only # - ccache config: read-only @@ -57,7 +61,8 @@ # --dry-run Print the bwrap command that would be executed without running it. # --exec CMD Run CMD inside the sandbox instead of opencode2. # Useful for running one-off commands, scripts, or diagnostic tools -# inside the fully-configured sandbox environment. +# inside the fully-configured sandbox environment. The command is +# NOT subject to the --standalone argument guard (it is arbitrary). # --new-session Force bwrap --new-session even on kernels with the TIOCSTI cap # guard. Detaches the controlling tty for stricter isolation at the # cost of SIGWINCH (terminal resize) propagation. @@ -153,6 +158,10 @@ parse_wrapper_args() { resolve_tool_binary() { _TOOL_BIN="$(bwrap_find_tool opencode2 2> /dev/null || true)" if [[ -z "${_TOOL_BIN}" ]]; then + if bwrap_tool_is_denied opencode2; then + echo "Error: you do not have access to 'opencode2' (it is restricted for shared/service accounts). Log in as yourself." >&2 + exit 1 + fi echo "Error: 'opencode2' not found in PATH (OpenCode 2 is installed by ansible when base_opencode2_enable is true)." >&2 exit 1 fi @@ -214,6 +223,13 @@ Limitation: the first non-option argument is taken as the subcommand; options that take a value are only recognised for --session/-s, --prompt, --log-level and --completions. Arguments after `--` are never inspected. +--exec runs an arbitrary command and therefore bypasses this argument guard. + +Also kept out of the sandbox: service.json (service settings), all OPENCODE_* +environment variables except a short allowlist of harmless switches (see +docs/bw-wrappers.md; OPENCODE_SERVER_PASSWORD etc. are dropped), and the +git credentials file under the mirrored XDG git directory. + All other arguments are passed through to opencode2 unchanged. HELP_EOF @@ -370,6 +386,13 @@ resolve_config_symlinks() { local entry local_resolved for entry in "${OC2_CONFIG_DIR}"/*; do [[ -e "${entry}" ]] || continue + # service.json (or service-.json on other channels) holds the + # background-service settings, including `service set env KEY VALUE` + # values and the port. The sandbox never runs the service, so the + # file is not mounted. + case "${entry##*/}" in + service*.json) continue ;; + esac if [[ -L "${entry}" ]]; then local_resolved="$(readlink -f "${entry}")" else @@ -510,7 +533,19 @@ build_xdg_mirror_mounts() { # Skip the mirror when the host XDG dirs already ARE the opencode2 ones. if [[ "${XDG_CONFIG_HOME}" != "${_mirror_cfg}" ]]; then if [[ -d "${XDG_CONFIG_HOME}/git" ]]; then - BWRAP_ARGS+=(--ro-bind "${XDG_CONFIG_HOME}/git" "${_mirror_cfg}/git") + # The XDG git dir may hold `credentials` (the default file of git's + # `store` credential helper). Mask it AFTER the parent bind + # (bwrap applies mounts in order); that needs bind-over-bind + # (bwrap >= 0.6.3). Fail closed: without it, do not mirror at all. + if [[ -e "${XDG_CONFIG_HOME}/git/credentials" && "${HAS_BIND_OVER_RO}" -ne 1 ]]; then + printf 'bwopencode2: warning: bwrap is too old (< 0.6.3) to mask %s/git/credentials;\n' "${XDG_CONFIG_HOME}" >&2 + printf ' not mirroring the XDG git config into the sandbox.\n' >&2 + else + BWRAP_ARGS+=(--ro-bind "${XDG_CONFIG_HOME}/git" "${_mirror_cfg}/git") + if [[ -e "${XDG_CONFIG_HOME}/git/credentials" ]]; then + BWRAP_ARGS+=(--ro-bind /dev/null "${_mirror_cfg}/git/credentials") + fi + fi fi if [[ -f "${XDG_CONFIG_HOME}/pixi/config.toml" ]]; then BWRAP_ARGS+=( @@ -531,7 +566,17 @@ build_xdg_mirror_mounts() { } # ── OpenCode env vars ──────────────────────────────────────────── -# Pass through any OPENCODE_* env vars (config overrides, etc.) +# Pass through ONLY the allowlisted OPENCODE_* env vars (harmless behaviour +# switches). Every other OPENCODE_* variable is dropped, because the +# OpenCode 2 binary reads several sensitive ones, e.g.: +# OPENCODE_PASSWORD / OPENCODE_SERVER_PASSWORD password of a v2 server; the +# sandbox shares the host network, so holding it would let the agent +# connect to an UNSANDBOXED server (port 49374) and run tools outside +# OPENCODE_SSH_ASKPASS_TOKEN / _PORT, OPENCODE_EDITOR_SSE_PORT, +# OPENCODE_PTY_HANDOFF host-side helper channels +# OPENCODE_DB, OPENCODE_TEST_HOME, OPENCODE_CONFIG*, OPENCODE_MODELS_PATH/URL +# redirect data/config/model sources outside the sandbox layout +# OPENCODE_API_KEY credentials # Also pass through AIFAPIM_* env vars for NSLS-II Hermes gateway. # With --github-tokens, also forward GH_TOKEN_* vars so the agent # can authenticate gh(1) via per-command: @@ -542,25 +587,50 @@ build_xdg_mirror_mounts() { # so OpenCode 2 uses its own directories even if `opencode2` on PATH is a # plain binary rather than the ansible wrapper. build_opencode2_env() { + # Allowlist of OPENCODE_* variables that may enter the sandbox. + # Do NOT add anything that carries a secret, a port, a file path or a URL. + local -a _OC2_ENV_ALLOWLIST=( + OPENCODE_DISABLE_AUTOUPDATE + OPENCODE_DISABLE_FILEWATCHER + OPENCODE_FILEWATCHER_DISABLE + OPENCODE_DISABLE_MODELS_FETCH + OPENCODE_DISABLE_CLAUDE_CODE + OPENCODE_DISABLE_CLAUDE_CODE_SKILLS + OPENCODE_DISABLE_FFF + OPENCODE_DISABLE_PROJECT_CONFIG + OPENCODE_LOG_LEVEL + OPENCODE_PRINT_LOGS + ) + # AIFAPIM gateway config pass_through_if_set AIFAPIM_HOST - # The API key is replaced with REDACTED in --dry-run output so that it is - # not printed to the terminal (pass_through_if_set would print it). - if [[ -n "${AIFAPIM_API_KEY:-}" ]]; then - if [[ "${DRY_RUN}" -eq 1 ]]; then - BWRAP_ARGS+=(--setenv AIFAPIM_API_KEY REDACTED) - else - BWRAP_ARGS+=(--setenv AIFAPIM_API_KEY "${AIFAPIM_API_KEY}") - fi - fi + # The API key is passed through unchanged; print_dry_run redacts the value + # of any --setenv whose name looks secret (KEY/TOKEN/PASSWORD/...). + pass_through_if_set AIFAPIM_API_KEY - local _key + local _key _allowed _name + local -a _dropped=() while IFS= read -r _key; do - if [[ "${_key}" == OPENCODE_* ]]; then + [[ "${_key}" == OPENCODE_* ]] || continue + _allowed=0 + for _name in "${_OC2_ENV_ALLOWLIST[@]}"; do + if [[ "${_key}" == "${_name}" ]]; then + _allowed=1 + break + fi + done + if [[ "${_allowed}" -eq 1 ]]; then BWRAP_ARGS+=(--setenv "${_key}" "${!_key}") + else + _dropped+=("${_key}") fi done < <(compgen -e) + # Names only, never values. + if [[ "${DRY_RUN}" -eq 1 && ${#_dropped[@]} -gt 0 ]]; then + printf 'bwopencode2: dry-run: dropped host OPENCODE_* variables (not in allowlist): %s\n' "${_dropped[*]}" >&2 + fi + # GitHub CLI token passthrough (opt-in via --github-tokens) # In --dry-run mode the value is replaced with REDACTED so that tokens # are not printed to the terminal in the bwrap command output. @@ -593,6 +663,7 @@ build_opencode2_env() { # ═══════════════════════════════════════════════════════════════════ main() { + bwrap_check_account parse_wrapper_args "$@" resolve_tool_binary diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index 1e4e73d..827e617 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -62,6 +62,39 @@ Every `bw*` wrapper accepts these options: Tool-specific options are listed in each wrapper's section below. +## Account check + +Every `bw*` launcher runs `/opt/nsls2/libexec/agent-account-check` first +(before parsing any option, including `--help` and `--dry-run`) when that +script is installed (it is deployed by ansible). The script refuses root and +members of the shared/service-account group with a boxed message on stderr and +a non-zero exit status; the launcher exits with that status. The path is fixed +and cannot be overridden from the environment. When the script is not +installed nothing happens (the ACLs on the agent installs still apply). When +the agent command exists on `PATH` but is not executable by you (for example +because its install directory is ACL-denied), the launcher prints +`Error: you do not have access to '' (it is restricted for +shared/service accounts). Log in as yourself.` instead of "not found". + +## Credentials and secrets handling + +- **Git credentials are masked.** The host `${XDG_CONFIG_HOME}/git` directory + (default `~/.config/git`) is mounted read-only so git config, ignore, and + attributes work. If it contains a `credentials` file (the default file of + git's `store` credential helper, holding plaintext passwords or tokens), + that file is hidden by binding `/dev/null` over it. This needs + `bwrap` >= 0.6.3 (the same requirement as binary masking). On an older + `bwrap` the wrappers fail closed: when a `credentials` file exists they do + **not** mount the XDG git directory at all and print a warning to stderr. + `$HOME` itself is a tmpfs, so `~/.git-credentials` is never visible. + A credential helper that stores secrets elsewhere (for example + `credential.helper = store --file=PATH`, or a path pulled in through a git + `include`) is not covered; keep such files out of mounted locations. +- **`--dry-run` redacts secrets.** Every printed `--setenv NAME VALUE` + whose `NAME` contains (case-insensitive) `KEY`, `TOKEN`, `PASSWORD`, + `SECRET`, or `CREDENTIAL` is printed with the value `REDACTED`. Only the + printed text is redacted; the real launch passes the real values. + ## `bwopencode` Launches [opencode](https://opencode.ai) inside a bubblewrap sandbox. @@ -79,6 +112,38 @@ personal accounts). | --- | --- | | `--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` | +### `OPENCODE_*` environment allowlist + +Only an allowlist of harmless `OPENCODE_*` behaviour toggles (46 names) is +forwarded from the host environment into the sandbox. **Behaviour change:** +any other host `OPENCODE_*` variable is now dropped (previously all were +passed). Sensitive or location-changing variables are deliberately excluded: +`OPENCODE_SERVER_PASSWORD` / `OPENCODE_SERVER_USERNAME` (the sandbox shares +the host network, so an agent holding the password could drive an +unsandboxed opencode server), `OPENCODE_API_KEY`, `OPENCODE_CONSOLE_TOKEN`, +`OPENCODE_CONFIG*` / `OPENCODE_TUI_CONFIG` / `OPENCODE_PERMISSION` (inject +config or permissions and bypass the managed config), `OPENCODE_AUTH_CONTENT`, +`OPENCODE_DB` / `OPENCODE_*_DB`, `OPENCODE_MODELS_URL` / `OPENCODE_MODELS_PATH`, +and path or URL overrides. + +Forwarded: `OPENCODE_DISABLE_{AUTOCOMPACT,AUTOUPDATE,CLAUDE_CODE, +CLAUDE_CODE_PROMPT,CLAUDE_CODE_SKILLS,DEFAULT_PLUGINS,EMBEDDED_WEB_UI, +EXTERNAL_SKILLS,FFF,LSP_DOWNLOAD,MODELS_FETCH,MOUSE,PROJECT_CONFIG,PRUNE, +TERMINAL_TITLE}`, `OPENCODE_ENABLE_{EXA,EXPERIMENTAL_MODELS,PARALLEL, +QUESTION_TOOL}`, `OPENCODE_EXPERIMENTAL`, the `OPENCODE_EXPERIMENTAL_*` +feature flags listed in the `_OPENCODE_ENV_ALLOWLIST` array in +`bin/bwopencode` (including `OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX`), +`OPENCODE_ALWAYS_NOTIFY_UPDATE`, `OPENCODE_FAST_BOOT`, `OPENCODE_LOG_LEVEL`, +`OPENCODE_PRINT_LOGS`, `OPENCODE_PURE`, and `OPENCODE_SHOW_TTFD`. +`OPENCODE_DISABLE_PROJECT_CONFIG` only disables a repository's own +`opencode.json` (stricter; it cannot override `/etc/opencode`). +`OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX` is a numeric output limit; because +its name contains `TOKEN`, `--dry-run` prints its value as `REDACTED` (the +real value is passed). `AIFAPIM_HOST` and `AIFAPIM_API_KEY` are forwarded as +before. With `--dry-run`, the **names** (never values) of dropped `OPENCODE_*` +variables are printed to stderr. To permit another variable, add it to +`_OPENCODE_ENV_ALLOWLIST` in `bin/bwopencode`. + ## `bwopencode2` Launches [OpenCode 2](https://opencode.ai) (`opencode2`, npm `@opencode/cli`) @@ -127,7 +192,35 @@ Because `XDG_CONFIG_HOME` and `XDG_CACHE_HOME` point at the opencode2 directorie the wrapper mirrors the host's `$XDG_CONFIG_HOME/git`, `pixi/config.toml`, `ccache` config, and `$XDG_CACHE_HOME/ccache` cache under the same relative location in the opencode2 directories so git, pixi, and ccache keep working. -`~/.gitconfig` and other `$HOME`-relative files are unaffected. +`~/.gitconfig` and other `$HOME`-relative files are unaffected. The mirrored +`git/credentials` file (the default file of git's `store` credential helper) is +masked with `/dev/null`, so git passwords stored there are not readable inside +the sandbox. This needs bubblewrap >= 0.6.3 (bind over an existing bind); on an +older bubblewrap the wrapper prints a warning and does not mirror the XDG git +directory at all. + +### What is kept out of the sandbox + +- **`service.json`**: OpenCode 2 keeps background-service settings (including + values set with `service set env KEY VALUE`, and the port) in + `~/.config/opencode2/opencode/service.json` (other release channels may use + `service-.json`). Files matching `service*.json` are not mounted; the + rest of the config directory is. +- **`OPENCODE_*` environment variables**: only this allowlist is passed + through; every other `OPENCODE_*` variable is dropped: + `OPENCODE_DISABLE_AUTOUPDATE`, `OPENCODE_DISABLE_FILEWATCHER`, + `OPENCODE_FILEWATCHER_DISABLE`, `OPENCODE_DISABLE_MODELS_FETCH`, + `OPENCODE_DISABLE_CLAUDE_CODE`, `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS`, + `OPENCODE_DISABLE_FFF`, `OPENCODE_DISABLE_PROJECT_CONFIG`, + `OPENCODE_LOG_LEVEL`, `OPENCODE_PRINT_LOGS`. The + variables that matter most are the server passwords (`OPENCODE_PASSWORD`, + `OPENCODE_SERVER_PASSWORD`): the sandbox shares the host network, so an agent + holding one could connect to an unsandboxed server on port 49374 and run + tools outside the sandbox. Others dropped include `OPENCODE_SSH_ASKPASS_*`, + `OPENCODE_EDITOR_SSE_PORT`, `OPENCODE_PTY_HANDOFF`, `OPENCODE_DB`, + `OPENCODE_TEST_HOME`, `OPENCODE_CONFIG*`, `OPENCODE_MODELS_PATH`/`_URL`, and + `OPENCODE_API_KEY`. With `--dry-run`, the names (never the values) of the + dropped host variables are printed to stderr. ### `--standalone` is enforced @@ -148,7 +241,9 @@ execution to that unsandboxed server. `bwopencode2` therefore: The argument scan takes the first non-option argument as the subcommand; only `--session`/`-s`, `--prompt`, `--log-level`, and `--completions` are known to take a value, and nothing after `--` is inspected. `--exec` bypasses the scan -because its arguments belong to the command you run. The config directory is +by design: it runs an arbitrary command, whose arguments are not `opencode2`'s, +so `bwopencode2 --exec 'opencode2 --server ...'` is not stopped (the host +server password is no longer in the sandbox environment, see above). The config directory is read-only, so commands that write global config (for example `plugin add`) fail; edit config on the host. diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index 70c08a4..eed93ee 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -161,17 +161,32 @@ detect_shell() { SANDBOX_SHELL="$(command -v bash 2> /dev/null || echo /bin/sh)" } -# bwrap_find_tool TOOL -# Locate TOOL using PATH with exact, non-empty entries from -# N2SNSCRIPTS_TOOL_EXCLUDE_PATH removed. This affects lookup only. -bwrap_find_tool() { - if [[ -z "${N2SNSCRIPTS_TOOL_EXCLUDE_PATH:-}" ]]; then - command -v -- "$1" - return +# bwrap_check_account +# Refuse to run for root and shared/service accounts. The check itself is +# /opt/nsls2/libexec/agent-account-check, deployed by ansible: it prints a +# boxed refusal to stderr and exits non-zero for those accounts, and exits +# 0 silently otherwise. Every launcher calls this first in main(). +# The path is deliberately hard-coded (not overridable from the environment, +# which would let a user point it at /bin/true). When the script is not +# installed this is a no-op: hosts without the ansible deploy are still +# protected by the filesystem ACLs on the agent installs. +bwrap_check_account() { + local _checker="/opt/nsls2/libexec/agent-account-check" + if [[ -f "${_checker}" && -x "${_checker}" ]]; then + "${_checker}" || exit $? fi +} +# _bwrap_filter_path +# Set _BWRAP_FILTERED_PATH to PATH with exact, non-empty entries from +# N2SNSCRIPTS_TOOL_EXCLUDE_PATH removed. Returns 1 when every PATH entry +# was excluded (PATH="" would make command -v search the current dir). +# A no-op for callers when N2SNSCRIPTS_TOOL_EXCLUDE_PATH is unset: they +# should skip this function in that case. +_BWRAP_FILTERED_PATH="" +_bwrap_filter_path() { local _excluded_dir _path_dir _remaining_path="${PATH-}" _remaining_exclusions - local _filtered_path _has_separator _has_exclusion_separator _is_excluded _retained_count=0 + local _has_separator _has_exclusion_separator _is_excluded _retained_count=0 local -a _excluded_dirs=() _filtered_dirs=() local IFS=: @@ -218,14 +233,58 @@ bwrap_find_tool() { # An empty retained entry is meaningful (it searches the current # directory), but an entirely filtered PATH must not become PATH="". - # command -v with that value would incorrectly search the current dir. [[ "${_retained_count}" -gt 0 ]] || return 1 - _filtered_path="${_filtered_dirs[*]}" - local PATH="${_filtered_path}" + _BWRAP_FILTERED_PATH="${_filtered_dirs[*]}" +} + +# bwrap_find_tool TOOL +# Locate TOOL using PATH with exact, non-empty entries from +# N2SNSCRIPTS_TOOL_EXCLUDE_PATH removed. This affects lookup only. +bwrap_find_tool() { + if [[ -z "${N2SNSCRIPTS_TOOL_EXCLUDE_PATH:-}" ]]; then + command -v -- "$1" + return + fi + + _bwrap_filter_path || return 1 + local PATH="${_BWRAP_FILTERED_PATH}" command -v -- "$1" } +# bwrap_tool_is_denied NAME +# Return 0 when some directory in the (exclusion-filtered) PATH holds an +# entry called NAME that exists as a path or as a symlink but is NOT +# executable by the current user. This is what a user sees when the agent +# install directory is ACL-denied: e.g. /usr/local/bin/copilot is a symlink +# whose target directory the user cannot traverse (-e false, -L true). +# Used by the launchers to print a clear "no access" message instead of a +# misleading "not found". +bwrap_tool_is_denied() { + local _name="$1" _search_path="${PATH-}" _dir _candidate _has_separator + if [[ -n "${N2SNSCRIPTS_TOOL_EXCLUDE_PATH:-}" ]]; then + _bwrap_filter_path || return 1 + _search_path="${_BWRAP_FILTERED_PATH}" + fi + + while :; do + if [[ "${_search_path}" == *:* ]]; then + _has_separator=1 + _dir="${_search_path%%:*}" + _search_path="${_search_path#*:}" + else + _has_separator=0 + _dir="${_search_path}" + fi + _candidate="${_dir:-.}/${_name}" + if [[ -e "${_candidate}" || -L "${_candidate}" ]] && [[ ! -x "${_candidate}" ]]; then + return 0 + fi + [[ "${_has_separator}" -eq 1 ]] || break + done + return 1 +} + # ── Dynamic mount helpers ──────────────────────────────────────── # _MOUNTED_PREFIXES: tracks directory trees already covered by a bwrap # bind so we never emit duplicate (or redundant sub-path) mounts. @@ -1064,7 +1123,28 @@ build_git_mounts() { if [[ -d "${XDG_CONFIG_HOME}/git" ]]; then # XDG_CONFIG_HOME is already validated by resolve_common_paths; # no additional check needed here. - BWRAP_ARGS+=(--ro-bind "${XDG_CONFIG_HOME}/git" "${XDG_CONFIG_HOME}/git") + # + # The XDG git directory may hold "credentials": the default file of + # git's "store" credential helper (plaintext passwords/tokens). The + # directory is bound read-only for config/ignore/attributes, so the + # credentials file must be masked with /dev/null on top of it. A + # bind over a read-only bind needs bwrap >= 0.6.3 + # (HAS_BIND_OVER_RO, same requirement as build_binary_masks). On + # older bwrap we fail closed: do not mount the directory at all when + # a credentials file is present, and warn. + local _git_creds="${XDG_CONFIG_HOME}/git/credentials" + if [[ ! -e "${_git_creds}" && ! -L "${_git_creds}" ]]; then + BWRAP_ARGS+=(--ro-bind "${XDG_CONFIG_HOME}/git" "${XDG_CONFIG_HOME}/git") + elif [[ "${HAS_BIND_OVER_RO}" -eq 1 ]]; then + BWRAP_ARGS+=( + --ro-bind "${XDG_CONFIG_HOME}/git" "${XDG_CONFIG_HOME}/git" + --ro-bind /dev/null "${_git_creds}" + ) + else + echo "Warning: ${XDG_CONFIG_HOME}/git/credentials exists and this bwrap" \ + "is too old (< 0.6.3) to mask it; NOT mounting ${XDG_CONFIG_HOME}/git" \ + "into the sandbox (git config there will be unavailable)." >&2 + fi fi # Git include files (read-only). @@ -1326,6 +1406,18 @@ _bwrap_flag_arity() { esac } +# _is_secret_env_name NAME +# Return 0 if NAME looks like it holds a secret (case-insensitive match on +# KEY, TOKEN, PASSWORD, SECRET or CREDENTIAL). Used only by print_dry_run +# to redact printed --setenv values; the real launch path never calls it. +_is_secret_env_name() { + local _upper="${1^^}" + case "${_upper}" in + *KEY* | *TOKEN* | *PASSWORD* | *SECRET* | *CREDENTIAL*) return 0 ;; + *) return 1 ;; + esac +} + print_dry_run() { local _BWRAP_BIN _ENV_BIN _BWRAP_BIN="$(command -v bwrap)" @@ -1352,7 +1444,14 @@ print_dry_run() { printf " %s" "${arg}" i=$((i + 1)) for ((_j = 0; _j < _arity && i < ${#BWRAP_ARGS[@]}; _j++)); do - printf " '%s'" "${BWRAP_ARGS[$i]}" + # Redact the VALUE of secret-looking --setenv variables (only the + # printed text changes; BWRAP_ARGS keeps the real values). + if [[ "${arg}" == "--setenv" && "${_j}" -eq 1 ]] && + _is_secret_env_name "${BWRAP_ARGS[$((i - 1))]}"; then + printf " 'REDACTED'" + else + printf " '%s'" "${BWRAP_ARGS[$i]}" + fi i=$((i + 1)) done printf " \\\\\n" From 1d89e9b78b08d546d4380a43ffa52826da19fe11 Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Sun, 18 Oct 2026 18:01:34 -0400 Subject: [PATCH 03/10] fix(bw): canonicalize parent symlinks; symlink the XDG git mirror Resolve config entries, git includes and the XDG git/pixi/ccache dirs with realpath before the blocked-path check, so a symlinked parent cannot hide a blocked target. bwopencode2 links the XDG git config to the real mount so relative includes resolve. Add a shared bwrap_resolve_tool and account check for all launchers. Assisted-by: opencode:claude-sonnet-5-5 --- bin/bwclaude | 22 +-- bin/bwcodex | 22 +-- bin/bwcopilot | 23 +-- bin/bwopencode | 43 ++--- bin/bwopencode2 | 95 ++++------ docs/bw-wrappers.md | 66 +++++-- lib/bwrap_sandbox_lib.sh | 391 +++++++++++++++++++++++++++++++++++---- 7 files changed, 461 insertions(+), 201 deletions(-) diff --git a/bin/bwclaude b/bin/bwclaude index 642895d..ffd9281 100755 --- a/bin/bwclaude +++ b/bin/bwclaude @@ -105,6 +105,7 @@ parse_wrapper_args() { echo "Error: --exec requires a command argument." >&2 exit 1 fi + # shellcheck disable=SC2034 # read by bwrap_resolve_tool in the library TEST_CMD="$2" shift 2 ;; @@ -153,25 +154,6 @@ parse_wrapper_args() { # Find the absolute path to the claude binary on the host so we do # not depend on PATH lookup inside the sandbox. # Sets globals: _TOOL_BIN, _TOOL_CMD -resolve_tool_binary() { - _TOOL_BIN="$(bwrap_find_tool claude 2> /dev/null || true)" - if [[ -z "${_TOOL_BIN}" ]]; then - if bwrap_tool_is_denied claude; then - echo "Error: you do not have access to 'claude' (it is restricted for shared/service accounts). Log in as yourself." >&2 - exit 1 - fi - echo "Error: 'claude' not found in PATH." >&2 - exit 1 - fi - - if [[ -n "${TEST_CMD}" ]]; then - # Split TEST_CMD into array so multi-word commands work correctly. - read -ra _TOOL_CMD <<< "${TEST_CMD}" - else - _TOOL_CMD=("${_TOOL_BIN}") - fi -} - show_help() { cat << 'HELP_EOF' bwclaude — Claude CLI inside a bubblewrap sandbox @@ -478,7 +460,7 @@ build_claude_bin_mount() { main() { bwrap_check_account parse_wrapper_args "$@" - resolve_tool_binary + bwrap_resolve_tool claude if [[ "${SHOW_HELP}" -eq 1 ]]; then show_help diff --git a/bin/bwcodex b/bin/bwcodex index 96d4c91..dbc1a7e 100755 --- a/bin/bwcodex +++ b/bin/bwcodex @@ -129,6 +129,7 @@ parse_wrapper_args() { echo "Error: --exec requires a command argument." >&2 exit 1 fi + # shellcheck disable=SC2034 # read by bwrap_resolve_tool in the library TEST_CMD="$2" shift 2 ;; @@ -181,25 +182,6 @@ parse_wrapper_args() { # Find the absolute path to the codex binary on the host so we do # not depend on PATH lookup inside the sandbox. # Sets globals: _TOOL_BIN, _TOOL_CMD -resolve_tool_binary() { - _TOOL_BIN="$(bwrap_find_tool codex 2> /dev/null || true)" - if [[ -z "${_TOOL_BIN}" ]]; then - if bwrap_tool_is_denied codex; then - echo "Error: you do not have access to 'codex' (it is restricted for shared/service accounts). Log in as yourself." >&2 - exit 1 - fi - echo "Error: 'codex' not found in PATH." >&2 - exit 1 - fi - - if [[ -n "${TEST_CMD}" ]]; then - # Split TEST_CMD into array so multi-word commands work correctly. - read -ra _TOOL_CMD <<< "${TEST_CMD}" - else - _TOOL_CMD=("${_TOOL_BIN}") - fi -} - show_help() { cat << 'HELP_EOF' bwcodex — OpenAI Codex CLI inside a bubblewrap sandbox @@ -478,7 +460,7 @@ build_codex_bin_mount() { main() { bwrap_check_account parse_wrapper_args "$@" - resolve_tool_binary + bwrap_resolve_tool codex if [[ "${SHOW_HELP}" -eq 1 ]]; then show_help diff --git a/bin/bwcopilot b/bin/bwcopilot index bbb49a8..c7f3fe7 100755 --- a/bin/bwcopilot +++ b/bin/bwcopilot @@ -98,6 +98,7 @@ parse_wrapper_args() { echo "Error: --exec requires a command argument." >&2 exit 1 fi + # shellcheck disable=SC2034 # read by bwrap_resolve_tool in the library TEST_CMD="$2" shift 2 ;; @@ -145,26 +146,6 @@ parse_wrapper_args() { # Does NOT fall back to `gh copilot` — the gh-copilot extension is # archived/deprecated as of Oct 2025. # Sets globals: _TOOL_BIN, _TOOL_CMD -resolve_tool_binary() { - _TOOL_BIN="$(bwrap_find_tool copilot 2> /dev/null || true)" - if [[ -z "${_TOOL_BIN}" ]]; then - if bwrap_tool_is_denied copilot; then - echo "Error: you do not have access to 'copilot' (it is restricted for shared/service accounts). Log in as yourself." >&2 - exit 1 - fi - echo "Error: 'copilot' not found in PATH." >&2 - echo "Install the standalone GitHub Copilot CLI from https://github.com/github/copilot-cli" >&2 - exit 1 - fi - - if [[ -n "${TEST_CMD}" ]]; then - # Split TEST_CMD into array so multi-word commands work correctly. - read -ra _TOOL_CMD <<< "${TEST_CMD}" - else - _TOOL_CMD=("${_TOOL_BIN}") - fi -} - show_help() { cat << 'HELP_EOF' bwcopilot — GitHub Copilot CLI inside a bubblewrap sandbox @@ -430,7 +411,7 @@ build_copilot_env() { main() { bwrap_check_account parse_wrapper_args "$@" - resolve_tool_binary + bwrap_resolve_tool copilot "Install the standalone GitHub Copilot CLI from https://github.com/github/copilot-cli" if [[ "${SHOW_HELP}" -eq 1 ]]; then show_help diff --git a/bin/bwopencode b/bin/bwopencode index 8d3142c..4acad60 100755 --- a/bin/bwopencode +++ b/bin/bwopencode @@ -95,6 +95,7 @@ parse_wrapper_args() { echo "Error: --exec requires a command argument." >&2 exit 1 fi + # shellcheck disable=SC2034 # read by bwrap_resolve_tool in the library TEST_CMD="$2" shift 2 ;; @@ -139,25 +140,6 @@ parse_wrapper_args() { # Find the absolute path to the opencode binary on the host so we do # not depend on PATH lookup inside the sandbox. # Sets globals: _TOOL_BIN, _TOOL_CMD -resolve_tool_binary() { - _TOOL_BIN="$(bwrap_find_tool opencode 2> /dev/null || true)" - if [[ -z "${_TOOL_BIN}" ]]; then - if bwrap_tool_is_denied opencode; then - echo "Error: you do not have access to 'opencode' (it is restricted for shared/service accounts). Log in as yourself." >&2 - exit 1 - fi - echo "Error: 'opencode' not found in PATH." >&2 - exit 1 - fi - - if [[ -n "${TEST_CMD}" ]]; then - # Split TEST_CMD into array so multi-word commands work correctly. - read -ra _TOOL_CMD <<< "${TEST_CMD}" - else - _TOOL_CMD=("${_TOOL_BIN}") - fi -} - show_help() { cat << 'HELP_EOF' bwopencode — opencode inside a bubblewrap sandbox @@ -244,22 +226,25 @@ ensure_host_dirs() { # bind the real file at the expected path. This avoids exposing the # dotfiles tree inside the sandbox. # -# Each resolved target is passed through _check_path_safe before being -# added to OC_CONFIG_BINDS. This blocks symlinks that point into -# sensitive subtrees (e.g. ~/.config/opencode/creds -> ~/.aws/credentials). +# Every path is canonicalized with realpath (readlink -f) and the canonical +# result is passed through _check_path_safe before being added to +# OC_CONFIG_BINDS. This blocks symlinks that point into sensitive subtrees +# (e.g. ~/.config/opencode/creds -> ~/.aws/credentials) and, because ALL +# entries are canonicalized (not only symlinked ones), also a symlinked +# PARENT directory (~/.config/opencode -> ~/.aws): bwrap's --ro-bind follows +# the symlink, so the unresolved name alone would hide the real target. resolve_config_symlinks() { OC_CONFIG_BINDS=() [[ -d "${OC_CONFIG_DIR}" ]] || return 0 + # The config directory itself may be (or sit under) a symlink. + _bwrap_canonicalize_safe "${OC_CONFIG_DIR}" "opencode config dir" + local entry local_resolved for entry in "${OC_CONFIG_DIR}"/*; do [[ -e "${entry}" ]] || continue - if [[ -L "${entry}" ]]; then - local_resolved="$(readlink -f "${entry}")" - else - local_resolved="${entry}" - fi - _check_path_safe "${local_resolved}" "opencode config entry '${entry}'" + _bwrap_canonicalize_safe "${entry}" "opencode config entry '${entry}'" + local_resolved="${_BWRAP_CANON}" OC_CONFIG_BINDS+=("--ro-bind" "${local_resolved}" "${entry}") done } @@ -506,7 +491,7 @@ build_opencode_env() { main() { bwrap_check_account parse_wrapper_args "$@" - resolve_tool_binary + bwrap_resolve_tool opencode if [[ "${SHOW_HELP}" -eq 1 ]]; then show_help diff --git a/bin/bwopencode2 b/bin/bwopencode2 index d5b53a8..2830c37 100755 --- a/bin/bwopencode2 +++ b/bin/bwopencode2 @@ -6,8 +6,8 @@ # - 0.5.0+: --clearenv (fallback: manual --unsetenv for each host var) # - 0.6.3+: bind over ro-bind (fallback: skip binary masking) # -# Requires the `opencode2` command (npm @opencode/cli), which ansible installs -# when base_opencode2_enable is true. OpenCode 2 is a separate product from +# Requires the `opencode2` command (npm @opencode/cli), which the ansible base +# role installs. OpenCode 2 is a separate product from # OpenCode 1 (see bwopencode) and never shares its directories. # # The sandbox provides filesystem isolation: @@ -155,25 +155,6 @@ parse_wrapper_args() { # Find the absolute path to the opencode2 binary on the host so we do # not depend on PATH lookup inside the sandbox. # Sets globals: _TOOL_BIN, _TOOL_CMD -resolve_tool_binary() { - _TOOL_BIN="$(bwrap_find_tool opencode2 2> /dev/null || true)" - if [[ -z "${_TOOL_BIN}" ]]; then - if bwrap_tool_is_denied opencode2; then - echo "Error: you do not have access to 'opencode2' (it is restricted for shared/service accounts). Log in as yourself." >&2 - exit 1 - fi - echo "Error: 'opencode2' not found in PATH (OpenCode 2 is installed by ansible when base_opencode2_enable is true)." >&2 - exit 1 - fi - - if [[ -n "${TEST_CMD}" ]]; then - # Split TEST_CMD into array so multi-word commands work correctly. - read -ra _TOOL_CMD <<< "${TEST_CMD}" - else - _TOOL_CMD=("${_TOOL_BIN}") - fi -} - show_help() { cat << 'HELP_EOF' bwopencode2 — OpenCode 2 (opencode2) inside a bubblewrap sandbox @@ -376,13 +357,20 @@ ensure_host_dirs() { # bind the real file at the expected path. This avoids exposing the # dotfiles tree inside the sandbox. # -# Each resolved target is passed through _check_path_safe before being -# added to OC2_CONFIG_BINDS. This blocks symlinks that point into -# sensitive subtrees (e.g. config/creds -> ~/.aws/credentials). +# Every path is canonicalized with realpath (readlink -f) and the canonical +# result is passed through _check_path_safe before being added to +# OC2_CONFIG_BINDS. This blocks symlinks that point into sensitive subtrees +# (e.g. config/creds -> ~/.aws/credentials) and, because ALL entries are +# canonicalized (not only symlinked ones), also a symlinked PARENT directory +# (~/.config/opencode2 -> ~/.aws): bwrap's --ro-bind follows the symlink, so +# the unresolved name alone would hide the real target. resolve_config_symlinks() { OC2_CONFIG_BINDS=() [[ -d "${OC2_CONFIG_DIR}" ]] || return 0 + # The config directory itself may be (or sit under) a symlink. + _bwrap_canonicalize_safe "${OC2_CONFIG_DIR}" "opencode2 config dir" + local entry local_resolved for entry in "${OC2_CONFIG_DIR}"/*; do [[ -e "${entry}" ]] || continue @@ -393,12 +381,8 @@ resolve_config_symlinks() { case "${entry##*/}" in service*.json) continue ;; esac - if [[ -L "${entry}" ]]; then - local_resolved="$(readlink -f "${entry}")" - else - local_resolved="${entry}" - fi - _check_path_safe "${local_resolved}" "opencode2 config entry '${entry}'" + _bwrap_canonicalize_safe "${entry}" "opencode2 config entry '${entry}'" + local_resolved="${_BWRAP_CANON}" OC2_CONFIG_BINDS+=("--ro-bind" "${local_resolved}" "${entry}") done } @@ -523,44 +507,45 @@ build_opencode2_state_dir() { # Inside the sandbox XDG_CONFIG_HOME and XDG_CACHE_HOME point at the # opencode2 directories, so git, pixi and ccache would no longer find # their configuration at the host locations the shared library mounts. -# Mirror whatever the host has under its XDG config/cache home at the -# same relative location under the opencode2 homes (read-only for -# config, read-write for the ccache cache). ~/.gitconfig and other -# $HOME-relative files are unaffected. +# Make whatever the host has under its XDG config/cache home visible at the +# same relative location under the opencode2 homes. +# +# These are SYMLINKS to the host-path mounts the shared library already made +# (build_git_mounts / build_pixi_mounts / build_ccache_mounts), not second +# binds. Why: git resolves a relative include (`path = ../shared`) against +# the directory of the config path AS OPENED. A bind of the git dir at +# ~/.config/opencode2/git would make `../shared` resolve to +# ~/.config/opencode2/shared (which does not exist) and git would silently +# skip it. Through a symlinked directory the kernel resolves `..` against +# the real parent, so ~/.config/opencode2/git/../shared is the same file as +# ~/.config/shared, where the library mounted the include. The symlink also +# inherits the read-only mount and the credentials mask of the target for +# free. Each symlink is created only when the library really mounted its +# target (otherwise it would dangle). Must run AFTER build_home_tmpfs and +# the library mount builders (sets/reads _BWRAP_XDG_GIT_MOUNTED). +# ~/.gitconfig and other $HOME-relative files are unaffected. build_xdg_mirror_mounts() { local _mirror_cfg="${OC2_XDG_CONFIG_HOME}" # Skip the mirror when the host XDG dirs already ARE the opencode2 ones. if [[ "${XDG_CONFIG_HOME}" != "${_mirror_cfg}" ]]; then - if [[ -d "${XDG_CONFIG_HOME}/git" ]]; then - # The XDG git dir may hold `credentials` (the default file of git's - # `store` credential helper). Mask it AFTER the parent bind - # (bwrap applies mounts in order); that needs bind-over-bind - # (bwrap >= 0.6.3). Fail closed: without it, do not mirror at all. - if [[ -e "${XDG_CONFIG_HOME}/git/credentials" && "${HAS_BIND_OVER_RO}" -ne 1 ]]; then - printf 'bwopencode2: warning: bwrap is too old (< 0.6.3) to mask %s/git/credentials;\n' "${XDG_CONFIG_HOME}" >&2 - printf ' not mirroring the XDG git config into the sandbox.\n' >&2 - else - BWRAP_ARGS+=(--ro-bind "${XDG_CONFIG_HOME}/git" "${_mirror_cfg}/git") - if [[ -e "${XDG_CONFIG_HOME}/git/credentials" ]]; then - BWRAP_ARGS+=(--ro-bind /dev/null "${_mirror_cfg}/git/credentials") - fi - fi + if [[ "${_BWRAP_XDG_GIT_MOUNTED}" -eq 1 ]]; then + BWRAP_ARGS+=(--symlink "${XDG_CONFIG_HOME}/git" "${_mirror_cfg}/git") fi + # build_pixi_mounts binds exactly this file at its host path. if [[ -f "${XDG_CONFIG_HOME}/pixi/config.toml" ]]; then - BWRAP_ARGS+=( - --dir "${_mirror_cfg}/pixi" - --ro-bind "${XDG_CONFIG_HOME}/pixi/config.toml" "${_mirror_cfg}/pixi/config.toml" - ) + BWRAP_ARGS+=(--dir "${_mirror_cfg}/pixi" + --symlink "${XDG_CONFIG_HOME}/pixi/config.toml" "${_mirror_cfg}/pixi/config.toml") fi # ccache config is only XDG-relative when the legacy ~/.ccache is absent. + # build_ccache_mounts binds it at its host path when it is a directory. if [[ "${CCACHE_CONFIG_DIR}" == "${XDG_CONFIG_HOME}/ccache" && -d "${CCACHE_CONFIG_DIR}" ]]; then - BWRAP_ARGS+=(--ro-bind "${CCACHE_CONFIG_DIR}" "${_mirror_cfg}/ccache") + BWRAP_ARGS+=(--symlink "${CCACHE_CONFIG_DIR}" "${_mirror_cfg}/ccache") fi fi if [[ "${XDG_CACHE_HOME}" != "${OC2_XDG_CACHE_HOME}" ]]; then if [[ "${CCACHE_CACHE_DIR}" == "${XDG_CACHE_HOME}/ccache" && -d "${CCACHE_CACHE_DIR}" ]]; then - BWRAP_ARGS+=(--bind "${CCACHE_CACHE_DIR}" "${OC2_XDG_CACHE_HOME}/ccache") + BWRAP_ARGS+=(--symlink "${CCACHE_CACHE_DIR}" "${OC2_XDG_CACHE_HOME}/ccache") fi fi } @@ -665,7 +650,7 @@ build_opencode2_env() { main() { bwrap_check_account parse_wrapper_args "$@" - resolve_tool_binary + bwrap_resolve_tool opencode2 "OpenCode 2 is installed by the ansible base role." if [[ "${SHOW_HELP}" -eq 1 ]]; then show_help diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index 827e617..22c6e33 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -70,11 +70,32 @@ script is installed (it is deployed by ansible). The script refuses root and members of the shared/service-account group with a boxed message on stderr and a non-zero exit status; the launcher exits with that status. The path is fixed and cannot be overridden from the environment. When the script is not -installed nothing happens (the ACLs on the agent installs still apply). When -the agent command exists on `PATH` but is not executable by you (for example -because its install directory is ACL-denied), the launcher prints -`Error: you do not have access to '' (it is restricted for -shared/service accounts). Log in as yourself.` instead of "not found". +installed nothing happens (the ACLs on the agent installs still apply). + +## Agent binary resolution + +After the account check every launcher resolves its agent command with the +shared `bwrap_resolve_tool` library function and distinguishes three failures +(each exits 1): + +- **Restricted.** The command on `PATH` is a symlink whose target directory + exists but cannot be entered by you (for example the ansible ACL that denies + shared/service accounts access to the agent package directory). The launcher + prints the same box as the ansible account check, without the two + documentation links and with an extra line naming the tool + (`'' is not available to this account.`). The box code in + `lib/bwrap_sandbox_lib.sh` mirrors + `roles/base/templates/agent-account-check.sh.j2` in NSLS2/ansible; the two + must be kept in step. +- **Installed incorrectly.** The command is a dangling symlink, or a file that + is not executable: + `Error: '' is installed incorrectly ( points to a missing file or + is not executable). Ask your system administrator.` +- **Not installed.** `Error: '' not found in PATH.` (plus an install + hint for `bwcopilot` and `bwopencode2`). + +Entries listed in `N2SNSCRIPTS_TOOL_EXCLUDE_PATH` are ignored in all three +cases. ## Credentials and secrets handling @@ -90,6 +111,14 @@ shared/service accounts). Log in as yourself.` instead of "not found". A credential helper that stores secrets elsewhere (for example `credential.helper = store --file=PATH`, or a path pulled in through a git `include`) is not covered; keep such files out of mounted locations. +- **Paths are resolved before the blocked-path check.** Config entries and + config directories (`~/.config/opencode*`), git include files, and the XDG + `git`, `pixi/config.toml` and `ccache` locations are canonicalized with + `realpath` (`readlink -f`) before the blocked-path check, so a symlinked + parent directory (for example `~/.config/opencode2 -> ~/.aws`) cannot hide a + blocked target; such a setup is refused with an error. A git include reached + through a symlinked directory is mounted at both its real path and the path + git opens. - **`--dry-run` redacts secrets.** Every printed `--setenv NAME VALUE` whose `NAME` contains (case-insensitive) `KEY`, `TOKEN`, `PASSWORD`, `SECRET`, or `CREDENTIAL` is printed with the value `REDACTED`. Only the @@ -155,8 +184,8 @@ that must not be reachable from the sandbox. bwopencode2 [bwopencode2-options] [opencode2 arguments...] ``` -Requires the `opencode2` command on `PATH`, which ansible installs when -`base_opencode2_enable` is true. +Requires the `opencode2` command on `PATH`, which the ansible base role +installs. Accepts the common options above except `--init-auth`, plus `--github-tokens` (same behavior as `bwopencode`). @@ -189,15 +218,22 @@ legacy `auth.json` is only an import source). State is not persisted, so the last-used model and prompt history are not remembered between sessions. Because `XDG_CONFIG_HOME` and `XDG_CACHE_HOME` point at the opencode2 directories, -the wrapper mirrors the host's `$XDG_CONFIG_HOME/git`, `pixi/config.toml`, -`ccache` config, and `$XDG_CACHE_HOME/ccache` cache under the same relative +the wrapper makes the host's `$XDG_CONFIG_HOME/git`, `pixi/config.toml`, +`ccache` config, and `$XDG_CACHE_HOME/ccache` cache visible at the same relative location in the opencode2 directories so git, pixi, and ccache keep working. -`~/.gitconfig` and other `$HOME`-relative files are unaffected. The mirrored -`git/credentials` file (the default file of git's `store` credential helper) is -masked with `/dev/null`, so git passwords stored there are not readable inside -the sandbox. This needs bubblewrap >= 0.6.3 (bind over an existing bind); on an -older bubblewrap the wrapper prints a warning and does not mirror the XDG git -directory at all. +These are symlinks to the host-path mounts the shared library already made, not +second binds: git resolves a relative `[include] path = ../shared` against the +directory of the config path as opened, so a bind at +`~/.config/opencode2/git` would look for `~/.config/opencode2/shared` and +silently skip the include, whereas through the symlink `..` resolves to the real +parent (`~/.config`), where the include is mounted. The read-only mount and the +`git/credentials` mask of the target apply automatically: that file (the default +file of git's `store` credential helper) is masked with `/dev/null`, so git +passwords stored there are not readable inside the sandbox. This needs +bubblewrap >= 0.6.3 (bind over an existing bind); on an older bubblewrap the +library prints a warning and does not mount the XDG git directory at all, so no +symlink is created. `~/.gitconfig` and other `$HOME`-relative files are +unaffected. ### What is kept out of the sandbox diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index eed93ee..f7bf143 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -17,7 +17,7 @@ # Tool-specific wrapper scripts source this file and provide: # - Argument parsing (parse_wrapper_args) — must handle --ro-path/--rw-path # and accumulate values into the shared EXTRA_RO_PATHS/EXTRA_RW_PATHS arrays -# - Binary resolution (resolve_tool_binary) setting _TOOL_BIN and _TOOL_CMD +# - Binary resolution: call bwrap_resolve_tool NAME [HINT] (sets _TOOL_BIN and _TOOL_CMD) # - Help text (show_help) # - Tool-specific path resolution, host directory creation, mounts, and env vars # - main() orchestrating the build sequence — must call build_extra_path_mounts @@ -60,7 +60,7 @@ EXTRA_RO_PATHS=() # shellcheck disable=SC2034 # used by tool wrappers that source this library EXTRA_RW_PATHS=() -# Tool binary (set by tool-specific resolve_tool_binary) +# Tool binary (set by bwrap_resolve_tool) _TOOL_BIN="" _TOOL_CMD=() @@ -86,9 +86,24 @@ HAS_BIND_OVER_RO=0 # Kernel capability flags (set by detect_kernel_capabilities) KERNEL_HAS_TIOCSTI_CAP_GUARD=0 -# Git include tracking (used by parse_git_includes) +# Git include tracking (used by parse_git_includes). +# GIT_INCLUDE_PATHS holds the CANONICAL (realpath) include files; +# GIT_INCLUDE_VISIBLE holds, at the same index, the path git itself will +# open (the lexically normalised path as computed from the config file's +# own location). They differ when a parent directory of the include is a +# symlink; build_git_mounts then mounts the content at both paths. declare -A _GIT_INCLUDE_SEEN=() GIT_INCLUDE_PATHS=() +GIT_INCLUDE_VISIBLE=() + +# Result of _bwrap_canonicalize_safe / _bwrap_normalize_path (globals, +# because _check_path_safe calls exit and must not run in a subshell). +_BWRAP_CANON="" +_BWRAP_NORMALIZED="" + +# Set to 1 by build_git_mounts when ${XDG_CONFIG_HOME}/git is actually +# mounted in the sandbox at its host path (launchers may point symlinks at it). +_BWRAP_XDG_GIT_MOUNTED=0 # Working directory state (set by build_workdir_mount) _BIND_DIR="" @@ -252,16 +267,116 @@ bwrap_find_tool() { command -v -- "$1" } -# bwrap_tool_is_denied NAME -# Return 0 when some directory in the (exclusion-filtered) PATH holds an -# entry called NAME that exists as a path or as a symlink but is NOT -# executable by the current user. This is what a user sees when the agent -# install directory is ACL-denied: e.g. /usr/local/bin/copilot is a symlink -# whose target directory the user cannot traverse (-e false, -L true). -# Used by the launchers to print a clear "no access" message instead of a -# misleading "not found". -bwrap_tool_is_denied() { +# ── Agent binary resolution and the "restricted" box ───────────── +# NOTE: bwrap_account_name and bwrap_print_banner mirror the box drawing in +# the ansible template roles/base/templates/agent-account-check.sh.j2 in +# NSLS2/ansible (same UTF-8/ASCII glyphs, 78-column border, 76-column panel, +# colour rules and padding arithmetic). The two copies must be kept in step: +# a change to the look of one box must be made in the other. + +# bwrap_account_name +# Print the current account name, or the numeric uid when the uid has no +# name. `id -un` can print the number and still fail for an unnamed uid, +# so its exit status is ignored and an empty result falls back to `id -u`. +bwrap_account_name() { + local _name + _name="$(/usr/bin/id -un 2> /dev/null || true)" + printf '%s\n' "${_name:-$(/usr/bin/id -u)}" +} + +# _bwrap_banner_panel VERTICAL CONTENT STYLE RESET +# One left-aligned panel line (76 columns of content between the borders). +_bwrap_banner_panel() { + local _vertical="$1" _content="$2" _style="$3" _reset="$4" + local _padding=$((76 - ${#_content})) + + printf '%s %s%s%s%*s %s\n' \ + "${_vertical}" "${_style}" "${_content}" "${_reset}" "${_padding}" '' "${_vertical}" >&2 +} + +# _bwrap_banner_centered VERTICAL CONTENT STYLE RESET +# One centred panel line. +_bwrap_banner_centered() { + local _vertical="$1" _content="$2" _style="$3" _reset="$4" + local _padding=$((76 - ${#_content})) + local _left_padding=$((_padding / 2)) + local _right_padding=$((_padding - _left_padding)) + + printf '%s %*s%s%s%s%*s %s\n' \ + "${_vertical}" "${_left_padding}" '' "${_style}" "${_content}" "${_reset}" \ + "${_right_padding}" '' "${_vertical}" >&2 +} + +# bwrap_print_banner TITLE LINE... +# Draw a boxed message on STDERR (never /dev/tty, so it works without a +# terminal): blank line, box, blank line. Layout: blank panel line, +# centred bold-red TITLE between warning markers, blank panel line, each +# LINE left-aligned (longer than 76 columns => truncated with an ellipsis +# so the border stays aligned), blank panel line. UTF-8 locales get +# box-drawing glyphs; others get ASCII. Colour only when stderr is a tty, +# NO_COLOR is unset and TERM is set and not "dumb". +# Mirrors agent-account-check.sh.j2 in NSLS2/ansible -- keep in step. +bwrap_print_banner() { + local _title="$1" + shift + local _top_left _top_right _bottom_left _bottom_right + local _horizontal _vertical _warning_marker _ellipsis + local _style_reset='' _style_bold_red='' _border='' _column _line + + if [[ "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" =~ [Uu][Tt][Ff]-?8 ]]; then + _top_left='┌' + _top_right='┐' + _bottom_left='└' + _bottom_right='┘' + _horizontal='─' + _vertical='│' + _warning_marker='⚠' + _ellipsis='…' + else + _top_left='+' + _top_right='+' + _bottom_left='+' + _bottom_right='+' + _horizontal='-' + _vertical='|' + _warning_marker='[!]' + _ellipsis='...' + fi + + if [[ -t 2 && -z "${NO_COLOR:-}" && -n "${TERM:-}" && "${TERM}" != dumb ]]; then + _style_reset=$'\033[0m' + _style_bold_red=$'\033[1;31m' + fi + + # tr is byte-oriented and corrupts multibyte box-drawing characters. + for ((_column = 0; _column < 78; _column++)); do + _border+="${_horizontal}" + done + + printf '\n%s%s%s\n' "${_top_left}" "${_border}" "${_top_right}" >&2 + _bwrap_banner_panel "${_vertical}" '' '' "${_style_reset}" + _bwrap_banner_centered "${_vertical}" "${_warning_marker} ${_title} ${_warning_marker}" \ + "${_style_bold_red}" "${_style_reset}" + _bwrap_banner_panel "${_vertical}" '' '' "${_style_reset}" + for _line in "$@"; do + if ((${#_line} > 76)); then + _line="${_line:0:76-${#_ellipsis}}${_ellipsis}" + fi + _bwrap_banner_panel "${_vertical}" "${_line}" '' "${_style_reset}" + done + _bwrap_banner_panel "${_vertical}" '' '' "${_style_reset}" + printf '%s%s%s\n\n' "${_bottom_left}" "${_border}" "${_bottom_right}" >&2 +} + +# _bwrap_first_candidate NAME +# Set _BWRAP_CANDIDATE to the first PATH entry (exclusion-filtered by +# N2SNSCRIPTS_TOOL_EXCLUDE_PATH, like bwrap_find_tool) called NAME that +# exists as a path or as a symlink -- even when it is not usable. Returns +# 1 (and leaves it empty) when there is none. +_BWRAP_CANDIDATE="" +_bwrap_first_candidate() { local _name="$1" _search_path="${PATH-}" _dir _candidate _has_separator + _BWRAP_CANDIDATE="" if [[ -n "${N2SNSCRIPTS_TOOL_EXCLUDE_PATH:-}" ]]; then _bwrap_filter_path || return 1 _search_path="${_BWRAP_FILTERED_PATH}" @@ -277,7 +392,8 @@ bwrap_tool_is_denied() { _dir="${_search_path}" fi _candidate="${_dir:-.}/${_name}" - if [[ -e "${_candidate}" || -L "${_candidate}" ]] && [[ ! -x "${_candidate}" ]]; then + if [[ -e "${_candidate}" || -L "${_candidate}" ]]; then + _BWRAP_CANDIDATE="${_candidate}" return 0 fi [[ "${_has_separator}" -eq 1 ]] || break @@ -285,6 +401,130 @@ bwrap_tool_is_denied() { return 1 } +# _bwrap_symlink_is_restricted LINK +# Return 0 when following the symlink LINK directory by directory from "/" +# reaches a directory that exists but cannot be entered (`-d && ! -x`) +# before the final file. That is what an ansible ACL `group::---` on an +# agent package directory looks like to a denied user: the symlink in +# /usr/local/bin is visible but its target is `-e` false because a parent +# directory cannot be traversed. Returns 1 otherwise (including a +# dangling link, where the missing component's parent IS enterable). +# The raw `readlink` target is used (no -f); a relative target is resolved +# against the link's directory; "." and ".." are handled lexically; a +# component that is itself a symlink is resolved with `readlink -f` (a +# failure counts as broken); at most 10 symlink hops are followed. +_bwrap_symlink_is_restricted() { + local _link="$1" _hops=0 _target _resolved _next _comp _i _last + local -a _parts + + while :; do + ((_hops += 1)) + [[ "${_hops}" -le 10 ]] || return 1 + _target="$(readlink -- "${_link}")" || return 1 + [[ -n "${_target}" ]] || return 1 + if [[ "${_target}" != /* ]]; then + [[ "${_link}" == /* ]] || _link="${PWD}/${_link}" + _target="${_link%/*}/${_target}" + fi + + _resolved="/" + IFS='/' read -ra _parts <<< "${_target}" + _last=$((${#_parts[@]} - 1)) + for ((_i = 0; _i <= _last; _i++)); do + _comp="${_parts[_i]}" + case "${_comp}" in + '' | .) continue ;; + ..) + _resolved="${_resolved%/*}" + [[ -n "${_resolved}" ]] || _resolved="/" + continue + ;; + esac + _next="${_resolved%/}/${_comp}" + + if [[ "${_i}" -eq "${_last}" ]]; then + # Final component: another symlink hop, or the end of the road. + if [[ -L "${_next}" ]]; then + _link="${_next}" + continue 2 + fi + return 1 + fi + + if [[ -L "${_next}" ]]; then + _next="$(readlink -f -- "${_next}")" || return 1 + fi + [[ -e "${_next}" ]] || return 1 + [[ -d "${_next}" ]] || return 1 + if [[ ! -x "${_next}" ]]; then + return 0 + fi + _resolved="${_next}" + done + # Target ended in "/", "." or ".." (a directory): nothing more to follow. + return 1 + done +} + +# bwrap_resolve_tool NAME [NOT_FOUND_HINT] +# Resolve the agent binary NAME for a launcher, setting _TOOL_BIN and +# _TOOL_CMD (TEST_CMD, split into words, overrides the command). When NAME +# cannot be used, exit 1 with a message that tells three cases apart: +# restricted NAME is a symlink whose target directory exists but +# cannot be entered (the ansible ACL on shared/service +# accounts): the same box as the ansible account check +# (without the docs links), naming the tool. +# installed incorrectly +# a dangling symlink, or a file that is not executable. +# not installed nothing called NAME in PATH; NOT_FOUND_HINT (optional) +# is printed on a second line. +bwrap_resolve_tool() { + local _name="$1" _hint="${2:-}" _account _max_name _suffix + + # `command -v` falls back to a non-executable file when no executable + # NAME exists in PATH, so a result must also pass -x to count as found. + _TOOL_BIN="$(bwrap_find_tool "${_name}" 2> /dev/null || true)" + if [[ -n "${_TOOL_BIN}" && -x "${_TOOL_BIN}" ]]; then + if [[ -n "${TEST_CMD}" ]]; then + # Split TEST_CMD into array so multi-word commands work correctly. + read -ra _TOOL_CMD <<< "${TEST_CMD}" + else + _TOOL_CMD=("${_TOOL_BIN}") + fi + return 0 + fi + + if ! _bwrap_first_candidate "${_name}"; then + echo "Error: '${_name}' not found in PATH." >&2 + [[ -z "${_hint}" ]] || echo "${_hint}" >&2 + exit 1 + fi + + if [[ -L "${_BWRAP_CANDIDATE}" ]] && _bwrap_symlink_is_restricted "${_BWRAP_CANDIDATE}"; then + _account="$(bwrap_account_name)" + # Keep the line within the 76-column panel by truncating the account + # name with the glyph set the box will use (same logic as the ansible + # check). + _suffix="' is a shared/service account." + _max_name=$((76 - 1 - ${#_suffix})) + if ((${#_account} > _max_name)); then + if [[ "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" =~ [Uu][Tt][Ff]-?8 ]]; then + _account="${_account:0:_max_name-1}…" + else + _account="${_account:0:_max_name-3}..." + fi + fi + bwrap_print_banner 'AI coding agents cannot be run from this account' \ + "'${_account}${_suffix}" \ + "'${_name}' is not available to this account." \ + 'Log in as yourself first: su - YOUR-USERNAME' + exit 1 + fi + + echo "Error: '${_name}' is installed incorrectly (${_BWRAP_CANDIDATE} points to a missing file or is not executable). Ask your system administrator." >&2 + exit 1 +} + # ── Dynamic mount helpers ──────────────────────────────────────── # _MOUNTED_PREFIXES: tracks directory trees already covered by a bwrap # bind so we never emit duplicate (or redundant sub-path) mounts. @@ -465,18 +705,21 @@ parse_git_includes() { path_val="${config_dir}/${path_val}" fi - # Resolve symlinks and canonicalize - local resolved - if [[ -L "${path_val}" ]]; then - resolved="$(readlink -f "${path_val}" 2> /dev/null || echo "${path_val}")" - else - resolved="${path_val}" - fi - - # Deduplicate - if [[ -e "${resolved}" ]] && [[ -z "${_GIT_INCLUDE_SEEN["${resolved}"]:-}" ]]; then - _GIT_INCLUDE_SEEN["${resolved}"]=1 + # Lexically normalise the path git will open (drop "." / ".." / "//"), + # then canonicalize it with realpath. Canonicalize ALWAYS, not only + # when the last component is a symlink: a symlinked parent directory + # would otherwise hide the real target from _check_path_safe. + local visible resolved + _bwrap_normalize_path "${path_val}" + visible="${_BWRAP_NORMALIZED}" + resolved="$(readlink -f "${path_val}" 2> /dev/null || true)" + [[ -n "${resolved}" ]] || resolved="${visible}" + + # Deduplicate on the (canonical, visible) pair + if [[ -e "${resolved}" ]] && [[ -z "${_GIT_INCLUDE_SEEN["${resolved}|${visible}"]:-}" ]]; then + _GIT_INCLUDE_SEEN["${resolved}|${visible}"]=1 GIT_INCLUDE_PATHS+=("${resolved}") + GIT_INCLUDE_VISIBLE+=("${visible}") fi done < "${config_file}" } @@ -651,6 +894,42 @@ _check_dir_safe() { _check_path_safe "${canon}" "${context}" } +# _bwrap_canonicalize_safe PATH CONTEXT +# Canonicalize PATH with readlink -f (falls back to PATH itself when +# readlink fails or PATH does not exist) and run _check_path_safe on the +# canonical result. ALWAYS canonicalizes, so a symlinked parent directory +# cannot hide a blocked target (bwrap's --ro-bind follows the symlink and +# would mount the real file). +# +# The canonical path is returned in the global _BWRAP_CANON, NOT on stdout: +# _check_path_safe calls exit 1, and inside $(...) that would only leave +# the subshell and let the launcher carry on. +_bwrap_canonicalize_safe() { + local path="$1" + local context="$2" + _BWRAP_CANON="$(readlink -f "${path}" 2> /dev/null || true)" + [[ -n "${_BWRAP_CANON}" ]] || _BWRAP_CANON="${path}" + _check_path_safe "${_BWRAP_CANON}" "${context}" +} + +# _bwrap_normalize_path PATH +# Purely lexical normalisation of an absolute path (collapses "//", "." +# and "..") without touching the filesystem. Result in _BWRAP_NORMALIZED. +_bwrap_normalize_path() { + local path="$1" part + local -a parts=() out=() + local IFS=/ + read -r -a parts <<< "${path}" + for part in "${parts[@]+"${parts[@]}"}"; do + case "${part}" in + '' | .) ;; + ..) [[ ${#out[@]} -gt 0 ]] && unset 'out[${#out[@]}-1]' ;; + *) out+=("${part}") ;; + esac + done + _BWRAP_NORMALIZED="/${out[*]}" +} + # validate_extra_path RAW_PATH MODE # Validate a user-supplied path (from --ro-path or --rw-path) before # mounting it into the sandbox. @@ -1121,8 +1400,13 @@ build_git_mounts() { BWRAP_ARGS+=(--ro-bind "${_gitconfig_real}" "${_HOME}/.gitconfig") fi if [[ -d "${XDG_CONFIG_HOME}/git" ]]; then - # XDG_CONFIG_HOME is already validated by resolve_common_paths; - # no additional check needed here. + # XDG_CONFIG_HOME itself is validated by resolve_common_paths, but + # the git subdirectory can be a symlink pointing anywhere (e.g. + # ~/.config/git -> ~/.ssh), so canonicalize and check it too. The + # canonical directory is bound at the host path so that relative + # includes (../shared) resolve against the host layout. + _bwrap_canonicalize_safe "${XDG_CONFIG_HOME}/git" "XDG git config dir" + local _git_xdg_real="${_BWRAP_CANON}" # # The XDG git directory may hold "credentials": the default file of # git's "store" credential helper (plaintext passwords/tokens). The @@ -1134,12 +1418,14 @@ build_git_mounts() { # a credentials file is present, and warn. local _git_creds="${XDG_CONFIG_HOME}/git/credentials" if [[ ! -e "${_git_creds}" && ! -L "${_git_creds}" ]]; then - BWRAP_ARGS+=(--ro-bind "${XDG_CONFIG_HOME}/git" "${XDG_CONFIG_HOME}/git") + BWRAP_ARGS+=(--ro-bind "${_git_xdg_real}" "${XDG_CONFIG_HOME}/git") + _BWRAP_XDG_GIT_MOUNTED=1 elif [[ "${HAS_BIND_OVER_RO}" -eq 1 ]]; then BWRAP_ARGS+=( - --ro-bind "${XDG_CONFIG_HOME}/git" "${XDG_CONFIG_HOME}/git" + --ro-bind "${_git_xdg_real}" "${XDG_CONFIG_HOME}/git" --ro-bind /dev/null "${_git_creds}" ) + _BWRAP_XDG_GIT_MOUNTED=1 else echo "Warning: ${XDG_CONFIG_HOME}/git/credentials exists and this bwrap" \ "is too old (< 0.6.3) to mask it; NOT mounting ${XDG_CONFIG_HOME}/git" \ @@ -1148,20 +1434,39 @@ build_git_mounts() { fi # Git include files (read-only). - # parse_git_includes already resolved symlinks in include paths via - # readlink -f. Check each resolved include path before mounting — - # a git config could contain path = ~/.ssh/id_rsa or similar. + # parse_git_includes canonicalized every include path with readlink -f + # (GIT_INCLUDE_PATHS) and recorded the path git will actually open + # (GIT_INCLUDE_VISIBLE). Check each canonical path before mounting — a + # git config could contain path = ~/.ssh/id_rsa or an include whose + # parent directory is a symlink into a blocked tree. When the visible + # path differs from the canonical one (symlinked parent), also mount the + # content at the visible path, since git opens the include there. declare -A _GIT_INC_DIRS_SEEN=() - local inc_path local_parent - for inc_path in "${GIT_INCLUDE_PATHS[@]+"${GIT_INCLUDE_PATHS[@]}"}"; do + declare -A _GIT_INC_BOUND=() + local inc_path inc_visible local_parent _i + for _i in "${!GIT_INCLUDE_PATHS[@]}"; do + inc_path="${GIT_INCLUDE_PATHS[${_i}]}" + inc_visible="${GIT_INCLUDE_VISIBLE[${_i}]:-${inc_path}}" _check_path_safe "${inc_path}" "git include path" - # Ensure parent directory exists as a mount point (deduplicated) - local_parent="${inc_path%/*}" - if [[ -z "${_GIT_INC_DIRS_SEEN["${local_parent}"]:-}" ]]; then - _GIT_INC_DIRS_SEEN["${local_parent}"]=1 - BWRAP_ARGS+=(--dir "${local_parent}") + if [[ -z "${_GIT_INC_BOUND["${inc_path}"]:-}" ]]; then + _GIT_INC_BOUND["${inc_path}"]=1 + # Ensure parent directory exists as a mount point (deduplicated) + local_parent="${inc_path%/*}" + if [[ -z "${_GIT_INC_DIRS_SEEN["${local_parent}"]:-}" ]]; then + _GIT_INC_DIRS_SEEN["${local_parent}"]=1 + BWRAP_ARGS+=(--dir "${local_parent}") + fi + BWRAP_ARGS+=(--ro-bind-try "${inc_path}" "${inc_path}") + fi + if [[ "${inc_visible}" != "${inc_path}" && -z "${_GIT_INC_BOUND["${inc_visible}"]:-}" ]]; then + _GIT_INC_BOUND["${inc_visible}"]=1 + local_parent="${inc_visible%/*}" + if [[ -z "${_GIT_INC_DIRS_SEEN["${local_parent}"]:-}" ]]; then + _GIT_INC_DIRS_SEEN["${local_parent}"]=1 + BWRAP_ARGS+=(--dir "${local_parent}") + fi + BWRAP_ARGS+=(--ro-bind-try "${inc_path}" "${inc_visible}") fi - BWRAP_ARGS+=(--ro-bind-try "${inc_path}" "${inc_path}") done } @@ -1172,17 +1477,21 @@ build_pixi_mounts() { fi # Pixi checks multiple config locations; bind any that exist. + # The config files can be symlinks (or live under a symlinked directory) + # pointing anywhere: canonicalize and check before binding the real file. if [[ -f "${XDG_CONFIG_HOME}/pixi/config.toml" ]]; then + _bwrap_canonicalize_safe "${XDG_CONFIG_HOME}/pixi/config.toml" "pixi config.toml (resolved)" BWRAP_ARGS+=( --dir "${XDG_CONFIG_HOME}/pixi" - --ro-bind "${XDG_CONFIG_HOME}/pixi/config.toml" "${XDG_CONFIG_HOME}/pixi/config.toml" + --ro-bind "${_BWRAP_CANON}" "${XDG_CONFIG_HOME}/pixi/config.toml" ) fi if [[ -f "${_HOME}/.pixi/config.toml" ]]; then # Already available via the pixi overlay, but if the overlay is not # present (dir doesn't exist), bind it explicitly. if [[ ! -d "${PIXI_HOME_DIR}" ]]; then - BWRAP_ARGS+=(--ro-bind "${_HOME}/.pixi/config.toml" "${_HOME}/.pixi/config.toml") + _bwrap_canonicalize_safe "${_HOME}/.pixi/config.toml" "\$HOME/.pixi/config.toml (resolved)" + BWRAP_ARGS+=(--ro-bind "${_BWRAP_CANON}" "${_HOME}/.pixi/config.toml") fi fi } From 937fa847f950edbf035550bf5a57a1e21d841612 Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Sun, 18 Oct 2026 18:21:05 -0400 Subject: [PATCH 04/10] fix(bwopencode2): mount /opt/nsls2/opencode2; keep git include .. semantics The ansible opencode2 wrapper runs the binary from /opt/nsls2/opencode2, which the sandbox did not mount. Git include paths are no longer collapsed lexically: directories walked before a .. are created, and a symlinked directory before a .. mounts the include at its real path only. Assisted-by: opencode:claude-sonnet-5-5 --- bin/bwopencode2 | 17 ++++++++ docs/bw-wrappers.md | 6 ++- lib/bwrap_sandbox_lib.sh | 87 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 102 insertions(+), 8 deletions(-) diff --git a/bin/bwopencode2 b/bin/bwopencode2 index 2830c37..b1d0f0c 100755 --- a/bin/bwopencode2 +++ b/bin/bwopencode2 @@ -567,6 +567,22 @@ build_xdg_mirror_mounts() { # can authenticate gh(1) via per-command: # GH_TOKEN="$GH_TOKEN_NSLS2" gh # +# ── OpenCode 2 install directory (read-only) ───────────────────── +# /usr/local/bin/opencode2 is a wrapper script (deployed by ansible) that runs +# /opt/nsls2/opencode2/node_modules/@opencode/cli/bin/opencode.exe. The +# sandbox mounts /usr but not /opt, so without this mount the wrapper cannot +# find the binary and exits with "binary not found or not executable". The +# path is fixed by the ansible base role. Mounted read-only; nothing is bound +# when the directory is absent (for example a plain opencode2 elsewhere on PATH). +build_opencode2_install_mount() { + if [[ -d /opt/nsls2/opencode2 ]]; then + BWRAP_ARGS+=( + --dir /opt/nsls2 + --ro-bind /opt/nsls2/opencode2 /opt/nsls2/opencode2 + ) + fi +} + # Must run AFTER build_env_vars: the XDG_* values set here override the # host pass-through from build_env_vars (bwrap --setenv: last one wins), # so OpenCode 2 uses its own directories even if `opencode2` on PATH is a @@ -695,6 +711,7 @@ main() { build_xdg_mirror_mounts build_user_bin_mount build_nsls2_mount + build_opencode2_install_mount build_dynamic_tool_mounts build_extra_path_mounts diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index 22c6e33..50cef7c 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -118,7 +118,11 @@ cases. parent directory (for example `~/.config/opencode2 -> ~/.aws`) cannot hide a blocked target; such a setup is refused with an error. A git include reached through a symlinked directory is mounted at both its real path and the path - git opens. + git opens. `..` in an include path is not collapsed lexically, because the + kernel applies it to a symlink's target: directories the path walks through + before a `..` (for example `~/dotfiles` in `~/dotfiles/../x.conf`) are created + in the sandbox, and when a symlinked directory comes before a `..` the include + is mounted at its real path only, with a warning. - **`--dry-run` redacts secrets.** Every printed `--setenv NAME VALUE` whose `NAME` contains (case-insensitive) `KEY`, `TOKEN`, `PASSWORD`, `SECRET`, or `CREDENTIAL` is printed with the value `REDACTED`. Only the diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index f7bf143..9e98905 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -95,11 +95,18 @@ KERNEL_HAS_TIOCSTI_CAP_GUARD=0 declare -A _GIT_INCLUDE_SEEN=() GIT_INCLUDE_PATHS=() GIT_INCLUDE_VISIBLE=() +# GIT_INCLUDE_PREDIRS: at the same index, a newline-separated list of +# directories that must exist in the sandbox so git can resolve ".." in the +# include path it opens (see _bwrap_include_visible_path). +GIT_INCLUDE_PREDIRS=() # Result of _bwrap_canonicalize_safe / _bwrap_normalize_path (globals, # because _check_path_safe calls exit and must not run in a subshell). _BWRAP_CANON="" _BWRAP_NORMALIZED="" +_BWRAP_INC_VISIBLE="" +_BWRAP_INC_PREDIRS="" +_BWRAP_INC_MISMATCH=0 # Set to 1 by build_git_mounts when ${XDG_CONFIG_HOME}/git is actually # mounted in the sandbox at its host path (launchers may point symlinks at it). @@ -705,21 +712,30 @@ parse_git_includes() { path_val="${config_dir}/${path_val}" fi - # Lexically normalise the path git will open (drop "." / ".." / "//"), - # then canonicalize it with realpath. Canonicalize ALWAYS, not only - # when the last component is a symlink: a symlinked parent directory - # would otherwise hide the real target from _check_path_safe. - local visible resolved - _bwrap_normalize_path "${path_val}" - visible="${_BWRAP_NORMALIZED}" + # Canonicalize ALWAYS (realpath), not only when the last component is + # a symlink: a symlinked parent directory would otherwise hide the real + # target from _check_path_safe. The path git itself will open (and + # therefore where the sandbox must provide the file) is worked out by + # _bwrap_include_visible_path, which does NOT collapse ".." lexically + # when that would change what the kernel resolves. + local visible resolved predirs + _bwrap_include_visible_path "${path_val}" + visible="${_BWRAP_INC_VISIBLE}" + predirs="${_BWRAP_INC_PREDIRS}" resolved="$(readlink -f "${path_val}" 2> /dev/null || true)" [[ -n "${resolved}" ]] || resolved="${visible}" + if [[ "${_BWRAP_INC_MISMATCH}" -eq 1 ]]; then + printf 'Warning: git include %s uses ".." after a symlinked directory; it is mounted\n' "${path_val}" >&2 + printf ' at its real path (%s) only, so git may not find it in the sandbox.\n' "${resolved}" >&2 + visible="" + fi # Deduplicate on the (canonical, visible) pair if [[ -e "${resolved}" ]] && [[ -z "${_GIT_INCLUDE_SEEN["${resolved}|${visible}"]:-}" ]]; then _GIT_INCLUDE_SEEN["${resolved}|${visible}"]=1 GIT_INCLUDE_PATHS+=("${resolved}") GIT_INCLUDE_VISIBLE+=("${visible}") + GIT_INCLUDE_PREDIRS+=("${predirs}") fi done < "${config_file}" } @@ -930,6 +946,55 @@ _bwrap_normalize_path() { _BWRAP_NORMALIZED="/${out[*]}" } +# _bwrap_include_visible_path RAW +# Work out where the sandbox must provide a git include so that git can open +# the path it computed (RAW, unnormalized). Sets: +# _BWRAP_INC_VISIBLE RAW with only "." and "//" collapsed +# _BWRAP_INC_PREDIRS newline-separated directories that must exist so the +# kernel can resolve each ".." in RAW +# _BWRAP_INC_MISMATCH 1 when a symlinked directory occurs before the last +# ".." (the kernel then resolves ".." against the +# symlink's TARGET, not lexically, so the visible +# destination cannot be chosen lexically) +# ".." is applied lexically only when no symlinked directory precedes it, +# where lexical and physical resolution agree. Directories walked before a +# ".." (for example ~/dotfiles in ~/dotfiles/../x.conf) are returned in +# _BWRAP_INC_PREDIRS because the kernel needs them to exist. In the mismatch +# case the physical targets of the symlinked directories are returned too. +_bwrap_include_visible_path() { + local raw="$1" part cur="" i last_dotdot=-1 + local -a parts=() stack=() + local IFS=/ + _BWRAP_INC_PREDIRS="" + _BWRAP_INC_MISMATCH=0 + read -r -a parts <<< "${raw}" + for ((i = 0; i < ${#parts[@]}; i++)); do + [[ "${parts[i]}" == ".." ]] && last_dotdot=${i} + done + for ((i = 0; i < ${#parts[@]}; i++)); do + part="${parts[i]}" + case "${part}" in + '' | .) ;; + ..) + [[ ${#stack[@]} -gt 0 ]] && unset 'stack[${#stack[@]}-1]' + ;; + *) + stack+=("${part}") + cur="/${stack[*]}" + if [[ "${i}" -lt "${last_dotdot}" ]]; then + if [[ -L "${cur}" ]]; then + _BWRAP_INC_MISMATCH=1 + _BWRAP_INC_PREDIRS+="$(readlink -f -- "${cur}" 2> /dev/null || true)"$'\n' + else + _BWRAP_INC_PREDIRS+="${cur}"$'\n' + fi + fi + ;; + esac + done + _BWRAP_INC_VISIBLE="/${stack[*]}" +} + # validate_extra_path RAW_PATH MODE # Validate a user-supplied path (from --ro-path or --rw-path) before # mounting it into the sandbox. @@ -1448,6 +1513,14 @@ build_git_mounts() { inc_path="${GIT_INCLUDE_PATHS[${_i}]}" inc_visible="${GIT_INCLUDE_VISIBLE[${_i}]:-${inc_path}}" _check_path_safe "${inc_path}" "git include path" + # Directories the kernel must traverse to resolve ".." in the include + # path git opens (they hold no data; they only have to exist). + local _predir + while IFS= read -r _predir; do + [[ -n "${_predir}" && -z "${_GIT_INC_DIRS_SEEN["${_predir}"]:-}" ]] || continue + _GIT_INC_DIRS_SEEN["${_predir}"]=1 + BWRAP_ARGS+=(--dir "${_predir}") + done <<< "${GIT_INCLUDE_PREDIRS[${_i}]:-}" if [[ -z "${_GIT_INC_BOUND["${inc_path}"]:-}" ]]; then _GIT_INC_BOUND["${inc_path}"]=1 # Ensure parent directory exists as a mount point (deduplicated) From 7fe2c84a3730e0e2feb91a6650e4da2bdb16b56b Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Sun, 18 Oct 2026 18:49:23 -0400 Subject: [PATCH 05/10] fix(bw): mount only four git config files; keep symlink topology Mount only allowed_signers, config, ignore and attributes from ~/.config/git (never the directory, never credentials). Recreate symlinked directories on git include paths so .. resolves as on the host. Skip entries that resolve into blocked locations with a warning; ~/.ssh is never mounted. Assisted-by: opencode:claude-sonnet-5-5 --- README.md | 8 +- bin/bwopencode2 | 23 ++- docs/bw-wrappers.md | 62 +++--- lib/bwrap_sandbox_lib.sh | 398 +++++++++++++++++++++++++-------------- 4 files changed, 311 insertions(+), 180 deletions(-) diff --git a/README.md b/README.md index a255996..d2ebf32 100644 --- a/README.md +++ b/README.md @@ -101,15 +101,15 @@ Common wrapper options: `bwclaude` also supports `--debug`. `bwopencode2` has no `--init-auth` (sign-in persists in its data directory), always runs OpenCode 2 with `--standalone` (`--exec` bypasses that guard by design), passes only an allowlist of -`OPENCODE_*` environment variables, does not mount `service.json`, and masks -the mirrored git `credentials` file; see [docs/bw-wrappers.md](docs/bw-wrappers.md). See each script's `--help` for +`OPENCODE_*` environment variables, does not mount `service.json`, and never mounts +the git `credentials` file; see [docs/bw-wrappers.md](docs/bw-wrappers.md). See each script's `--help` for the authoritative option list. Security notes: every launcher runs `/opt/nsls2/libexec/agent-account-check` first when installed (refuses root and shared/service accounts); `bwopencode` forwards only an allowlist of `OPENCODE_*` behaviour toggles (all -other host `OPENCODE_*` variables are dropped); `${XDG_CONFIG_HOME}/git/credentials` -is masked inside every sandbox; and `--dry-run` prints values of secret-looking +other host `OPENCODE_*` variables are dropped); only `allowed_signers`, `config`, `ignore` and `attributes` of +`${XDG_CONFIG_HOME}/git` are mounted in every sandbox (never `credentials`); and `--dry-run` prints values of secret-looking `--setenv` variables (names containing `KEY`, `TOKEN`, `PASSWORD`, `SECRET`, or `CREDENTIAL`) as `REDACTED`. See `docs/bw-wrappers.md`. diff --git a/bin/bwopencode2 b/bin/bwopencode2 index b1d0f0c..8cbbcf4 100755 --- a/bin/bwopencode2 +++ b/bin/bwopencode2 @@ -26,8 +26,9 @@ # OpenCode 2 can never touch OpenCode 1 data (which it would migrate # one way) # - Git config + includes: read-only (also mirrored under the opencode2 -# XDG config home, where git looks inside the sandbox; the mirrored -# git/credentials file is masked with /dev/null) +# XDG config home, where git looks inside the sandbox; only the +# allowed_signers, config, ignore and attributes files of git/ are +# mounted, never credentials) # - OPENCODE_* env vars: only an allowlist of harmless switches is passed # (see build_opencode2_env); OPENCODE_SERVER_PASSWORD etc. are dropped # - Pixi cache: read-write @@ -209,7 +210,7 @@ and --completions. Arguments after `--` are never inspected. Also kept out of the sandbox: service.json (service settings), all OPENCODE_* environment variables except a short allowlist of harmless switches (see docs/bw-wrappers.md; OPENCODE_SERVER_PASSWORD etc. are dropped), and the -git credentials file under the mirrored XDG git directory. +git credentials file (only a few files of the XDG git directory are mounted). All other arguments are passed through to opencode2 unchanged. @@ -518,18 +519,22 @@ build_opencode2_state_dir() { # ~/.config/opencode2/shared (which does not exist) and git would silently # skip it. Through a symlinked directory the kernel resolves `..` against # the real parent, so ~/.config/opencode2/git/../shared is the same file as -# ~/.config/shared, where the library mounted the include. The symlink also -# inherits the read-only mount and the credentials mask of the target for -# free. Each symlink is created only when the library really mounted its -# target (otherwise it would dangle). Must run AFTER build_home_tmpfs and -# the library mount builders (sets/reads _BWRAP_XDG_GIT_MOUNTED). +# ~/.config/shared, where the library mounted the include. The library +# mounts only allowed_signers, config, ignore and attributes of the host git +# directory (read-only, never the directory itself, so "credentials" and any +# file created on the host later stay invisible); the symlink exposes just +# those. If the host git directory is itself a symlink the library recreates +# it, so the mirror still resolves through it. Each symlink is created only +# when the library really mounted its target (otherwise it would dangle). +# Must run AFTER build_home_tmpfs and the library mount builders (reads +# _BWRAP_XDG_GIT_HAS_FILES). # ~/.gitconfig and other $HOME-relative files are unaffected. build_xdg_mirror_mounts() { local _mirror_cfg="${OC2_XDG_CONFIG_HOME}" # Skip the mirror when the host XDG dirs already ARE the opencode2 ones. if [[ "${XDG_CONFIG_HOME}" != "${_mirror_cfg}" ]]; then - if [[ "${_BWRAP_XDG_GIT_MOUNTED}" -eq 1 ]]; then + if [[ "${_BWRAP_XDG_GIT_HAS_FILES}" -eq 1 ]]; then BWRAP_ARGS+=(--symlink "${XDG_CONFIG_HOME}/git" "${_mirror_cfg}/git") fi # build_pixi_mounts binds exactly this file at its host path. diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index 50cef7c..6401757 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -99,18 +99,24 @@ cases. ## Credentials and secrets handling -- **Git credentials are masked.** The host `${XDG_CONFIG_HOME}/git` directory - (default `~/.config/git`) is mounted read-only so git config, ignore, and - attributes work. If it contains a `credentials` file (the default file of - git's `store` credential helper, holding plaintext passwords or tokens), - that file is hidden by binding `/dev/null` over it. This needs - `bwrap` >= 0.6.3 (the same requirement as binary masking). On an older - `bwrap` the wrappers fail closed: when a `credentials` file exists they do - **not** mount the XDG git directory at all and print a warning to stderr. - `$HOME` itself is a tmpfs, so `~/.git-credentials` is never visible. - A credential helper that stores secrets elsewhere (for example - `credential.helper = store --file=PATH`, or a path pulled in through a git - `include`) is not covered; keep such files out of mounted locations. +- **Only four files of the XDG git directory are mounted.** From the host + `${XDG_CONFIG_HOME}/git` directory (default `~/.config/git`) the wrappers + mount `allowed_signers`, `config`, `ignore` and `attributes`, each read-only + and only if present. The directory itself is never bound: nothing else in it + is mounted, including `credentials` (the default file of git's `store` + credential helper, holding plaintext passwords or tokens), and files created + on the host later never appear in the sandbox. A listed file (or the whole + directory) that resolves into a blocked location is skipped with a warning + (`Warning: resolves into a blocked location (); not + mounted.`) rather than aborting; a dangling symlink is skipped silently. + `~/.ssh` is never mounted by any path. `$HOME` itself is a tmpfs, so + `~/.git-credentials` is never visible. A credential helper that stores + secrets elsewhere (for example `credential.helper = store --file=PATH`, or a + path pulled in through a git `include`) is not covered; keep such files out + of mounted locations. Signing commits inside the sandbox is not possible + (`ssh-add` and `ssh-keygen` are masked and no agent socket is passed in). + `allowed_signers` is mounted read-only, but `git` verifies SSH signatures + with `ssh-keygen`, which is masked as well, so verify signatures on the host. - **Paths are resolved before the blocked-path check.** Config entries and config directories (`~/.config/opencode*`), git include files, and the XDG `git`, `pixi/config.toml` and `ccache` locations are canonicalized with @@ -119,10 +125,15 @@ cases. blocked target; such a setup is refused with an error. A git include reached through a symlinked directory is mounted at both its real path and the path git opens. `..` in an include path is not collapsed lexically, because the - kernel applies it to a symlink's target: directories the path walks through + kernel applies it to a symlink's target. Directories the path walks through before a `..` (for example `~/dotfiles` in `~/dotfiles/../x.conf`) are created - in the sandbox, and when a symlinked directory comes before a `..` the include - is mounted at its real path only, with a warning. + in the sandbox. Symlinked directories under `$HOME` before a `..` (including + a symlinked `~/.config/git`, whose four files are then mounted at the real + target) are recreated in the sandbox as symlinks to their real target, so + `..` resolves as on the host; a symlink whose target is in a blocked location + is never recreated. If a needed symlink cannot be recreated (outside `$HOME` + and not in a mounted tree, or dangling or blocked), the include is mounted at + its real path only, with a warning. - **`--dry-run` redacts secrets.** Every printed `--setenv NAME VALUE` whose `NAME` contains (case-insensitive) `KEY`, `TOKEN`, `PASSWORD`, `SECRET`, or `CREDENTIAL` is printed with the value `REDACTED`. Only the @@ -225,19 +236,18 @@ Because `XDG_CONFIG_HOME` and `XDG_CACHE_HOME` point at the opencode2 directorie the wrapper makes the host's `$XDG_CONFIG_HOME/git`, `pixi/config.toml`, `ccache` config, and `$XDG_CACHE_HOME/ccache` cache visible at the same relative location in the opencode2 directories so git, pixi, and ccache keep working. -These are symlinks to the host-path mounts the shared library already made, not -second binds: git resolves a relative `[include] path = ../shared` against the -directory of the config path as opened, so a bind at +The git mirror is a symlink to the host-path mounts the shared library already +made, not a second bind: git resolves a relative `[include] path = ../shared` +against the directory of the config path as opened, so a bind at `~/.config/opencode2/git` would look for `~/.config/opencode2/shared` and silently skip the include, whereas through the symlink `..` resolves to the real -parent (`~/.config`), where the include is mounted. The read-only mount and the -`git/credentials` mask of the target apply automatically: that file (the default -file of git's `store` credential helper) is masked with `/dev/null`, so git -passwords stored there are not readable inside the sandbox. This needs -bubblewrap >= 0.6.3 (bind over an existing bind); on an older bubblewrap the -library prints a warning and does not mount the XDG git directory at all, so no -symlink is created. `~/.gitconfig` and other `$HOME`-relative files are -unaffected. +parent (`~/.config`), where the include is mounted. The symlink is created only +when at least one of the four mounted files (`allowed_signers`, `config`, +`ignore`, `attributes`; see "Credentials and secrets handling") exists, so it +exposes just those files, read-only: `credentials` and anything created on the +host later are not reachable through it. If the host `~/.config/git` is itself a +symlink the library recreates it in the sandbox and the mirror resolves through +it. `~/.gitconfig` and other `$HOME`-relative files are unaffected. ### What is kept out of the sandbox diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index 9e98905..f578c13 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -89,9 +89,10 @@ KERNEL_HAS_TIOCSTI_CAP_GUARD=0 # Git include tracking (used by parse_git_includes). # GIT_INCLUDE_PATHS holds the CANONICAL (realpath) include files; # GIT_INCLUDE_VISIBLE holds, at the same index, the path git itself will -# open (the lexically normalised path as computed from the config file's -# own location). They differ when a parent directory of the include is a -# symlink; build_git_mounts then mounts the content at both paths. +# open once every symlinked directory that precedes a ".." has been +# resolved physically (see _bwrap_include_visible_path). They differ when a +# parent directory of the include is a symlink; build_git_mounts then mounts +# the content at both paths. declare -A _GIT_INCLUDE_SEEN=() GIT_INCLUDE_PATHS=() GIT_INCLUDE_VISIBLE=() @@ -99,18 +100,30 @@ GIT_INCLUDE_VISIBLE=() # directories that must exist in the sandbox so git can resolve ".." in the # include path it opens (see _bwrap_include_visible_path). GIT_INCLUDE_PREDIRS=() - -# Result of _bwrap_canonicalize_safe / _bwrap_normalize_path (globals, -# because _check_path_safe calls exit and must not run in a subshell). +# GIT_INCLUDE_LINKS: at the same index, a newline-separated list of symlinked +# directories that precede a ".." in the include path. The sandbox $HOME is +# a tmpfs, so build_git_mounts recreates them with --symlink; the kernel then +# resolves ".." against the symlink target exactly as it does on the host. +GIT_INCLUDE_LINKS=() + +# Results of _bwrap_canonicalize_safe / _bwrap_include_visible_path / +# _bwrap_path_blocked (globals, because _check_path_safe calls exit and must +# not run in a subshell). _BWRAP_CANON="" -_BWRAP_NORMALIZED="" _BWRAP_INC_VISIBLE="" _BWRAP_INC_PREDIRS="" -_BWRAP_INC_MISMATCH=0 +_BWRAP_INC_LINKS="" +_BWRAP_BLOCKED_KIND="" +_BWRAP_BLOCKED_PREFIX="" + +# Symlinks already handled by _bwrap_git_recreate_link: path -> 0 (present or +# recreated in the sandbox) / 1 (could not be recreated). +declare -A _GIT_LINK_STATUS=() -# Set to 1 by build_git_mounts when ${XDG_CONFIG_HOME}/git is actually -# mounted in the sandbox at its host path (launchers may point symlinks at it). -_BWRAP_XDG_GIT_MOUNTED=0 +# Set to 1 by build_git_mounts when at least one file of +# ${XDG_CONFIG_HOME}/git was mounted (launchers may point symlinks at the +# directory; it is only guaranteed to resolve in the sandbox in that case). +_BWRAP_XDG_GIT_HAS_FILES=0 # Working directory state (set by build_workdir_mount) _BIND_DIR="" @@ -716,19 +729,15 @@ parse_git_includes() { # a symlink: a symlinked parent directory would otherwise hide the real # target from _check_path_safe. The path git itself will open (and # therefore where the sandbox must provide the file) is worked out by - # _bwrap_include_visible_path, which does NOT collapse ".." lexically - # when that would change what the kernel resolves. - local visible resolved predirs + # _bwrap_include_visible_path, which also reports the symlinked + # directories before a ".." that build_git_mounts must recreate. + local visible resolved predirs links _bwrap_include_visible_path "${path_val}" visible="${_BWRAP_INC_VISIBLE}" predirs="${_BWRAP_INC_PREDIRS}" + links="${_BWRAP_INC_LINKS}" resolved="$(readlink -f "${path_val}" 2> /dev/null || true)" [[ -n "${resolved}" ]] || resolved="${visible}" - if [[ "${_BWRAP_INC_MISMATCH}" -eq 1 ]]; then - printf 'Warning: git include %s uses ".." after a symlinked directory; it is mounted\n' "${path_val}" >&2 - printf ' at its real path (%s) only, so git may not find it in the sandbox.\n' "${resolved}" >&2 - visible="" - fi # Deduplicate on the (canonical, visible) pair if [[ -e "${resolved}" ]] && [[ -z "${_GIT_INCLUDE_SEEN["${resolved}|${visible}"]:-}" ]]; then @@ -736,6 +745,7 @@ parse_git_includes() { GIT_INCLUDE_PATHS+=("${resolved}") GIT_INCLUDE_VISIBLE+=("${visible}") GIT_INCLUDE_PREDIRS+=("${predirs}") + GIT_INCLUDE_LINKS+=("${links}") fi done < "${config_file}" } @@ -746,7 +756,13 @@ parse_all_git_includes() { parse_git_includes "${_HOME}/.gitconfig" fi if [[ -f "${XDG_CONFIG_HOME}/git/config" ]]; then - parse_git_includes "${XDG_CONFIG_HOME}/git/config" + # A config that resolves into a blocked location is not mounted + # (see _bwrap_mount_xdg_git_files), so its includes are not followed. + local _xdg_cfg_real + _xdg_cfg_real="$(readlink -f "${XDG_CONFIG_HOME}/git/config" 2> /dev/null || true)" + if [[ -z "${_xdg_cfg_real}" ]] || ! _bwrap_path_blocked "${_xdg_cfg_real}"; then + parse_git_includes "${XDG_CONFIG_HOME}/git/config" + fi fi } @@ -773,6 +789,66 @@ _is_prefix_of() { [[ "${a}" == "/" || "${b}" == "${a}" || "${b}" == "${a}/"* ]] } +# _bwrap_path_blocked CANON +# Non-exiting form of the check below: return 0 (true) when the canonical +# path CANON is blocked, 1 otherwise. On a hit, _BWRAP_BLOCKED_KIND is +# "home" (CANON is $HOME or an ancestor of it) or "prefix" (CANON is inside +# the sensitive subtree _BWRAP_BLOCKED_PREFIX). Used directly where a +# blocked entry is skipped with a warning instead of aborting the launcher, +# and by _check_path_safe, which prints the error and exits. +_bwrap_path_blocked() { + local canon="$1" + _BWRAP_BLOCKED_KIND="" + _BWRAP_BLOCKED_PREFIX="" + + # Reject if canon IS $HOME or is a directory that contains $HOME + # (i.e. /, /home, the username's parent dir, etc.). + # "_is_prefix_of canon _HOME" means: canon is a prefix of _HOME. + if _is_prefix_of "${canon}" "${_HOME}"; then + _BWRAP_BLOCKED_KIND="home" + return 0 + fi + + # XDG_DATA_HOME is set by resolve_common_paths, which is always called + # before any function that invokes this check. + local _blocked_prefix + local _BLOCKED_PREFIXES=( + # SSH / GPG / cloud keys + "${_HOME}/.ssh" + "${_HOME}/.gnupg" + "${_HOME}/.aws" + "${_HOME}/.kube" + "${_HOME}/.docker" + # Package-manager credentials + "${_HOME}/.netrc" + "${_HOME}/.pypirc" + "${_HOME}/.rattler" + "${_HOME}/.yarnrc" + "${_HOME}/.yarnrc.yml" + "${_HOME}/.yarn" + "${XDG_DATA_HOME}/uv/credentials" + # Password managers + "${_HOME}/.password-store" # pass / gopass encrypted secret store + "${_HOME}/.config/1Password" # 1Password CLI session/config + "${_HOME}/.config/op" # 1Password CLI (alternate path) + # GitHub CLI tokens + "${_HOME}/.config/gh" + # System + "/root" + "/etc/shadow" + "/etc/sudoers" + "/etc/sudoers.d" + ) + for _blocked_prefix in "${_BLOCKED_PREFIXES[@]}"; do + if _is_prefix_of "${_blocked_prefix}" "${canon}"; then + _BWRAP_BLOCKED_KIND="prefix" + _BWRAP_BLOCKED_PREFIX="${_blocked_prefix}" + return 0 + fi + done + return 1 +} + # _check_path_safe CANON CONTEXT # Assert that the already-resolved canonical path CANON is safe to mount # into the sandbox. Exits 1 with a descriptive error if it is not. @@ -836,54 +912,16 @@ _check_path_safe() { local canon="$1" local context="$2" - # ── Ancestor-of-home check ─────────────────────────────────── - # Reject if canon IS $HOME or is a directory that contains $HOME - # (i.e. /, /home, the username's parent dir, etc.). - # "_is_prefix_of canon _HOME" means: canon is a prefix of _HOME. - if _is_prefix_of "${canon}" "${_HOME}"; then - echo "Error: ${context} '${canon}' is \$HOME or an ancestor of \$HOME." >&2 - echo " Refusing to mount — this would expose the entire home tree." >&2 - exit 1 - fi - - # ── Sensitive-subtree checks ───────────────────────────────── - # XDG_DATA_HOME is set by resolve_common_paths, which is always called - # before any function that invokes _check_path_safe. - local _blocked_prefix - local _BLOCKED_PREFIXES=( - # SSH / GPG / cloud keys - "${_HOME}/.ssh" - "${_HOME}/.gnupg" - "${_HOME}/.aws" - "${_HOME}/.kube" - "${_HOME}/.docker" - # Package-manager credentials - "${_HOME}/.netrc" - "${_HOME}/.pypirc" - "${_HOME}/.rattler" - "${_HOME}/.yarnrc" - "${_HOME}/.yarnrc.yml" - "${_HOME}/.yarn" - "${XDG_DATA_HOME}/uv/credentials" - # Password managers - "${_HOME}/.password-store" # pass / gopass encrypted secret store - "${_HOME}/.config/1Password" # 1Password CLI session/config - "${_HOME}/.config/op" # 1Password CLI (alternate path) - # GitHub CLI tokens - "${_HOME}/.config/gh" - # System - "/root" - "/etc/shadow" - "/etc/sudoers" - "/etc/sudoers.d" - ) - for _blocked_prefix in "${_BLOCKED_PREFIXES[@]}"; do - if _is_prefix_of "${_blocked_prefix}" "${canon}"; then - echo "Error: ${context} '${canon}' is inside the blocked path '${_blocked_prefix}'." >&2 + if _bwrap_path_blocked "${canon}"; then + if [[ "${_BWRAP_BLOCKED_KIND}" == "home" ]]; then + echo "Error: ${context} '${canon}' is \$HOME or an ancestor of \$HOME." >&2 + echo " Refusing to mount — this would expose the entire home tree." >&2 + else + echo "Error: ${context} '${canon}' is inside the blocked path '${_BWRAP_BLOCKED_PREFIX}'." >&2 echo " Refusing to mount." >&2 - exit 1 fi - done + exit 1 + fi } # _check_dir_safe PATH CONTEXT @@ -928,45 +966,29 @@ _bwrap_canonicalize_safe() { _check_path_safe "${_BWRAP_CANON}" "${context}" } -# _bwrap_normalize_path PATH -# Purely lexical normalisation of an absolute path (collapses "//", "." -# and "..") without touching the filesystem. Result in _BWRAP_NORMALIZED. -_bwrap_normalize_path() { - local path="$1" part - local -a parts=() out=() - local IFS=/ - read -r -a parts <<< "${path}" - for part in "${parts[@]+"${parts[@]}"}"; do - case "${part}" in - '' | .) ;; - ..) [[ ${#out[@]} -gt 0 ]] && unset 'out[${#out[@]}-1]' ;; - *) out+=("${part}") ;; - esac - done - _BWRAP_NORMALIZED="/${out[*]}" -} - # _bwrap_include_visible_path RAW # Work out where the sandbox must provide a git include so that git can open -# the path it computed (RAW, unnormalized). Sets: -# _BWRAP_INC_VISIBLE RAW with only "." and "//" collapsed -# _BWRAP_INC_PREDIRS newline-separated directories that must exist so the -# kernel can resolve each ".." in RAW -# _BWRAP_INC_MISMATCH 1 when a symlinked directory occurs before the last -# ".." (the kernel then resolves ".." against the -# symlink's TARGET, not lexically, so the visible -# destination cannot be chosen lexically) -# ".." is applied lexically only when no symlinked directory precedes it, -# where lexical and physical resolution agree. Directories walked before a -# ".." (for example ~/dotfiles in ~/dotfiles/../x.conf) are returned in -# _BWRAP_INC_PREDIRS because the kernel needs them to exist. In the mismatch -# case the physical targets of the symlinked directories are returned too. +# the path it computed (RAW, not normalised). Sets: +# _BWRAP_INC_VISIBLE the path as the kernel resolves it on the host: +# every component up to the LAST ".." is resolved +# physically (a symlinked directory is replaced by its +# target, so ".." means the parent of the TARGET, as +# on the host); components after it are kept as named +# _BWRAP_INC_PREDIRS newline-separated directories the kernel walks +# before the last ".." (they hold no data; they only +# have to exist in the sandbox) +# _BWRAP_INC_LINKS newline-separated symlinked directories met before +# the last ".."; the sandbox must provide them as +# symlinks (build_git_mounts recreates them) for the +# kernel to resolve ".." the same way there +# Without a symlinked directory before a ".." this equals the lexical +# normalisation of RAW and _BWRAP_INC_LINKS is empty. _bwrap_include_visible_path() { - local raw="$1" part cur="" i last_dotdot=-1 - local -a parts=() stack=() + local raw="$1" part cand phys="/" tail="" i last_dotdot=-1 + local -a parts=() local IFS=/ _BWRAP_INC_PREDIRS="" - _BWRAP_INC_MISMATCH=0 + _BWRAP_INC_LINKS="" read -r -a parts <<< "${raw}" for ((i = 0; i < ${#parts[@]}; i++)); do [[ "${parts[i]}" == ".." ]] && last_dotdot=${i} @@ -976,23 +998,28 @@ _bwrap_include_visible_path() { case "${part}" in '' | .) ;; ..) - [[ ${#stack[@]} -gt 0 ]] && unset 'stack[${#stack[@]}-1]' + # phys is physical, so its parent is the physical parent + [[ "${phys}" == "/" ]] || phys="${phys%/*}" + [[ -n "${phys}" ]] || phys="/" ;; *) - stack+=("${part}") - cur="/${stack[*]}" if [[ "${i}" -lt "${last_dotdot}" ]]; then - if [[ -L "${cur}" ]]; then - _BWRAP_INC_MISMATCH=1 - _BWRAP_INC_PREDIRS+="$(readlink -f -- "${cur}" 2> /dev/null || true)"$'\n' + cand="${phys%/}/${part}" + if [[ -L "${cand}" ]]; then + _BWRAP_INC_LINKS+="${cand}"$'\n' + phys="$(readlink -f -- "${cand}" 2> /dev/null || true)" + [[ -n "${phys}" ]] || phys="${cand}" else - _BWRAP_INC_PREDIRS+="${cur}"$'\n' + phys="${cand}" fi + _BWRAP_INC_PREDIRS+="${phys}"$'\n' + else + tail+="/${part}" fi ;; esac done - _BWRAP_INC_VISIBLE="/${stack[*]}" + _BWRAP_INC_VISIBLE="${phys%/}${tail}" } # validate_extra_path RAW_PATH MODE @@ -1454,6 +1481,111 @@ build_workdir_mount() { } # ── Git config (read-only) ────────────────────────────────────── + +# _bwrap_already_mounted PATH +# Return 0 when PATH lies inside a tree that is present in the sandbox with +# its host content (symlinks included): the standard system trees, the +# working directory, and the pixi / ccache / npm / ~/bin / registered +# mounts. Anything else under the tmpfs $HOME is NOT present. +_bwrap_already_mounted() { + local path="$1" _m + for _m in /usr /bin /sbin /lib /lib64 /etc /nsls2/software/bin \ + "${_BIND_DIR}" "${PIXI_HOME_DIR}" "${CCACHE_CONFIG_DIR}" \ + "${CCACHE_CACHE_DIR}" "${NPM_CACHE_DIR}" "${_HOME}/bin"; do + [[ -n "${_m}" ]] || continue + _is_prefix_of "${_m}" "${path}" && return 0 + done + if [[ ${#_MOUNTED_PREFIXES[@]} -gt 0 ]]; then + for _m in "${!_MOUNTED_PREFIXES[@]}"; do + _is_prefix_of "${_m}" "${path}" && return 0 + done + fi + return 1 +} + +# _bwrap_git_recreate_link LINK +# Make the symlinked directory LINK exist in the sandbox. The sandbox +# $HOME is a tmpfs, so a symlink under $HOME is lost; without it the kernel +# resolves ".." in a git include path differently than on the host. Emits +# --symlink LINK (after the --dir ancestors) once per LINK. +# Return 0 when LINK is present (already mounted, or recreated), 1 when it +# cannot be: outside $HOME and not inside a mounted tree, dangling, or LINK +# or its target inside a blocked location (warned about; never linked). +_bwrap_git_recreate_link() { + local link="$1" target + if [[ -n "${_GIT_LINK_STATUS["${link}"]:-}" ]]; then + return "${_GIT_LINK_STATUS["${link}"]}" + fi + _GIT_LINK_STATUS["${link}"]=1 + if _bwrap_already_mounted "${link}"; then + _GIT_LINK_STATUS["${link}"]=0 + return 0 + fi + [[ "${link}" == "${_HOME}"/* ]] || return 1 + target="$(readlink -f -- "${link}" 2> /dev/null || true)" + [[ -n "${target}" && -d "${target}" ]] || return 1 + if _bwrap_path_blocked "${link}" || _bwrap_path_blocked "${target}"; then + echo "Warning: symlink ${link} resolves into a blocked location (${target}); not recreated." >&2 + return 1 + fi + _emit_home_intermediate_dirs "${link}" + BWRAP_ARGS+=(--symlink "${target}" "${link}") + _GIT_LINK_STATUS["${link}"]=0 + return 0 +} + +# _bwrap_mount_xdg_git_files +# Mount ONLY allowed_signers, config, ignore and attributes of +# ${XDG_CONFIG_HOME}/git, each read-only and only when present. The +# directory itself is never bound: anything else in it (the "store" +# credential helper's "credentials" file included) is never mounted, and a +# file created on the host later never appears in the sandbox. A file (or +# the whole directory) that resolves into a blocked location is skipped +# with a warning; a dangling symlink is skipped silently. When the +# directory is itself a symlink it is recreated as one and the files are +# mounted at its real target, so ".." in includes resolves as on the host. +# Sets _BWRAP_XDG_GIT_HAS_FILES=1 when at least one file was mounted. +_bwrap_mount_xdg_git_files() { + local gdir="${XDG_CONFIG_HOME}/git" gdir_real dest_dir name f f_real + local -a names=() reals=() + _BWRAP_XDG_GIT_HAS_FILES=0 + [[ -d "${gdir}" ]] || return 0 + gdir_real="$(readlink -f -- "${gdir}" 2> /dev/null || true)" + [[ -n "${gdir_real}" ]] || return 0 + if _bwrap_path_blocked "${gdir_real}"; then + echo "Warning: ${gdir} resolves into a blocked location (${gdir_real}); not mounted." >&2 + return 0 + fi + for name in allowed_signers config ignore attributes; do + f="${gdir}/${name}" + [[ -e "${f}" ]] || continue + f_real="$(readlink -f -- "${f}" 2> /dev/null || true)" + [[ -n "${f_real}" && -f "${f_real}" ]] || continue + if _bwrap_path_blocked "${f_real}"; then + echo "Warning: ${f} resolves into a blocked location (${f_real}); not mounted." >&2 + continue + fi + names+=("${name}") + reals+=("${f_real}") + done + [[ ${#names[@]} -gt 0 ]] || return 0 + + dest_dir="${gdir}" + if [[ -L "${gdir}" ]] && _bwrap_git_recreate_link "${gdir}"; then + dest_dir="${gdir_real}" + fi + if [[ "${dest_dir}" == "${_HOME}"/* ]]; then + _emit_home_intermediate_dirs "${dest_dir}" inclusive + else + BWRAP_ARGS+=(--dir "${dest_dir}") + fi + local _k + for _k in "${!names[@]}"; do + BWRAP_ARGS+=(--ro-bind "${reals[${_k}]}" "${dest_dir}/${names[${_k}]}") + done + _BWRAP_XDG_GIT_HAS_FILES=1 +} + build_git_mounts() { # Resolve symlinks so we bind the real file (readlink -f is a no-op on # regular files). Check the resolved target before emitting the bind: @@ -1464,39 +1596,7 @@ build_git_mounts() { _check_path_safe "${_gitconfig_real}" "\$HOME/.gitconfig (resolved)" BWRAP_ARGS+=(--ro-bind "${_gitconfig_real}" "${_HOME}/.gitconfig") fi - if [[ -d "${XDG_CONFIG_HOME}/git" ]]; then - # XDG_CONFIG_HOME itself is validated by resolve_common_paths, but - # the git subdirectory can be a symlink pointing anywhere (e.g. - # ~/.config/git -> ~/.ssh), so canonicalize and check it too. The - # canonical directory is bound at the host path so that relative - # includes (../shared) resolve against the host layout. - _bwrap_canonicalize_safe "${XDG_CONFIG_HOME}/git" "XDG git config dir" - local _git_xdg_real="${_BWRAP_CANON}" - # - # The XDG git directory may hold "credentials": the default file of - # git's "store" credential helper (plaintext passwords/tokens). The - # directory is bound read-only for config/ignore/attributes, so the - # credentials file must be masked with /dev/null on top of it. A - # bind over a read-only bind needs bwrap >= 0.6.3 - # (HAS_BIND_OVER_RO, same requirement as build_binary_masks). On - # older bwrap we fail closed: do not mount the directory at all when - # a credentials file is present, and warn. - local _git_creds="${XDG_CONFIG_HOME}/git/credentials" - if [[ ! -e "${_git_creds}" && ! -L "${_git_creds}" ]]; then - BWRAP_ARGS+=(--ro-bind "${_git_xdg_real}" "${XDG_CONFIG_HOME}/git") - _BWRAP_XDG_GIT_MOUNTED=1 - elif [[ "${HAS_BIND_OVER_RO}" -eq 1 ]]; then - BWRAP_ARGS+=( - --ro-bind "${_git_xdg_real}" "${XDG_CONFIG_HOME}/git" - --ro-bind /dev/null "${_git_creds}" - ) - _BWRAP_XDG_GIT_MOUNTED=1 - else - echo "Warning: ${XDG_CONFIG_HOME}/git/credentials exists and this bwrap" \ - "is too old (< 0.6.3) to mask it; NOT mounting ${XDG_CONFIG_HOME}/git" \ - "into the sandbox (git config there will be unavailable)." >&2 - fi - fi + _bwrap_mount_xdg_git_files # Git include files (read-only). # parse_git_includes canonicalized every include path with readlink -f @@ -1513,11 +1613,27 @@ build_git_mounts() { inc_path="${GIT_INCLUDE_PATHS[${_i}]}" inc_visible="${GIT_INCLUDE_VISIBLE[${_i}]:-${inc_path}}" _check_path_safe "${inc_path}" "git include path" + # Symlinked directories before a ".." in the include path: recreate + # them so the kernel resolves ".." as on the host. If one cannot be + # recreated the include is mounted at its real path only. + local _link _links_ok=1 + while IFS= read -r _link; do + [[ -n "${_link}" ]] || continue + _bwrap_git_recreate_link "${_link}" || _links_ok=0 + done <<< "${GIT_INCLUDE_LINKS[${_i}]:-}" + if [[ "${_links_ok}" -eq 0 ]]; then + printf 'Warning: git include %s needs a symlinked directory that cannot be recreated in\n' "${inc_visible}" >&2 + printf ' the sandbox; it is mounted at its real path (%s) only, so git may not find it.\n' "${inc_path}" >&2 + inc_visible="${inc_path}" + fi # Directories the kernel must traverse to resolve ".." in the include # path git opens (they hold no data; they only have to exist). local _predir while IFS= read -r _predir; do [[ -n "${_predir}" && -z "${_GIT_INC_DIRS_SEEN["${_predir}"]:-}" ]] || continue + # Never create a directory inside a blocked location, not even an + # empty one (a symlinked directory pointing there stays unresolved). + ! _bwrap_path_blocked "${_predir}" || continue _GIT_INC_DIRS_SEEN["${_predir}"]=1 BWRAP_ARGS+=(--dir "${_predir}") done <<< "${GIT_INCLUDE_PREDIRS[${_i}]:-}" From 4b82d914db666d45bf7c22dc1bb8ec6b3f4859cb Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Sun, 18 Oct 2026 19:11:43 -0400 Subject: [PATCH 06/10] fix(bw): drop blanket /etc prefix; don't recreate symlinks over pre-created dirs Treat only the /etc directories actually bound as present in the sandbox. Skip recreating a host symlink whose destination is already a directory in the sandbox mount list (for example ~/.config) and fall back to a warning, mounting includes at their lexical path where safe. Assisted-by: opencode:claude-sonnet-5-5 --- docs/bw-wrappers.md | 14 +++- lib/bwrap_sandbox_lib.sh | 168 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 171 insertions(+), 11 deletions(-) diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index 6401757..eaaa758 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -133,7 +133,19 @@ cases. `..` resolves as on the host; a symlink whose target is in a blocked location is never recreated. If a needed symlink cannot be recreated (outside `$HOME` and not in a mounted tree, or dangling or blocked), the include is mounted at - its real path only, with a warning. + its real path only, with a warning. A host symlink at a path the launcher + pre-creates in the sandbox home (`~/.config`, `~/.local`, `~/.local/share`, + `~/.cache`, `~/bin`), or at any path already created or populated by an + earlier mount, is not recreated either (bwrap cannot create a symlink where a + directory exists): the sandbox sees a plain directory there and a warning is + printed. Git then resolves `..` through it lexically; when every unrecreated + symlink of an include is of this kind, the include is additionally mounted at + its lexically collapsed path (for example `~/.config/shared` for + `~/.config/git/../shared`), unless that path is already in use (never over + `~/.gitconfig` or an XDG git file). Symlinks outside `$HOME` (for example + under `/etc`, where only selected entries are bound) are not recreated: the + four XDG git files are then mounted at the host path of the symlinked + directory. - **`--dry-run` redacts secrets.** Every printed `--setenv NAME VALUE` whose `NAME` contains (case-insensitive) `KEY`, `TOKEN`, `PASSWORD`, `SECRET`, or `CREDENTIAL` is printed with the value `REDACTED`. Only the diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index f578c13..523e884 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -105,6 +105,10 @@ GIT_INCLUDE_PREDIRS=() # a tmpfs, so build_git_mounts recreates them with --symlink; the kernel then # resolves ".." against the symlink target exactly as it does on the host. GIT_INCLUDE_LINKS=() +# GIT_INCLUDE_RAW: at the same index, the include path exactly as git computes +# it (not normalised); used to find where git looks for the file when a +# symlinked directory could not be recreated (see _bwrap_include_lexical_path). +GIT_INCLUDE_RAW=() # Results of _bwrap_canonicalize_safe / _bwrap_include_visible_path / # _bwrap_path_blocked (globals, because _check_path_safe calls exit and must @@ -113,12 +117,18 @@ _BWRAP_CANON="" _BWRAP_INC_VISIBLE="" _BWRAP_INC_PREDIRS="" _BWRAP_INC_LINKS="" +_BWRAP_INC_LEXICAL="" +_BWRAP_INC_LEXDIRS="" +_BWRAP_LEX_DEST="" _BWRAP_BLOCKED_KIND="" _BWRAP_BLOCKED_PREFIX="" # Symlinks already handled by _bwrap_git_recreate_link: path -> 0 (present or # recreated in the sandbox) / 1 (could not be recreated). declare -A _GIT_LINK_STATUS=() +# Subset of the failed symlinks above: LINK -> 1 when LINK exists in the +# sandbox as a plain directory (see _bwrap_git_recreate_link). +declare -A _GIT_LINK_CONFLICT=() # Set to 1 by build_git_mounts when at least one file of # ${XDG_CONFIG_HOME}/git was mounted (launchers may point symlinks at the @@ -746,6 +756,7 @@ parse_git_includes() { GIT_INCLUDE_VISIBLE+=("${visible}") GIT_INCLUDE_PREDIRS+=("${predirs}") GIT_INCLUDE_LINKS+=("${links}") + GIT_INCLUDE_RAW+=("${path_val}") fi done < "${config_file}" } @@ -1022,6 +1033,36 @@ _bwrap_include_visible_path() { _BWRAP_INC_VISIBLE="${phys%/}${tail}" } +# _bwrap_include_lexical_path RAW +# Work out where git looks for an include in a sandbox where every symlinked +# directory of RAW exists as a plain directory (so ".." is collapsed +# lexically). Sets: +# _BWRAP_INC_LEXICAL RAW normalised lexically +# _BWRAP_INC_LEXDIRS newline-separated directories the kernel walks +# before the last ".." +_bwrap_include_lexical_path() { + local raw="$1" part cur="" i last_dotdot=-1 + local -a parts=() + local IFS=/ + _BWRAP_INC_LEXDIRS="" + read -r -a parts <<< "${raw}" + for ((i = 0; i < ${#parts[@]}; i++)); do + [[ "${parts[i]}" == ".." ]] && last_dotdot=${i} + done + for ((i = 0; i < ${#parts[@]}; i++)); do + part="${parts[i]}" + case "${part}" in + '' | .) ;; + ..) cur="${cur%/*}" ;; + *) + cur+="/${part}" + [[ "${i}" -lt "${last_dotdot}" ]] && _BWRAP_INC_LEXDIRS+="${cur}"$'\n' + ;; + esac + done + _BWRAP_INC_LEXICAL="${cur:-/}" +} + # validate_extra_path RAW_PATH MODE # Validate a user-supplied path (from --ro-path or --rw-path) before # mounting it into the sandbox. @@ -1484,12 +1525,16 @@ build_workdir_mount() { # _bwrap_already_mounted PATH # Return 0 when PATH lies inside a tree that is present in the sandbox with -# its host content (symlinks included): the standard system trees, the -# working directory, and the pixi / ccache / npm / ~/bin / registered -# mounts. Anything else under the tmpfs $HOME is NOT present. +# its host content (symlinks included): the standard system trees, the /etc +# DIRECTORY trees build_etc_mounts binds (never /etc itself: only selected +# entries of it are bound, so e.g. /etc/xdg is NOT present), the working +# directory, and the pixi / ccache / npm / ~/bin / registered mounts. +# Anything else under the tmpfs $HOME is NOT present. _bwrap_already_mounted() { local path="$1" _m - for _m in /usr /bin /sbin /lib /lib64 /etc /nsls2/software/bin \ + for _m in /usr /bin /sbin /lib /lib64 \ + /etc/ssl /etc/pki /etc/crypto-policies /etc/ca-certificates \ + /etc/alternatives /etc/ld.so.conf.d /nsls2/software/bin \ "${_BIND_DIR}" "${PIXI_HOME_DIR}" "${CCACHE_CONFIG_DIR}" \ "${CCACHE_CACHE_DIR}" "${NPM_CACHE_DIR}" "${_HOME}/bin"; do [[ -n "${_m}" ]] || continue @@ -1503,14 +1548,35 @@ _bwrap_already_mounted() { return 1 } +# _bwrap_dest_in_args PATH +# Return 0 when some element of BWRAP_ARGS equals PATH or lies below it +# (--dir / --tmpfs / --bind / --ro-bind / --symlink destination, or a child +# of PATH): PATH already exists in the sandbox as a directory (or other +# object) or has been populated, so a --symlink at PATH would make bwrap +# fail at startup. +_bwrap_dest_in_args() { + local path="$1" _a + for _a in "${BWRAP_ARGS[@]+"${BWRAP_ARGS[@]}"}"; do + [[ "${_a}" == "${path}" || "${_a}" == "${path}"/* ]] && return 0 + done + return 1 +} + # _bwrap_git_recreate_link LINK # Make the symlinked directory LINK exist in the sandbox. The sandbox # $HOME is a tmpfs, so a symlink under $HOME is lost; without it the kernel # resolves ".." in a git include path differently than on the host. Emits # --symlink LINK (after the --dir ancestors) once per LINK. # Return 0 when LINK is present (already mounted, or recreated), 1 when it -# cannot be: outside $HOME and not inside a mounted tree, dangling, or LINK -# or its target inside a blocked location (warned about; never linked). +# cannot be: outside $HOME and not inside a mounted tree (symlinks outside +# $HOME, e.g. under /etc, are never recreated), dangling, LINK or its target +# inside a blocked location, or LINK already present in BWRAP_ARGS (warned +# about; never linked). +# Ordering rule: bwrap fails at startup when --symlink's destination +# exists. build_home_tmpfs pre-creates ~/.config, ~/.local, ~/.local/share, +# ~/.cache and ~/bin as directories and other mounts populate paths below +# them before this runs, so a host symlink at such a path is NOT emitted; +# the sandbox keeps the plain directory (_GIT_LINK_CONFLICT[LINK]=1). _bwrap_git_recreate_link() { local link="$1" target if [[ -n "${_GIT_LINK_STATUS["${link}"]:-}" ]]; then @@ -1528,6 +1594,13 @@ _bwrap_git_recreate_link() { echo "Warning: symlink ${link} resolves into a blocked location (${target}); not recreated." >&2 return 1 fi + if _bwrap_dest_in_args "${link}"; then + _GIT_LINK_CONFLICT["${link}"]=1 + printf 'Warning: %s is a symlink on the host but exists as a plain directory in the sandbox\n' "${link}" >&2 + printf ' (it is pre-created or already populated); not recreated. Git includes that use ".."\n' >&2 + printf ' through it may not resolve.\n' >&2 + return 1 + fi _emit_home_intermediate_dirs "${link}" BWRAP_ARGS+=(--symlink "${target}" "${link}") _GIT_LINK_STATUS["${link}"]=0 @@ -1586,6 +1659,63 @@ _bwrap_mount_xdg_git_files() { _BWRAP_XDG_GIT_HAS_FILES=1 } +# _bwrap_git_lexical_mount RAW INC_PATH +# Fallback for a git include whose symlinked directories exist in the +# sandbox as plain directories: mount INC_PATH (read-only) at RAW collapsed +# lexically, creating the directories git walks before the last ".." (the +# caller's _GIT_INC_DIRS_SEEN / _GIT_INC_BOUND dedupe). Returns 1 without +# emitting the bind when that is not safe: the destination is outside $HOME +# or blocked, is already in BWRAP_ARGS (never mounted over an existing +# destination such as ~/.gitconfig or an XDG git file), or a walked +# component is blocked, outside $HOME and not mounted, or a symlink that is +# present in the sandbox as a symlink (not a plain directory). Sets +# _BWRAP_LEX_DEST on success. Runs in the caller's shell (it appends to +# BWRAP_ARGS), so it must not be used in a command substitution. +_bwrap_git_lexical_mount() { + local raw="$1" inc_path="$2" d lex parent + local -a dirs=() + _BWRAP_LEX_DEST="" + [[ -n "${raw}" ]] || return 1 + _bwrap_include_lexical_path "${raw}" + lex="${_BWRAP_INC_LEXICAL}" + [[ "${lex}" == "${_HOME}"/* && "${lex}" != "${inc_path}" ]] || return 1 + _bwrap_path_blocked "${lex}" && return 1 + while IFS= read -r d; do + [[ -n "${d}" ]] || continue + if _is_prefix_of "${d}" "${_HOME}"; then + continue # $HOME or an ancestor: exists (the tmpfs mount point) + elif [[ "${d}" == "${_HOME}"/* ]]; then + _bwrap_path_blocked "${d}" && return 1 + if [[ -L "${d}" && -z "${_GIT_LINK_CONFLICT["${d}"]:-}" ]]; then + return 1 + fi + dirs+=("${d}") + else + _bwrap_already_mounted "${d}" || return 1 + fi + done <<< "${_BWRAP_INC_LEXDIRS}" + if [[ -z "${_GIT_INC_BOUND["${lex}"]:-}" ]]; then + if _bwrap_dest_in_args "${lex}"; then + printf 'Warning: not mounting git include at %s: that path is already in use in the sandbox.\n' "${lex}" >&2 + return 1 + fi + for d in "${dirs[@]+"${dirs[@]}"}"; do + [[ -z "${_GIT_INC_DIRS_SEEN["${d}"]:-}" ]] || continue + _GIT_INC_DIRS_SEEN["${d}"]=1 + BWRAP_ARGS+=(--dir "${d}") + done + parent="${lex%/*}" + if [[ "${parent}" != "${_HOME}" && -z "${_GIT_INC_DIRS_SEEN["${parent}"]:-}" ]]; then + _GIT_INC_DIRS_SEEN["${parent}"]=1 + BWRAP_ARGS+=(--dir "${parent}") + fi + _GIT_INC_BOUND["${lex}"]=1 + BWRAP_ARGS+=(--ro-bind-try "${inc_path}" "${lex}") + fi + _BWRAP_LEX_DEST="${lex}" + return 0 +} + build_git_mounts() { # Resolve symlinks so we bind the real file (readlink -f is a no-op on # regular files). Check the resolved target before emitting the bind: @@ -1616,15 +1746,33 @@ build_git_mounts() { # Symlinked directories before a ".." in the include path: recreate # them so the kernel resolves ".." as on the host. If one cannot be # recreated the include is mounted at its real path only. - local _link _links_ok=1 + local _link _links_ok=1 _conflict_only=1 while IFS= read -r _link; do [[ -n "${_link}" ]] || continue - _bwrap_git_recreate_link "${_link}" || _links_ok=0 + if ! _bwrap_git_recreate_link "${_link}"; then + _links_ok=0 + [[ -n "${_GIT_LINK_CONFLICT["${_link}"]:-}" ]] || _conflict_only=0 + fi done <<< "${GIT_INCLUDE_LINKS[${_i}]:-}" + local _lex="" if [[ "${_links_ok}" -eq 0 ]]; then - printf 'Warning: git include %s needs a symlinked directory that cannot be recreated in\n' "${inc_visible}" >&2 - printf ' the sandbox; it is mounted at its real path (%s) only, so git may not find it.\n' "${inc_path}" >&2 inc_visible="${inc_path}" + # Every symlink that could not be recreated exists in the sandbox + # as a plain directory (conflict with a pre-created directory): + # git resolves ".." there lexically, so provide the include at + # its lexically collapsed path as well. Never done when a + # symlink failed for another reason (missing, blocked, outside + # $HOME): that component does not exist in the sandbox. + if [[ "${_conflict_only}" -eq 1 ]]; then + _bwrap_git_lexical_mount "${GIT_INCLUDE_RAW[${_i}]:-}" "${inc_path}" && _lex="${_BWRAP_LEX_DEST}" + fi + if [[ -n "${_lex}" ]]; then + printf 'Warning: git include %s: a symlinked directory is a plain directory in the sandbox;\n' "${GIT_INCLUDE_RAW[${_i}]}" >&2 + printf ' the include is also mounted at %s, where git looks for it there.\n' "${_lex}" >&2 + else + printf 'Warning: git include %s needs a symlinked directory that cannot be recreated in\n' "${GIT_INCLUDE_VISIBLE[${_i}]:-${inc_path}}" >&2 + printf ' the sandbox; it is mounted at its real path (%s) only, so git may not find it.\n' "${inc_path}" >&2 + fi fi # Directories the kernel must traverse to resolve ".." in the include # path git opens (they hold no data; they only have to exist). From 9ea70868077791384eb5f58f2272b108cfc1d636 Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Sun, 18 Oct 2026 19:38:05 -0400 Subject: [PATCH 07/10] fix(bw): keep include traversal dirs, nested includes, bind-root links; refuse dirs containing blocked paths Deduplicate git includes by raw path, follow nested includes (depth 10, cycle-safe), treat a symlink that is itself a bind root as a conflict, and refuse a working directory or extra path that contains a blocked location. Assisted-by: opencode:claude-sonnet-5-5 --- bin/bwopencode2 | 4 + docs/bw-wrappers.md | 33 ++++++- lib/bwrap_sandbox_lib.sh | 183 ++++++++++++++++++++++++++++++++------- 3 files changed, 187 insertions(+), 33 deletions(-) diff --git a/bin/bwopencode2 b/bin/bwopencode2 index 8cbbcf4..3a6aade 100755 --- a/bin/bwopencode2 +++ b/bin/bwopencode2 @@ -335,6 +335,10 @@ resolve_opencode2_paths() { OC2_XDG_STATE_HOME="${_HOME}/.local/state/opencode2" OC2_XDG_CACHE_HOME="${_HOME}/.cache/opencode2" OC2_CONFIG_DIR="${OC2_XDG_CONFIG_HOME}/opencode" + # The config directory holds service.json, which is never mounted: refuse + # a working directory / --ro-path / --rw-path that would bind it whole. + # shellcheck disable=SC2034 # read by _check_dir_contains_blocked in the library + EXTRA_BLOCKED_DIRS=("${OC2_CONFIG_DIR}") OC2_DATA_DIR="${OC2_XDG_DATA_HOME}/opencode" OC2_STATE_DIR="${OC2_XDG_STATE_HOME}/opencode" OC2_CACHE_DIR="${OC2_XDG_CACHE_HOME}/opencode" diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index eaaa758..f62b51c 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -145,7 +145,38 @@ cases. `~/.gitconfig` or an XDG git file). Symlinks outside `$HOME` (for example under `/etc`, where only selected entries are bound) are not recreated: the four XDG git files are then mounted at the host path of the symlinked - directory. + directory. A symlink that is itself the root of a directory mount (for + example `~/bin -> ~/dotfiles/bin`, bound as a plain directory at `~/bin`) is + not recreated either: the sandbox has a plain directory there, a warning is + printed and the lexical fallback above applies. A symlink strictly inside a + mounted tree (for example inside the working directory) keeps its host form + and needs nothing. +- **Git includes are followed recursively.** `[include]` / `[includeIf]` + `path =` entries of `~/.gitconfig` and `${XDG_CONFIG_HOME}/git/config` are + followed through nested includes to a depth of 10 (git's own limit), each + file is read once (include loops terminate), and an included file that + resolves into a blocked location is neither read nor mounted (the launcher + refuses it). Distinct include paths as written each get their own traversal + directories (`~/first/../common.conf` and `~/second/../common.conf` both + work), even when they name the same file; identical mounts are emitted once. +- **A directory that contains a blocked location is refused.** The working + directory (the git root when inside a repository), `--ro-path` and + `--rw-path` are checked on their canonical path: besides `$HOME`, its + ancestors and anything inside a blocked tree, a directory that contains a + blocked location that exists is refused with `Error: '' + contains the blocked path ''. Refusing to mount.` (for example running + from `~/.config` when `~/.config/gh` exists, or from a git root above + `~/.ssh`); blocked locations that do not exist are ignored. `bwopencode2` + also refuses a directory that equals or contains `~/.config/opencode2/opencode` + (for example `~/.config/opencode2`), because `service.json` there is + deliberately never mounted and a whole-directory bind would expose it. +- **Known limitations of the git include handling.** `includeIf` conditions + are not evaluated, so every candidate include is mounted; includes configured + only through the environment or `git -c include.path=...` are not seen; include + chains deeper than 10 are ignored; unusual symlink layouts (for example a + symlink at a directory-bind root or under a path the launcher pre-creates) + fall back to a warning and a lexical mount, and git may still not find the + file. - **`--dry-run` redacts secrets.** Every printed `--setenv NAME VALUE` whose `NAME` contains (case-insensitive) `KEY`, `TOKEN`, `PASSWORD`, `SECRET`, or `CREDENTIAL` is printed with the value `REDACTED`. Only the diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index 523e884..9f9b434 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -93,7 +93,17 @@ KERNEL_HAS_TIOCSTI_CAP_GUARD=0 # resolved physically (see _bwrap_include_visible_path). They differ when a # parent directory of the include is a symlink; build_git_mounts then mounts # the content at both paths. +# _GIT_INCLUDE_SEEN is keyed by the RAW include path (as git computes it), not +# by (canonical, visible): two raw paths that reach the same file can need +# different directories to exist in the sandbox (GIT_INCLUDE_PREDIRS), so each +# distinct raw path is its own record; build_git_mounts de-duplicates the +# resulting binds and directories. declare -A _GIT_INCLUDE_SEEN=() +# _GIT_INCLUDE_PARSED: canonical paths of the config files already read by +# parse_git_includes (cycle protection for nested includes). +declare -A _GIT_INCLUDE_PARSED=() +# Maximum include nesting followed (git's own limit is 10). +_GIT_INCLUDE_MAX_DEPTH=10 GIT_INCLUDE_PATHS=() GIT_INCLUDE_VISIBLE=() # GIT_INCLUDE_PREDIRS: at the same index, a newline-separated list of @@ -708,9 +718,28 @@ _mount_npm_global_prefix() { # ── Git include parser ─────────────────────────────────────────── +# parse_git_includes CONFIG_FILE [DEPTH] +# Record every [include] / [includeIf] "path =" target of CONFIG_FILE and +# follow the included files recursively (DEPTH is the nesting level of +# CONFIG_FILE: 0 for ~/.gitconfig and the XDG config; at most +# _GIT_INCLUDE_MAX_DEPTH levels of includes are followed, like git). +# CONFIG_FILE is the path as git opened it (not normalised): relative +# includes resolve against its directory, and the kernel resolves any ".." +# in it, exactly as for git. A file is parsed once (keyed by its canonical +# path), so include loops terminate; a canonical path in a blocked location +# is never read (build_git_mounts refuses it at mount time). Conditions of +# [includeIf] are not evaluated: every candidate is recorded. parse_git_includes() { - local config_file="$1" - [[ -f "${config_file}" ]] || return 0 + local config_file="$1" depth="${2:-0}" line + [[ -f "${config_file}" && -r "${config_file}" ]] || return 0 + [[ "${depth}" -lt "${_GIT_INCLUDE_MAX_DEPTH}" ]] || return 0 + + local self_real + self_real="$(readlink -f -- "${config_file}" 2> /dev/null || true)" + if [[ -n "${self_real}" ]]; then + [[ -z "${_GIT_INCLUDE_PARSED["${self_real}"]:-}" ]] || return 0 + _GIT_INCLUDE_PARSED["${self_real}"]=1 + fi # Git resolves relative include paths relative to the location of # the config file (the symlink path, NOT the resolved target). @@ -749,14 +778,18 @@ parse_git_includes() { resolved="$(readlink -f "${path_val}" 2> /dev/null || true)" [[ -n "${resolved}" ]] || resolved="${visible}" - # Deduplicate on the (canonical, visible) pair - if [[ -e "${resolved}" ]] && [[ -z "${_GIT_INCLUDE_SEEN["${resolved}|${visible}"]:-}" ]]; then - _GIT_INCLUDE_SEEN["${resolved}|${visible}"]=1 + # Deduplicate on the raw path as written (see _GIT_INCLUDE_SEEN) + if [[ -e "${resolved}" ]] && [[ -z "${_GIT_INCLUDE_SEEN["${path_val}"]:-}" ]]; then + _GIT_INCLUDE_SEEN["${path_val}"]=1 GIT_INCLUDE_PATHS+=("${resolved}") GIT_INCLUDE_VISIBLE+=("${visible}") GIT_INCLUDE_PREDIRS+=("${predirs}") GIT_INCLUDE_LINKS+=("${links}") GIT_INCLUDE_RAW+=("${path_val}") + # Nested includes: follow the included file unless it is blocked. + if [[ -f "${resolved}" ]] && ! _bwrap_path_blocked "${resolved}"; then + parse_git_includes "${path_val}" "$((depth + 1))" + fi fi done < "${config_file}" } @@ -800,30 +833,14 @@ _is_prefix_of() { [[ "${a}" == "/" || "${b}" == "${a}" || "${b}" == "${a}/"* ]] } -# _bwrap_path_blocked CANON -# Non-exiting form of the check below: return 0 (true) when the canonical -# path CANON is blocked, 1 otherwise. On a hit, _BWRAP_BLOCKED_KIND is -# "home" (CANON is $HOME or an ancestor of it) or "prefix" (CANON is inside -# the sensitive subtree _BWRAP_BLOCKED_PREFIX). Used directly where a -# blocked entry is skipped with a warning instead of aborting the launcher, -# and by _check_path_safe, which prints the error and exits. -_bwrap_path_blocked() { - local canon="$1" - _BWRAP_BLOCKED_KIND="" - _BWRAP_BLOCKED_PREFIX="" - - # Reject if canon IS $HOME or is a directory that contains $HOME - # (i.e. /, /home, the username's parent dir, etc.). - # "_is_prefix_of canon _HOME" means: canon is a prefix of _HOME. - if _is_prefix_of "${canon}" "${_HOME}"; then - _BWRAP_BLOCKED_KIND="home" - return 0 - fi - - # XDG_DATA_HOME is set by resolve_common_paths, which is always called - # before any function that invokes this check. - local _blocked_prefix - local _BLOCKED_PREFIXES=( +# _bwrap_load_blocked_prefixes +# Fill the global array _BWRAP_BLOCKED_LIST with the sensitive locations +# that are never mounted (see _check_path_safe for the rationale). +# XDG_DATA_HOME is set by resolve_common_paths, which is always called +# before any function that uses this list. +_BWRAP_BLOCKED_LIST=() +_bwrap_load_blocked_prefixes() { + _BWRAP_BLOCKED_LIST=( # SSH / GPG / cloud keys "${_HOME}/.ssh" "${_HOME}/.gnupg" @@ -850,7 +867,31 @@ _bwrap_path_blocked() { "/etc/sudoers" "/etc/sudoers.d" ) - for _blocked_prefix in "${_BLOCKED_PREFIXES[@]}"; do +} + +# _bwrap_path_blocked CANON +# Non-exiting form of the check below: return 0 (true) when the canonical +# path CANON is blocked, 1 otherwise. On a hit, _BWRAP_BLOCKED_KIND is +# "home" (CANON is $HOME or an ancestor of it) or "prefix" (CANON is inside +# the sensitive subtree _BWRAP_BLOCKED_PREFIX). Used directly where a +# blocked entry is skipped with a warning instead of aborting the launcher, +# and by _check_path_safe, which prints the error and exits. +_bwrap_path_blocked() { + local canon="$1" + _BWRAP_BLOCKED_KIND="" + _BWRAP_BLOCKED_PREFIX="" + + # Reject if canon IS $HOME or is a directory that contains $HOME + # (i.e. /, /home, the username's parent dir, etc.). + # "_is_prefix_of canon _HOME" means: canon is a prefix of _HOME. + if _is_prefix_of "${canon}" "${_HOME}"; then + _BWRAP_BLOCKED_KIND="home" + return 0 + fi + + local _blocked_prefix + _bwrap_load_blocked_prefixes + for _blocked_prefix in "${_BWRAP_BLOCKED_LIST[@]}"; do if _is_prefix_of "${_blocked_prefix}" "${canon}"; then _BWRAP_BLOCKED_KIND="prefix" _BWRAP_BLOCKED_PREFIX="${_blocked_prefix}" @@ -935,6 +976,36 @@ _check_path_safe() { fi } +# EXTRA_BLOCKED_DIRS: launcher-specific locations that a mounted directory +# must not CONTAIN (they may be entered by other means, e.g. mounted file by +# file). bwopencode2 sets it to its config directory, whose service.json is +# deliberately never mounted. Existing paths only. +EXTRA_BLOCKED_DIRS=() + +# _check_dir_contains_blocked CANON CONTEXT +# Companion of _check_path_safe for a directory that is bound WHOLE (working +# directory or git root, --ro-path, --rw-path): exit 1 when the canonical +# directory CANON is, or is an ancestor of, a blocked location that exists +# on disk (anything in the blocked list, a symlink's target included, or an +# EXTRA_BLOCKED_DIRS entry). Binding such a directory would expose the +# blocked location through the parent mount (e.g. running from ~/.config +# would bind ~/.config/gh read-write). Blocked locations that do not exist +# are ignored. Call AFTER _check_path_safe (which reports $HOME, its +# ancestors and paths inside a blocked tree first). +_check_dir_contains_blocked() { + local canon="$1" context="$2" _b _t + _bwrap_load_blocked_prefixes + for _b in "${_BWRAP_BLOCKED_LIST[@]}" "${EXTRA_BLOCKED_DIRS[@]+"${EXTRA_BLOCKED_DIRS[@]}"}"; do + [[ -n "${_b}" ]] || continue + [[ -e "${_b}" || -L "${_b}" ]] || continue + _t="$(readlink -f -- "${_b}" 2> /dev/null || true)" + if _is_prefix_of "${canon}" "${_b}" || { [[ -n "${_t}" ]] && _is_prefix_of "${canon}" "${_t}"; }; then + echo "Error: ${context} '${canon}' contains the blocked path '${_b}'. Refusing to mount." >&2 + exit 1 + fi + done +} + # _check_dir_safe PATH CONTEXT # Convenience wrapper around _check_path_safe for directory paths that may # or may not exist yet (e.g. tool-home dirs created later by ensure_host_dirs). @@ -1097,6 +1168,9 @@ validate_extra_path() { # ── Safety check ───────────────────────────────────────────── _check_path_safe "${canon}" "--${mode}-path" + if [[ -d "${canon}" ]]; then + _check_dir_contains_blocked "${canon}" "--${mode}-path" + fi # All checks passed — return the canonical path. echo "${canon}" @@ -1512,6 +1586,14 @@ build_workdir_mount() { # already canonical paths, so no readlink -f step is needed here. _check_path_safe "${_BIND_DIR}" "working directory" + # A directory that CONTAINS a blocked location (e.g. ~/.config with + # ~/.config/gh) is refused too: the whole tree is bound read-write. The + # check runs on the canonical path. + local _bind_canon + _bind_canon="$(readlink -f -- "${_BIND_DIR}" 2> /dev/null || true)" + [[ -n "${_bind_canon}" ]] || _bind_canon="${_BIND_DIR}" + _check_dir_contains_blocked "${_bind_canon}" "working directory" + # If the bind dir is under HOME, create intermediate directories in the # tmpfs so the bind mount point is reachable. _BIND_DIR is itself the # bind mount point, so only its ancestors are pre-created. @@ -1548,6 +1630,31 @@ _bwrap_already_mounted() { return 1 } +# _bwrap_is_bind_root PATH +# Return 0 when PATH is EXACTLY the root of a directory the launcher binds +# whole (the working directory, the pixi / ccache / npm / ~/bin trees, any +# registered mount), other than the system trees /usr /bin /sbin /lib +# /lib64 /proc /dev /tmp (those are real directories, or symlinks that +# build_sys_mounts recreates as symlinks). A bind's source is resolved, so +# when PATH is a symlink the sandbox sees a plain directory at PATH; a +# symlink strictly INSIDE a bound tree keeps its host form. +_bwrap_is_bind_root() { + local path="$1" _m + for _m in "${_BIND_DIR}" "${PIXI_HOME_DIR}" "${CCACHE_CONFIG_DIR}" \ + "${CCACHE_CACHE_DIR}" "${NPM_CACHE_DIR}" "${_HOME}/bin"; do + [[ -n "${_m}" && "${_m}" == "${path}" ]] && return 0 + done + if [[ ${#_MOUNTED_PREFIXES[@]} -gt 0 ]]; then + for _m in "${!_MOUNTED_PREFIXES[@]}"; do + case "${_m}" in + /usr | /bin | /sbin | /lib | /lib64 | /proc | /dev | /tmp) continue ;; + esac + [[ "${_m}" == "${path}" ]] && return 0 + done + fi + return 1 +} + # _bwrap_dest_in_args PATH # Return 0 when some element of BWRAP_ARGS equals PATH or lies below it # (--dir / --tmpfs / --bind / --ro-bind / --symlink destination, or a child @@ -1570,8 +1677,9 @@ _bwrap_dest_in_args() { # Return 0 when LINK is present (already mounted, or recreated), 1 when it # cannot be: outside $HOME and not inside a mounted tree (symlinks outside # $HOME, e.g. under /etc, are never recreated), dangling, LINK or its target -# inside a blocked location, or LINK already present in BWRAP_ARGS (warned -# about; never linked). +# inside a blocked location, LINK already present in BWRAP_ARGS, or LINK is +# itself the root of a directory bind (_bwrap_is_bind_root; the last two are +# warned about and never linked). # Ordering rule: bwrap fails at startup when --symlink's destination # exists. build_home_tmpfs pre-creates ~/.config, ~/.local, ~/.local/share, # ~/.cache and ~/bin as directories and other mounts populate paths below @@ -1583,6 +1691,17 @@ _bwrap_git_recreate_link() { return "${_GIT_LINK_STATUS["${link}"]}" fi _GIT_LINK_STATUS["${link}"]=1 + if _bwrap_is_bind_root "${link}"; then + # The symlink IS the root of a directory bind (e.g. ~/bin -> dotfiles + # with --ro-bind ~/bin ~/bin): the bind source is the symlink's + # target, so the sandbox has a plain directory at LINK and ".." + # through it resolves lexically. + _GIT_LINK_CONFLICT["${link}"]=1 + printf 'Warning: %s is a symlink on the host but a plain directory in the sandbox\n' "${link}" >&2 + printf ' (it is the root of a directory mount); not recreated. Git includes that use ".."\n' >&2 + printf ' through it may not resolve.\n' >&2 + return 1 + fi if _bwrap_already_mounted "${link}"; then _GIT_LINK_STATUS["${link}"]=0 return 0 From 18cf31abee92603304d4e8e3ad29c60b40b16056 Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Sun, 18 Oct 2026 19:50:19 -0400 Subject: [PATCH 08/10] fix(bw): block git credential stores; key nested includes by opened path Add ~/.git-credentials and $XDG_CONFIG_HOME/git/credentials to the blocked list so whole-dir and explicit mounts refuse them. Track parsed git includes by the path as opened so symlinked configs sharing a target are all followed. Assisted-by: opencode:claude-sonnet-5-5 --- docs/bw-wrappers.md | 12 +++++++++++- lib/bwrap_sandbox_lib.sh | 27 ++++++++++++++++----------- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index f62b51c..c91edcf 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -117,6 +117,12 @@ cases. (`ssh-add` and `ssh-keygen` are masked and no agent socket is passed in). `allowed_signers` is mounted read-only, but `git` verifies SSH signatures with `ssh-keygen`, which is masked as well, so verify signatures on the host. +- **Default git credential stores are blocked paths.** `~/.git-credentials` + and `$XDG_CONFIG_HOME/git/credentials` (the plaintext files of git's `store` + credential helper) are on the blocked list, so a working directory or git + root that contains one that exists (for example launching from + `~/.config/git`), `--ro-path`, `--rw-path` and an `[include]` pointing at + either are refused. - **Paths are resolved before the blocked-path check.** Config entries and config directories (`~/.config/opencode*`), git include files, and the XDG `git`, `pixi/config.toml` and `ccache` locations are canonicalized with @@ -176,7 +182,11 @@ cases. chains deeper than 10 are ignored; unusual symlink layouts (for example a symlink at a directory-bind root or under a path the launcher pre-creates) fall back to a warning and a lexical mount, and git may still not find the - file. + file. Includes are tracked per path as opened, so two symlinks to one config + are both followed. In short: an unusual include or symlink layout can lose git + settings inside the sandbox. That does not expose credentials, so it is + treated as a limitation of the sandbox, not a bug, and the include handling is + not being extended further. - **`--dry-run` redacts secrets.** Every printed `--setenv NAME VALUE` whose `NAME` contains (case-insensitive) `KEY`, `TOKEN`, `PASSWORD`, `SECRET`, or `CREDENTIAL` is printed with the value `REDACTED`. Only the diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index 9f9b434..36b48cf 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -99,8 +99,10 @@ KERNEL_HAS_TIOCSTI_CAP_GUARD=0 # distinct raw path is its own record; build_git_mounts de-duplicates the # resulting binds and directories. declare -A _GIT_INCLUDE_SEEN=() -# _GIT_INCLUDE_PARSED: canonical paths of the config files already read by -# parse_git_includes (cycle protection for nested includes). +# _GIT_INCLUDE_PARSED: config files already read by parse_git_includes, keyed +# by the path AS OPENED (not the canonical path: two symlinks to one file each +# need their own traversal, because their relative includes resolve against +# different directories). Include loops end at the depth limit. declare -A _GIT_INCLUDE_PARSED=() # Maximum include nesting followed (git's own limit is 10). _GIT_INCLUDE_MAX_DEPTH=10 @@ -725,21 +727,18 @@ _mount_npm_global_prefix() { # _GIT_INCLUDE_MAX_DEPTH levels of includes are followed, like git). # CONFIG_FILE is the path as git opened it (not normalised): relative # includes resolve against its directory, and the kernel resolves any ".." -# in it, exactly as for git. A file is parsed once (keyed by its canonical -# path), so include loops terminate; a canonical path in a blocked location -# is never read (build_git_mounts refuses it at mount time). Conditions of +# in it, exactly as for git. A file is parsed once per path as opened, and +# nesting stops at the depth limit, so include loops terminate; a canonical +# path in a blocked location is never read (build_git_mounts refuses it at +# mount time). Conditions of # [includeIf] are not evaluated: every candidate is recorded. parse_git_includes() { local config_file="$1" depth="${2:-0}" line [[ -f "${config_file}" && -r "${config_file}" ]] || return 0 [[ "${depth}" -lt "${_GIT_INCLUDE_MAX_DEPTH}" ]] || return 0 - local self_real - self_real="$(readlink -f -- "${config_file}" 2> /dev/null || true)" - if [[ -n "${self_real}" ]]; then - [[ -z "${_GIT_INCLUDE_PARSED["${self_real}"]:-}" ]] || return 0 - _GIT_INCLUDE_PARSED["${self_real}"]=1 - fi + [[ -z "${_GIT_INCLUDE_PARSED["${config_file}"]:-}" ]] || return 0 + _GIT_INCLUDE_PARSED["${config_file}"]=1 # Git resolves relative include paths relative to the location of # the config file (the symlink path, NOT the resolved target). @@ -861,6 +860,12 @@ _bwrap_load_blocked_prefixes() { "${_HOME}/.config/op" # 1Password CLI (alternate path) # GitHub CLI tokens "${_HOME}/.config/gh" + # Default stores of git's "store" credential helper (plaintext). Listing + # them here makes the working-directory / --ro-path / --rw-path checks + # refuse them, and refuse a directory that contains an existing one + # (for example launching from ~/.config/git). + "${_HOME}/.git-credentials" + "${XDG_CONFIG_HOME}/git/credentials" # System "/root" "/etc/shadow" From dc0e1af5cb8647136122f97fed6fc41121a85708 Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Mon, 19 Oct 2026 04:58:44 -0400 Subject: [PATCH 09/10] fix(bw): decode quoted and commented git include values Decode include path values the way git does (double quotes, escapes, trailing comments, case-insensitive key) before choosing mounts, so a quoted include in ~/.config/git is found, and a quoted include of a blocked file is refused. Assisted-by: opencode:claude-sonnet-5-5 --- docs/bw-wrappers.md | 5 +++- lib/bwrap_sandbox_lib.sh | 55 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index c91edcf..a745cd3 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -182,7 +182,10 @@ cases. chains deeper than 10 are ignored; unusual symlink layouts (for example a symlink at a directory-bind root or under a path the launcher pre-creates) fall back to a warning and a lexical mount, and git may still not find the - file. Includes are tracked per path as opened, so two symlinks to one config + file. Include values are decoded the way git does (double quotes, the + escapes `\n \t \b \" \\`, trailing `#` or `;` comments, any case of + `path`); a value that git itself would reject is skipped, and so is one that + ends in a backslash (git's line continuation is not supported). Includes are tracked per path as opened, so two symlinks to one config are both followed. In short: an unusual include or symlink layout can lose git settings inside the sandbox. That does not expose credentials, so it is treated as a limitation of the sandbox, not a bug, and the include handling is diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index 36b48cf..6bf0f62 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -127,6 +127,7 @@ GIT_INCLUDE_RAW=() # not run in a subshell). _BWRAP_CANON="" _BWRAP_INC_VISIBLE="" +_BWRAP_GIT_VALUE="" _BWRAP_INC_PREDIRS="" _BWRAP_INC_LINKS="" _BWRAP_INC_LEXICAL="" @@ -720,6 +721,49 @@ _mount_npm_global_prefix() { # ── Git include parser ─────────────────────────────────────────── +# _bwrap_git_decode_value RAW +# Decode a git config value the way git does, so a quoted or commented +# include path is recognised. Result in _BWRAP_GIT_VALUE; returns 1 when the +# value is not valid (unterminated quote, unknown escape). Rules: leading +# and trailing whitespace is dropped; a double quote toggles quoting; inside +# or outside quotes the escapes \n \t \b \" \\ are decoded; outside quotes +# an unescaped '#' or ';' starts a comment. A trailing backslash (git's line +# continuation) is not supported: such a value is treated as invalid and the +# include is skipped. +_bwrap_git_decode_value() { + local raw="$1" out="" c next i n=${#1} quoted=0 pending="" + _BWRAP_GIT_VALUE="" + for ((i = 0; i < n; i++)); do + c="${raw:i:1}" + if [[ "${c}" == "\\" ]]; then + i=$((i + 1)) + [[ ${i} -lt ${n} ]] || return 1 + next="${raw:i:1}" + case "${next}" in + n) c=$'\n' ;; + t) c=$'\t' ;; + b) c=$'\b' ;; + '"' | "\\") c="${next}" ;; + *) return 1 ;; + esac + out+="${pending}${c}" + pending="" + elif [[ "${c}" == '"' ]]; then + quoted=$((1 - quoted)) + elif [[ ${quoted} -eq 0 && ("${c}" == "#" || "${c}" == ";") ]]; then + break + elif [[ ${quoted} -eq 0 && ("${c}" == " " || "${c}" == $'\t') ]]; then + # Whitespace outside quotes is kept only if more text follows. + [[ -n "${out}" || -n "${pending}" ]] && pending+=" " + else + out+="${pending}${c}" + pending="" + fi + done + [[ ${quoted} -eq 0 ]] || return 1 + _BWRAP_GIT_VALUE="${out}" +} + # parse_git_includes CONFIG_FILE [DEPTH] # Record every [include] / [includeIf] "path =" target of CONFIG_FILE and # follow the included files recursively (DEPTH is the nesting level of @@ -748,11 +792,12 @@ parse_git_includes() { # Matches lines like: path = /some/path or path = ~/relative # Pure bash — no grep/sed/xargs subprocesses. while IFS= read -r line || [[ -n "${line}" ]]; do - [[ "${line}" =~ ^[[:space:]]*path[[:space:]]*=[[:space:]]*(.*) ]] || continue - local path_val="${BASH_REMATCH[1]}" - - # Trim trailing whitespace - path_val="${path_val%"${path_val##*[![:space:]]}"}" + # git variable names are case-insensitive. The value is decoded the + # way git does (quotes, escapes, trailing comments) before anything is + # derived from it; an undecodable value is skipped. + [[ "${line}" =~ ^[[:space:]]*[Pp][Aa][Tt][Hh][[:space:]]*=[[:space:]]*(.*) ]] || continue + _bwrap_git_decode_value "${BASH_REMATCH[1]}" || continue + local path_val="${_BWRAP_GIT_VALUE}" [[ -z "${path_val}" ]] && continue # Resolve ~/ prefix From f34ea8d4b1a04fcc9cc0be36f33108095291bf0e Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Mon, 19 Oct 2026 05:35:42 -0400 Subject: [PATCH 10/10] fix(bw): match blocked paths at their symlink targets too The blocked list held literal paths while callers compare canonical ones, so a symlinked ~/.config/git, ~/.git-credentials, ~/.ssh or ~/.config let a blocked file through --ro-path, --rw-path or a git include. List each entry both as written and at its resolved target. Assisted-by: opencode:claude-opus-5-5 --- docs/bw-wrappers.md | 6 ++++++ lib/bwrap_sandbox_lib.sh | 22 +++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index a745cd3..295ce2b 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -123,6 +123,12 @@ cases. root that contains one that exists (for example launching from `~/.config/git`), `--ro-path`, `--rw-path` and an `[include]` pointing at either are refused. +- **Blocked paths are matched at their symlink targets too.** Every entry of + the blocked list is checked both as written and at its resolved target, so + with `~/.config/git -> ~/dotfiles/git` the file `~/dotfiles/git/credentials` + is blocked, and a symlinked `~/.git-credentials`, `~/.ssh` or `~/.config` is + blocked at its target. This applies to `--ro-path`, `--rw-path`, git includes + and the directory checks alike. - **Paths are resolved before the blocked-path check.** Config entries and config directories (`~/.config/opencode*`), git include files, and the XDG `git`, `pixi/config.toml` and `ccache` locations are canonicalized with diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index 6bf0f62..216b375 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -882,9 +882,19 @@ _is_prefix_of() { # that are never mounted (see _check_path_safe for the rationale). # XDG_DATA_HOME is set by resolve_common_paths, which is always called # before any function that uses this list. +# Callers compare CANONICAL paths, so each entry is listed both as written +# and at its resolved target (readlink -f) when that differs: with +# ~/.config/git -> ~/dotfiles/git the credentials file is matched as +# ~/dotfiles/git/credentials too, and a symlinked ~/.git-credentials or ~/.ssh +# is matched at its target. readlink -f resolves a missing last component +# against its (resolved) parent, so an entry that does not exist yet is +# covered as well. The list is built once per HOME/XDG combination. _BWRAP_BLOCKED_LIST=() +_BWRAP_BLOCKED_KEY="" _bwrap_load_blocked_prefixes() { - _BWRAP_BLOCKED_LIST=( + local _key="${_HOME}|${XDG_CONFIG_HOME:-}|${XDG_DATA_HOME:-}" + [[ "${_key}" == "${_BWRAP_BLOCKED_KEY}" && ${#_BWRAP_BLOCKED_LIST[@]} -gt 0 ]] && return 0 + local -a _literal=( # SSH / GPG / cloud keys "${_HOME}/.ssh" "${_HOME}/.gnupg" @@ -917,6 +927,16 @@ _bwrap_load_blocked_prefixes() { "/etc/sudoers" "/etc/sudoers.d" ) + local _e _r + _BWRAP_BLOCKED_LIST=() + for _e in "${_literal[@]}"; do + _BWRAP_BLOCKED_LIST+=("${_e}") + _r="$(readlink -f -- "${_e}" 2> /dev/null || true)" + if [[ -n "${_r}" && "${_r}" != "${_e}" ]]; then + _BWRAP_BLOCKED_LIST+=("${_r}") + fi + done + _BWRAP_BLOCKED_KEY="${_key}" } # _bwrap_path_blocked CANON