From 97615b8a3a3153508ae488c5bb4fcf90b06e3fdd Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 21 Jul 2026 23:06:20 -0400 Subject: [PATCH 01/40] ci: pin actions/checkout to commit SHA Pin actions/checkout from the floating @v7 tag to the specific commit SHA 3d3c42e (v7.0.1) for supply-chain reproducibility, consistent with the approach already used for the prefix-dev/setup-pixi action in the same workflow. Assisted-by: opencode:claude-sonnet-4-6 --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8feb8f0..2390a68 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -25,7 +25,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false From 93e99106d86b8c376530b115c274d8f2c68860e1 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Fri, 24 Jul 2026 11:25:47 -0400 Subject: [PATCH 02/40] ci(lint): guard zero/unfetchable push ref, generate the version table - On push, $BEFORE is 40 zeros on branch creation and may be unfetchable after a force-push; both break `pre-commit --from-ref`. Fall back to the --all-files path when $BEFORE is all-zeros or `git cat-file -e` can't find it. - Pass environments: dev to setup-pixi and drop the separate `pixi install -e dev` step (one fewer solve/install). - Generate the job-summary version table at runtime from `pixi list -e dev --json` and the .pre-commit-config.yaml rev tags instead of hardcoding versions that silently drifted from the lockfile (mypy was already pinned as "*" in pixi.toml yet listed as a fixed version). Assisted-by: ClaudeCode:claude-fable-5 Assisted-by: ClaudeCode:claude-sonnet-5 --- .github/workflows/lint.yml | 84 ++++++++++++++++++++++++++++++-------- 1 file changed, 68 insertions(+), 16 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 2390a68..8bff403 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -36,9 +36,7 @@ jobs: pixi-version: v0.71.3 cache: false frozen: true - - - name: Install dev environment - run: pixi install -e dev + environments: dev - name: Determine changed-file ref range id: refs @@ -52,14 +50,57 @@ jobs: echo "from_ref=origin/${BASE_REF}" >> "$GITHUB_OUTPUT" echo "to_ref=${SHA}" >> "$GITHUB_OUTPUT" elif [[ "$EVENT_NAME" == "push" ]]; then - echo "from_ref=${BEFORE}" >> "$GITHUB_OUTPUT" - echo "to_ref=${SHA}" >> "$GITHUB_OUTPUT" + # On branch creation, $BEFORE is 40 zeros; after force-push, the old SHA + # may be unfetchable. Both break pre-commit --from-ref, so fall back to --all-files. + if [[ "$BEFORE" =~ ^0+$ ]] || ! git cat-file -e "$BEFORE" 2>/dev/null; then + echo "from_ref=" >> "$GITHUB_OUTPUT" + echo "to_ref=" >> "$GITHUB_OUTPUT" + else + echo "from_ref=${BEFORE}" >> "$GITHUB_OUTPUT" + echo "to_ref=${SHA}" >> "$GITHUB_OUTPUT" + fi else # workflow_dispatch: lint everything echo "from_ref=" >> "$GITHUB_OUTPUT" echo "to_ref=" >> "$GITHUB_OUTPUT" fi + - name: Collect tool versions + id: versions + run: | + # Extract versions from pixi dev environment + RUFF_VER=$(pixi list -e dev --json | jq -r '.[] | select(.name=="ruff") | .version') + MYPY_VER=$(pixi list -e dev --json | jq -r '.[] | select(.name=="mypy") | .version') + CODESPELL_VER=$(pixi list -e dev --json | jq -r '.[] | select(.name=="codespell") | .version') + YAMLLINT_VER=$(pixi list -e dev --json | jq -r '.[] | select(.name=="yamllint") | .version') + ZIZMOR_VER=$(pixi list -e dev --json | jq -r '.[] | select(.name=="zizmor") | .version') + + # Extract versions from .pre-commit-config.yaml rev: tags + # (strip wrapper-specific build suffixes where applicable) + PRECOMMIT_HOOKS_VER=$(grep -A1 'repo: https://github.com/pre-commit/pre-commit-hooks' .pre-commit-config.yaml | grep 'rev:' | awk '{print $2}') + MARKDOWNLINT_VER=$(grep -A1 'repo: https://github.com/igorshubovych/markdownlint-cli' .pre-commit-config.yaml | grep 'rev:' | awk '{print $2}') + ACTIONLINT_VER=$(grep -A1 'repo: https://github.com/rhysd/actionlint' .pre-commit-config.yaml | grep 'rev:' | awk '{print $2}') + # shfmt: pre-commit-shfmt adds -N build numbers (v3.12.0-2 -> v3.12.0) + SHFMT_VER=$(grep -A1 'repo: https://github.com/scop/pre-commit-shfmt' .pre-commit-config.yaml | grep 'rev:' | awk '{print $2}' | sed 's/-[0-9]\+$//') + # shellcheck-py: Python wrapper adds .N to upstream shellcheck version (v0.11.0.1 -> v0.11.0) + SHELLCHECK_VER=$(grep -A1 'repo: https://github.com/shellcheck-py/shellcheck-py' .pre-commit-config.yaml | grep 'rev:' | awk '{print $2}' | sed 's/\.[0-9]\+$//') + GITLEAKS_VER=$(grep -A1 'repo: https://github.com/gitleaks/gitleaks' .pre-commit-config.yaml | grep 'rev:' | awk '{print $2}') + + # Write to GITHUB_OUTPUT + { + echo "ruff=$RUFF_VER" + echo "mypy=$MYPY_VER" + echo "codespell=$CODESPELL_VER" + echo "yamllint=$YAMLLINT_VER" + echo "zizmor=$ZIZMOR_VER" + echo "precommit_hooks=$PRECOMMIT_HOOKS_VER" + echo "markdownlint=$MARKDOWNLINT_VER" + echo "actionlint=$ACTIONLINT_VER" + echo "shfmt=$SHFMT_VER" + echo "shellcheck=$SHELLCHECK_VER" + echo "gitleaks=$GITLEAKS_VER" + } >> "$GITHUB_OUTPUT" + - name: Run pre-commit (changed files) id: pre_commit env: @@ -88,6 +129,17 @@ jobs: PRE_COMMIT_OUTCOME: ${{ steps.pre_commit.outcome }} FROM_REF: ${{ steps.refs.outputs.from_ref }} TO_REF: ${{ steps.refs.outputs.to_ref }} + RUFF_VER: ${{ steps.versions.outputs.ruff }} + MYPY_VER: ${{ steps.versions.outputs.mypy }} + CODESPELL_VER: ${{ steps.versions.outputs.codespell }} + YAMLLINT_VER: ${{ steps.versions.outputs.yamllint }} + ZIZMOR_VER: ${{ steps.versions.outputs.zizmor }} + PRECOMMIT_HOOKS_VER: ${{ steps.versions.outputs.precommit_hooks }} + MARKDOWNLINT_VER: ${{ steps.versions.outputs.markdownlint }} + ACTIONLINT_VER: ${{ steps.versions.outputs.actionlint }} + SHFMT_VER: ${{ steps.versions.outputs.shfmt }} + SHELLCHECK_VER: ${{ steps.versions.outputs.shellcheck }} + GITLEAKS_VER: ${{ steps.versions.outputs.gitleaks }} run: | if [[ "$PRE_COMMIT_OUTCOME" == "success" ]]; then BADGE="All linting hooks passed" @@ -112,17 +164,17 @@ jobs: echo "" echo "| Hook | Tool | Version |" echo "| ---- | ---- | ------- |" - echo "| trailing-whitespace / end-of-file-fixer / check-* | pre-commit-hooks | v5.0.0 |" - echo "| ruff check + ruff format | ruff | 0.15.8 |" - echo "| mypy | mypy | 2.1.0 |" - echo "| codespell | codespell | 2.4.2 |" - echo "| markdownlint | markdownlint-cli | v0.48.0 |" - echo "| yamllint | yamllint | 1.38.0 |" - echo "| actionlint | actionlint | v1.7.12 |" - echo "| zizmor | zizmor | 1.23.1 |" - echo "| shfmt | shfmt | v3.12.0 |" - echo "| shellcheck | shellcheck | v0.11.0 |" - echo "| gitleaks | gitleaks | v8.30.1 |" + echo "| trailing-whitespace / end-of-file-fixer / check-* | pre-commit-hooks | ${PRECOMMIT_HOOKS_VER} |" + echo "| ruff check + ruff format | ruff | ${RUFF_VER} |" + echo "| mypy | mypy | ${MYPY_VER} |" + echo "| codespell | codespell | ${CODESPELL_VER} |" + echo "| markdownlint | markdownlint-cli | ${MARKDOWNLINT_VER} |" + echo "| yamllint | yamllint | ${YAMLLINT_VER} |" + echo "| actionlint | actionlint | ${ACTIONLINT_VER} |" + echo "| zizmor | zizmor | ${ZIZMOR_VER} |" + echo "| shfmt | shfmt | ${SHFMT_VER} |" + echo "| shellcheck | shellcheck | ${SHELLCHECK_VER} |" + echo "| gitleaks | gitleaks | ${GITLEAKS_VER} |" echo "" if [[ -s /tmp/pre-commit-output.txt ]]; then echo "### Output" From a7ee8d239e147866ed087762f58ad91b4a25ba4a Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 23 Jul 2026 16:42:44 -0400 Subject: [PATCH 03/40] fix(gpg-passwd): close nameref collision; decrypt_env_file read required-variable values through a bash `local -n` nameref, which does not exist in zsh: sourced from a zsh startup file it failed with "local: bad option: -n" and then falsely reported every required variable empty (return 2) even when the decrypt had set it. Read the value by indirection through `eval "value=\${$name:-}"` instead (the portable idiom this file used before the nameref was introduced), guarded so it is still safe: - reject a name starting with the reserved __gpgpw_ prefix (would read one of this function's own locals), and - reject a name that is not a plain shell identifier (would let the eval inject code), both via pure glob `case` patterns that behave identically in bash and zsh. Verified in bash and zsh: valid vars pass, empty required var returns 2, an injection-shaped name and a reserved name both return 2, and the decrypted-content eval-failure path still returns 4. Assisted-by: OpenCode:claude-fable-5 Assisted-by: ClaudeCode:claude-opus-4-8 --- docs/gpg-passwd.md | 26 +++++++++++++++ lib/gpg-passwd.sh | 79 ++++++++++++++++++++++++++++++++++------------ 2 files changed, 84 insertions(+), 21 deletions(-) diff --git a/docs/gpg-passwd.md b/docs/gpg-passwd.md index b26c5b7..f71f56f 100644 --- a/docs/gpg-passwd.md +++ b/docs/gpg-passwd.md @@ -4,6 +4,10 @@ Shell library providing `decrypt_env_file`, a helper for sourcing GPG-encrypted environment files into the current shell. Sourced, not executed. +**Requires bash >= 4.3. Not zsh-safe.** The required-variable check uses a +`local -n` nameref, which does not exist in zsh and needs bash 4.3 or newer. +Source this only from bash callers. + ## Usage Source the library and call `decrypt_env_file`: @@ -13,6 +17,28 @@ source "${N2SNSCRIPTS_LIB}/gpg-passwd.sh" decrypt_env_file "$HOME/.private_env.gpg" AIFAPIM_HOST AIFAPIM_API_KEY ``` +### Calling under `set -e` + +`decrypt_env_file` runs `gpg` inside a command substitution. Under a +caller's `set -e`, a non-zero exit from that substitution can abort the +caller *before* it reads the return code, so the return values below are only +reliably observed when the call is guarded. Always capture the status: + +```bash +rc=0 +decrypt_env_file "$HOME/.private_env.gpg" AIFAPIM_HOST AIFAPIM_API_KEY || rc=$? +# inspect $rc: 0 = ok/missing-file, 2 = required var empty / bad args, +# 3 = gpg failed +``` + +The trailing `|| rc=$?` both captures the code and suppresses `set -e`. + +### Required-variable names + +Required-variable names passed to `decrypt_env_file` must not begin with +`__gpgpw_`; that prefix is reserved for the function's own locals and such a +name is rejected with return code `2`. + ## Behaviour - Decrypts the file with `gpg --quiet --batch --yes --decrypt` and diff --git a/lib/gpg-passwd.sh b/lib/gpg-passwd.sh index 1c2e796..eedcde9 100644 --- a/lib/gpg-passwd.sh +++ b/lib/gpg-passwd.sh @@ -21,46 +21,83 @@ # source /n2snscripts/lib/gpg-passwd.sh # decrypt_env_file "$HOME/.zshrc.private.gpg" AIFAPIM_HOST AIFAPIM_API_KEY # +# Calling convention under `set -e`: +# decrypt_env_file runs `gpg` in a command substitution. Under a caller's +# `set -e`, a non-zero exit from that command substitution can abort the +# caller *before* it observes the function's return code, so the documented +# return 3 (and return 2/0) are only reliably visible when the call is +# guarded. Always capture the status explicitly, e.g.: +# rc=0 +# decrypt_env_file "$HOME/.private.gpg" AIFAPIM_HOST || rc=$? +# case "$rc" in ... esac +# The trailing `|| rc=$?` both captures the code and suppresses `set -e`. +# # Portability: -# This file uses only portable bash syntax and works in both bash and -# zsh; the original lives in dotfiles/config/bashrc-functions and is -# also sourced from zshrc-functions. +# Works in both bash and zsh. Required-variable values are read by +# indirection through `eval "value=\${$name:-}"`; the name is first +# validated to be a plain shell identifier (and rejected if it starts with +# the reserved __gpgpw_ prefix), so the eval can neither inject code nor +# read one of this function's own locals. Bash `local -n` namerefs are not +# available in zsh, so eval indirection is the portable alternative. # Guard against double-sourcing. [[ -n "${_GPG_PASSWD_LIB_SOURCED:-}" ]] && return 0 _GPG_PASSWD_LIB_SOURCED=1 +# All function-local variables are prefixed `__gpgpw_` so the eval indirection +# below cannot read one of this function's own locals when a caller passes a +# required-var name that collides with a local. Required-var names that +# themselves start with `__gpgpw_`, or that are not plain shell identifiers, +# are rejected (return 2) to close the residual collision and injection risks. decrypt_env_file() { - local gpg_file="$1" + local __gpgpw_file="${1:-}" shift || true - local required_vars=("$@") + local __gpgpw_required=("$@") - if [[ -z "$gpg_file" ]]; then + if [[ -z "$__gpgpw_file" ]]; then printf 'decrypt_env_file: missing gpg_file argument\n' >&2 return 2 fi - if [[ ! -r "$gpg_file" ]]; then - printf 'decrypt_env_file: %s not found or unreadable; skipping decrypt.\n' "$gpg_file" >&2 + if [[ ! -r "$__gpgpw_file" ]]; then + printf 'decrypt_env_file: %s not found or unreadable; skipping decrypt.\n' "$__gpgpw_file" >&2 return 0 fi - local decrypted gpg_rc - decrypted="$(gpg --quiet --batch --yes --decrypt "$gpg_file")" - gpg_rc=$? - if ((gpg_rc != 0)); then - printf 'decrypt_env_file: gpg --decrypt %s failed (exit %d).\n' "$gpg_file" "$gpg_rc" >&2 + local __gpgpw_decrypted __gpgpw_rc + __gpgpw_decrypted="$(gpg --quiet --batch --yes --decrypt "$__gpgpw_file")" + __gpgpw_rc=$? + if ((__gpgpw_rc != 0)); then + printf 'decrypt_env_file: gpg --decrypt %s failed (exit %d).\n' "$__gpgpw_file" "$__gpgpw_rc" >&2 return 3 fi - eval "$decrypted" - unset decrypted + eval "$__gpgpw_decrypted" + unset __gpgpw_decrypted - local var value - for var in "${required_vars[@]}"; do - local -n _ref="$var" - value="${_ref:-}" - if [[ -z "$value" ]]; then - printf 'decrypt_env_file: required variable %s is still empty after decrypt.\n' "$var" >&2 + local __gpgpw_var __gpgpw_value + # Guard the expansion so an empty required-vars array does not trip + # `set -u` on bash < 4.4. + for __gpgpw_var in ${__gpgpw_required[@]+"${__gpgpw_required[@]}"}; do + # Validate the name before the eval indirection below. Reject the + # reserved __gpgpw_ prefix (would read one of this function's locals) + # and anything that is not a plain shell identifier (would let the + # eval inject code). Pure glob patterns so the check behaves + # identically in bash and zsh. + case "$__gpgpw_var" in + __gpgpw_*) + printf 'decrypt_env_file: required variable name %s is reserved (must not start with __gpgpw_).\n' "$__gpgpw_var" >&2 + return 2 + ;; + '' | [!A-Za-z_]* | *[!A-Za-z0-9_]*) + printf 'decrypt_env_file: required variable name %s is not a valid shell identifier.\n' "$__gpgpw_var" >&2 + return 2 + ;; + esac + # eval indirection for portability: `local -n` namerefs are bash-only. + # Safe: the name is a validated identifier, so only the caller's variable is read. + eval "__gpgpw_value=\${$__gpgpw_var:-}" + if [[ -z "$__gpgpw_value" ]]; then + printf 'decrypt_env_file: required variable %s is still empty after decrypt.\n' "$__gpgpw_var" >&2 return 2 fi done From b35e09f22dab623c63a7ac766efce83092170294 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Fri, 24 Jul 2026 11:16:54 -0400 Subject: [PATCH 04/40] fix(gpg-passwd): surface eval failures from decrypted content decrypt_env_file ran eval on the decrypted env file without checking its status, so a syntax error or failing command was silently swallowed (the function could still return 0). Capture eval's status directly (inside `if ! eval ...` the negated pipeline would make $? always 0) and return a new documented code 4 with the underlying exit code in the message. Assisted-by: ClaudeCode:claude-fable-5 Assisted-by: ClaudeCode:claude-sonnet-5 --- docs/gpg-passwd.md | 1 + lib/gpg-passwd.sh | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/docs/gpg-passwd.md b/docs/gpg-passwd.md index f71f56f..8d604b1 100644 --- a/docs/gpg-passwd.md +++ b/docs/gpg-passwd.md @@ -54,5 +54,6 @@ name is rejected with return code `2`. | `0` (with warning) | File is missing — no decrypt attempted | | `2` | A required variable is still empty after decrypt | | `3` | `gpg` failed (bad passphrase, malformed file, etc.) | +| `4` | `eval` of decrypted content failed (syntax error or failing command) | See the library header comment for the full contract. diff --git a/lib/gpg-passwd.sh b/lib/gpg-passwd.sh index eedcde9..c07c760 100644 --- a/lib/gpg-passwd.sh +++ b/lib/gpg-passwd.sh @@ -13,6 +13,8 @@ # 2 - decrypt succeeded but a required variable is still unset # or empty (also returned when gpg_file argument is missing) # 3 - gpg decrypt failed +# 4 - eval of decrypted content failed (syntax error or failing +# command) # # Usage (in a caller script): # # Source the library by its installed path; substitute the correct @@ -71,8 +73,15 @@ decrypt_env_file() { printf 'decrypt_env_file: gpg --decrypt %s failed (exit %d).\n' "$__gpgpw_file" "$__gpgpw_rc" >&2 return 3 fi + # Capture the status directly: inside `if ! eval ...`, $? would be the + # negated pipeline's status (always 0), not eval's. eval "$__gpgpw_decrypted" + __gpgpw_rc=$? unset __gpgpw_decrypted + if ((__gpgpw_rc != 0)); then + printf 'decrypt_env_file: eval of decrypted content from %s failed (exit %d).\n' "$__gpgpw_file" "$__gpgpw_rc" >&2 + return 4 + fi local __gpgpw_var __gpgpw_value # Guard the expansion so an empty required-vars array does not trip From fb9642dfff2a6e9beb45eea9cc81487bd316b22f Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 23 Jul 2026 16:44:56 -0400 Subject: [PATCH 05/40] fix(gh-protect-branch): validate input early; build JSON with jq; paginate ruleset lookup User-supplied values (--approvers slug, --exclude-branches patterns) were interpolated raw into JSON payloads and API URLs and validated only after steps 1-4 had already mutated the repo. Validate them against a safe charset in the flag block before any API call, and rebuild every payload carrying variable input with 'jq -n --arg/--argjson/--args' so the injection class is gone (JSON shapes verified identical). The ruleset existence GET was unpaginated (a match past page 1 was missed, causing a duplicate POST) and swallowed transient errors as 'not found'. Add --paginate, collapse duplicate-name matches with first(...), abort on a real API error, and guard against a newline-joined id. Also: reject extra positional args, send error-path help to stderr, and declare _pats/_refs local. Assisted-by: OpenCode:claude-fable-5 Assisted-by: ClaudeCode:claude-opus-4-8 --- bin/gh-protect-branch | 231 ++++++++++++++++++++++++++++-------------- 1 file changed, 156 insertions(+), 75 deletions(-) diff --git a/bin/gh-protect-branch b/bin/gh-protect-branch index a3b4488..0c60328 100755 --- a/bin/gh-protect-branch +++ b/bin/gh-protect-branch @@ -55,6 +55,10 @@ # --exclude-branches Comma-separated list of fnmatch branch-name # patterns to allow creation of. Each entry is # matched against refs/heads/. +# Patterns are comma-split, so a comma cannot +# appear within a single pattern; each pattern must +# match ^[A-Za-z0-9._/*?-]+$ (letters, digits and +# . _ / * ? -). # Default: main,preview # Requires --restrict-branch-creation or # --only-restrict-creation. @@ -134,7 +138,7 @@ while [[ "${1:-}" == -* ]]; do ;; *) echo "Error: unknown option: $1" >&2 - show_help + show_help >&2 exit 1 ;; esac @@ -164,15 +168,66 @@ if [[ "${EXCLUDE_CSV_SET}" == "true" && exit 1 fi +# ── Validate user-supplied values (before any repo mutation) ─────────────────── +# These checks run BEFORE any API call so a malformed value cannot abort the run +# mid-way and leave the repository partially protected. They also confine the +# values that later flow into JSON payloads / API URLs to a safe charset, +# closing the JSON-injection / URL-rewrite classes at the source. + +if [[ -n "${APPROVERS_TEAM}" && ! "${APPROVERS_TEAM}" =~ ^[A-Za-z0-9_-]+$ ]]; then + echo "Error: --approvers slug must match ^[A-Za-z0-9_-]+\$ (letters, digits, '_', '-')." >&2 + echo " Got: '${APPROVERS_TEAM}'" >&2 + exit 1 +fi + +# Validate every --exclude-branches pattern. Patterns are comma-split, so a +# comma can never appear within a single pattern (documented in --help). Each +# trimmed pattern must be non-empty and match a safe fnmatch charset. +if [[ "${RESTRICT_CREATION}" == "true" || "${ONLY_RESTRICT_CREATION}" == "true" ]]; then + IFS=',' read -ra _exclude_pats <<< "${EXCLUDE_CSV}" + for _pat in "${_exclude_pats[@]}"; do + # trim leading/trailing whitespace + _pat="${_pat#"${_pat%%[![:space:]]*}"}" + _pat="${_pat%"${_pat##*[![:space:]]}"}" + if [[ -z "${_pat}" ]]; then + echo "Error: --exclude-branches contains an empty pattern (check for stray commas)." >&2 + exit 1 + fi + if [[ ! "${_pat}" =~ ^[A-Za-z0-9._/*?-]+$ ]]; then + echo "Error: --exclude-branches pattern '${_pat}' contains invalid characters." >&2 + echo " Allowed: letters, digits and . _ / * ? - (commas separate patterns)." >&2 + exit 1 + fi + done + unset _exclude_pats _pat +fi + # ── Positional arguments ─────────────────────────────────────────────────────── if [[ -z "${1:-}" ]]; then - show_help + show_help >&2 exit 1 fi REPO="$1" -BRANCH="${2:-main}" +shift + +# Consume the optional [branch] (only meaningful for full protection). Any +# remaining positional argument is unexpected and is a usage error. +BRANCH="main" +if [[ "${ONLY_RESTRICT_CREATION}" == "false" && $# -gt 0 ]]; then + BRANCH="$1" + shift +fi + +if [[ $# -gt 0 ]]; then + echo "Error: unexpected extra positional argument(s): $*" >&2 + if [[ "${ONLY_RESTRICT_CREATION}" == "true" ]]; then + echo " --only-restrict-creation takes only (no [branch])." >&2 + fi + show_help >&2 + exit 1 +fi if [[ ! "${REPO}" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then echo "Error: repository must be in 'owner/repo' format (e.g. NSLS2/n2snscripts)." >&2 @@ -211,22 +266,24 @@ fi _build_exclude_json() { local csv="$1" - local result="" local p + local _pats=() + local _refs=() IFS=',' read -ra _pats <<< "${csv}" for p in "${_pats[@]}"; do # trim leading/trailing whitespace p="${p#"${p%%[![:space:]]*}"}" p="${p%"${p##*[![:space:]]}"}" [[ -z "$p" ]] && continue - result+="${result:+, }\"refs/heads/${p}\"" + _refs+=("refs/heads/${p}") done - if [[ -z "${result}" ]]; then + if [[ "${#_refs[@]}" -eq 0 ]]; then echo "Error: --exclude-branches produced no usable patterns after trimming." >&2 echo " At least one non-empty branch name or pattern is required." >&2 exit 1 fi - printf '%s' "${result}" + # Emit a JSON array of ref strings; jq encodes each value safely. + jq -n --args '$ARGS.positional' "${_refs[@]}" } # ── Full branch protection (steps 1–4) ──────────────────────────────────────── @@ -235,45 +292,59 @@ _build_exclude_json() { if [[ "${ONLY_RESTRICT_CREATION}" == "false" ]]; then echo "Applying branch protection to ${REPO}@${BRANCH}..." - # Build the rules array. Signed commits are conditional. + # Build the conditional signed-commits rule as a (possibly empty) JSON array + # that is appended to the base rules; keeps the emitted shape identical to + # the previous printf assembly. if [[ "${SIGN_COMMITS}" == "true" ]]; then - SIGN_RULE=', - { "type": "required_signatures" }' + SIGN_RULES='[{ "type": "required_signatures" }]' else - SIGN_RULE='' + SIGN_RULES='[]' fi # Compose the ruleset payload (used for both create and update). - RULESET_PAYLOAD=$(printf '{ - "name": "%s", - "target": "branch", - "enforcement": "active", - "conditions": { - "ref_name": { - "include": ["~ALL"], - "exclude": [] - } - }, - "rules": [ - { "type": "deletion" }, - { "type": "non_fast_forward" }, - { - "type": "pull_request", - "parameters": { - "required_approving_review_count": 1, - "dismiss_stale_reviews_on_push": true, - "require_last_push_approval": true, - "require_code_owner_review": true, - "required_review_thread_resolution": true - } - }%s - ] -}' "${FULL_RULESET_NAME}" "${SIGN_RULE}") + RULESET_PAYLOAD=$(jq -n \ + --arg name "${FULL_RULESET_NAME}" \ + --argjson sign "${SIGN_RULES}" \ + '{ + name: $name, + target: "branch", + enforcement: "active", + conditions: { + ref_name: { + include: ["~ALL"], + exclude: [] + } + }, + rules: ([ + { type: "deletion" }, + { type: "non_fast_forward" }, + { + type: "pull_request", + parameters: { + required_approving_review_count: 1, + dismiss_stale_reviews_on_push: true, + require_last_push_approval: true, + require_code_owner_review: true, + required_review_thread_resolution: true + } + } + ] + $sign) + }') # 1) Repository ruleset (all branches via ~ALL) — idempotent. # Check for an existing ruleset with our name; PUT if found, POST if not. - RULESET_ID=$(gh api "repos/${REPO}/rulesets" \ - --jq ".[] | select(.name==\"${FULL_RULESET_NAME}\") | .id" 2> /dev/null || true) + # --paginate walks every page (the default 30/page can hide a match); + # first(...) collapses any duplicate-name rulesets to a single id. A real + # API error is distinguished from an empty result so a transient failure + # is not misread as "not found" (which would POST a duplicate). + if ! RULESET_ID=$(gh api --paginate "repos/${REPO}/rulesets" \ + --jq "first(.[] | select(.name==\"${FULL_RULESET_NAME}\") | .id)"); then + echo "Error: failed to query existing rulesets on ${REPO}." >&2 + echo " Aborting to avoid creating a duplicate ruleset." >&2 + exit 1 + fi + # Guard against a newline-joined id if multiple pages each emit a match. + RULESET_ID="${RULESET_ID%%$'\n'*}" if [[ -n "${RULESET_ID}" ]]; then printf '%s' "${RULESET_PAYLOAD}" | @@ -293,25 +364,27 @@ if [[ "${ONLY_RESTRICT_CREATION}" == "false" ]]; then # restrictions. The Ruleset covers the substantive rules for all # branches; the classic rule adds admin enforcement on the named branch # and (when --approvers is set) restricts who can dismiss stale reviews. - if [[ -n "${APPROVERS_TEAM}" ]]; then - DISMISSAL_JSON='"dismissal_restrictions": { "teams": ["'"${APPROVERS_TEAM}"'"] },' - else - DISMISSAL_JSON='' - fi - - printf '{ - "required_status_checks": null, - "enforce_admins": true, - "required_pull_request_reviews": { - %s - "required_approving_review_count": 1, - "dismiss_stale_reviews": true, - "require_last_push_approval": true, - "require_code_owner_reviews": true - }, - "restrictions": null, - "required_conversation_resolution": true -}' "${DISMISSAL_JSON}" | + # jq -n builds the payload with APPROVERS_TEAM safely encoded. When no team + # is set, the dismissal_restrictions key is omitted entirely (identical to + # the previous empty-string interpolation). + jq -n --arg team "${APPROVERS_TEAM}" ' + { + required_status_checks: null, + enforce_admins: true, + required_pull_request_reviews: ( + (if $team == "" then {} + else { dismissal_restrictions: { teams: [$team] } } + end) + + { + required_approving_review_count: 1, + dismiss_stale_reviews: true, + require_last_push_approval: true, + require_code_owner_reviews: true + } + ), + restrictions: null, + required_conversation_resolution: true + }' | gh api -X PUT "repos/${REPO}/branches/${BRANCH}/protection" \ -H "Accept: application/vnd.github+json" \ --input - > /dev/null @@ -359,24 +432,32 @@ fi if [[ "${RESTRICT_CREATION}" == "true" || "${ONLY_RESTRICT_CREATION}" == "true" ]]; then EXCLUDE_JSON=$(_build_exclude_json "${EXCLUDE_CSV}") - CREATION_PAYLOAD=$(printf '{ - "name": "%s", - "target": "branch", - "enforcement": "active", - "conditions": { - "ref_name": { - "include": ["~ALL"], - "exclude": [%s] - } - }, - "rules": [ - { "type": "creation" } - ] -}' "${CREATION_RULESET_NAME}" "${EXCLUDE_JSON}") - - CREATION_RULESET_ID=$(gh api "repos/${REPO}/rulesets" \ - --jq ".[] | select(.name==\"${CREATION_RULESET_NAME}\") | .id" \ - 2> /dev/null || true) + CREATION_PAYLOAD=$(jq -n \ + --arg name "${CREATION_RULESET_NAME}" \ + --argjson exclude "${EXCLUDE_JSON}" \ + '{ + name: $name, + target: "branch", + enforcement: "active", + conditions: { + ref_name: { + include: ["~ALL"], + exclude: $exclude + } + }, + rules: [ + { type: "creation" } + ] + }') + + # --paginate + first(...) + explicit error handling, as above. + if ! CREATION_RULESET_ID=$(gh api --paginate "repos/${REPO}/rulesets" \ + --jq "first(.[] | select(.name==\"${CREATION_RULESET_NAME}\") | .id)"); then + echo "Error: failed to query existing rulesets on ${REPO}." >&2 + echo " Aborting to avoid creating a duplicate ruleset." >&2 + exit 1 + fi + CREATION_RULESET_ID="${CREATION_RULESET_ID%%$'\n'*}" if [[ -n "${CREATION_RULESET_ID}" ]]; then printf '%s' "${CREATION_PAYLOAD}" | From 4ef252394f095fef60cb3773a446df50636cf05c Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Fri, 24 Jul 2026 11:14:27 -0400 Subject: [PATCH 06/40] fix(gh-protect-branch): scope ruleset lookups to the repository - Query rulesets with includes_parents=false: the list endpoint includes org-inherited rulesets by default, so an org-level ruleset sharing our standard name could be selected by the name-only jq filter and the subsequent PUT repos/{repo}/rulesets/{org-id} would 404 mid-run. - When both the full-protection and creation-restriction rulesets are needed in one invocation, fetch the ruleset list once and extract both IDs from the cached stream (jq -s) instead of two --paginate GETs; a fetched flag (not string emptiness) marks the cache valid so a repo with no rulesets yet does not re-fetch. - Drop the unreachable re-validation in _build_exclude_json: the pre-flight loop already rejects empty/invalid patterns under exactly the call site's condition. - docs: the "non-default branch" example passed the default branch. Assisted-by: ClaudeCode:claude-fable-5 Assisted-by: ClaudeCode:claude-sonnet-5 --- bin/gh-protect-branch | 73 ++++++++++++++++++++++++++++++--------- docs/gh-protect-branch.md | 2 +- 2 files changed, 58 insertions(+), 17 deletions(-) diff --git a/bin/gh-protect-branch b/bin/gh-protect-branch index 0c60328..8408e1b 100755 --- a/bin/gh-protect-branch +++ b/bin/gh-protect-branch @@ -263,6 +263,8 @@ fi # ── Build the creation-restriction ruleset exclude JSON ─────────────────────── # Each CSV entry is trimmed and prefixed with refs/heads/ so bare names like # "main" match correctly. fnmatch patterns (e.g. release/**) are preserved. +# The pre-flight validation loop (lines 186-203) already guaranteed every pattern +# is non-empty and matches the safe charset; this just builds the JSON array. _build_exclude_json() { local csv="$1" @@ -274,18 +276,36 @@ _build_exclude_json() { # trim leading/trailing whitespace p="${p#"${p%%[![:space:]]*}"}" p="${p%"${p##*[![:space:]]}"}" - [[ -z "$p" ]] && continue _refs+=("refs/heads/${p}") done - if [[ "${#_refs[@]}" -eq 0 ]]; then - echo "Error: --exclude-branches produced no usable patterns after trimming." >&2 - echo " At least one non-empty branch name or pattern is required." >&2 - exit 1 - fi # Emit a JSON array of ref strings; jq encodes each value safely. jq -n --args '$ARGS.positional' "${_refs[@]}" } +# ── Fetch repository rulesets once (used by both full protection and creation restriction) +# When both rulesets are needed in one invocation (ONLY_RESTRICT_CREATION=false + +# RESTRICT_CREATION=true), this fetch runs once and both IDs are extracted from +# the same response. When only one ruleset is needed, the fetch still happens +# lazily/correctly in its own section below. +# includes_parents=false excludes org-inherited rulesets so an org ruleset with +# the same name cannot be mistaken for a repo one (which would 404 on PUT). +# The query string is embedded in the URL so --paginate's link-header following +# preserves it across pages. +RULESETS_JSON="" +RULESETS_FETCHED=0 +if [[ "${ONLY_RESTRICT_CREATION}" == "false" && "${RESTRICT_CREATION}" == "true" ]]; then + # Both rulesets needed — fetch once. Track the fetch with a flag rather + # than testing RULESETS_JSON for emptiness: a repo with no rulesets at all + # (the common first run) legitimately yields an empty stream. + if ! RULESETS_JSON=$(gh api --paginate "repos/${REPO}/rulesets?includes_parents=false" \ + --jq '.[]'); then + echo "Error: failed to query existing rulesets on ${REPO}." >&2 + echo " Aborting to avoid creating duplicate rulesets." >&2 + exit 1 + fi + RULESETS_FETCHED=1 +fi + # ── Full branch protection (steps 1–4) ──────────────────────────────────────── # Skipped when --only-restrict-creation is set. @@ -337,11 +357,22 @@ if [[ "${ONLY_RESTRICT_CREATION}" == "false" ]]; then # first(...) collapses any duplicate-name rulesets to a single id. A real # API error is distinguished from an empty result so a transient failure # is not misread as "not found" (which would POST a duplicate). - if ! RULESET_ID=$(gh api --paginate "repos/${REPO}/rulesets" \ - --jq "first(.[] | select(.name==\"${FULL_RULESET_NAME}\") | .id)"); then - echo "Error: failed to query existing rulesets on ${REPO}." >&2 - echo " Aborting to avoid creating a duplicate ruleset." >&2 - exit 1 + if [[ "${RULESETS_FETCHED}" -eq 1 ]]; then + # Reuse the already-fetched rulesets list (both rulesets needed). + # -s slurps the newline-separated object stream into one array + # (interpolating it into an --argjson literal would be invalid JSON + # as soon as there is more than one ruleset). + RULESET_ID=$(jq -rs --arg name "${FULL_RULESET_NAME}" \ + 'first(.[] | select(.name==$name) | .id) // ""' \ + <<< "${RULESETS_JSON}") + else + # Only full protection needed — fetch rulesets now. + if ! RULESET_ID=$(gh api --paginate "repos/${REPO}/rulesets?includes_parents=false" \ + --jq "first(.[] | select(.name==\"${FULL_RULESET_NAME}\") | .id)"); then + echo "Error: failed to query existing rulesets on ${REPO}." >&2 + echo " Aborting to avoid creating a duplicate ruleset." >&2 + exit 1 + fi fi # Guard against a newline-joined id if multiple pages each emit a match. RULESET_ID="${RULESET_ID%%$'\n'*}" @@ -451,11 +482,21 @@ if [[ "${RESTRICT_CREATION}" == "true" || "${ONLY_RESTRICT_CREATION}" == "true" }') # --paginate + first(...) + explicit error handling, as above. - if ! CREATION_RULESET_ID=$(gh api --paginate "repos/${REPO}/rulesets" \ - --jq "first(.[] | select(.name==\"${CREATION_RULESET_NAME}\") | .id)"); then - echo "Error: failed to query existing rulesets on ${REPO}." >&2 - echo " Aborting to avoid creating a duplicate ruleset." >&2 - exit 1 + if [[ "${RULESETS_FETCHED}" -eq 1 ]]; then + # Reuse the already-fetched rulesets list (both rulesets needed); + # -s slurps the object stream into one array (see the full-protection + # lookup above). + CREATION_RULESET_ID=$(jq -rs --arg name "${CREATION_RULESET_NAME}" \ + 'first(.[] | select(.name==$name) | .id) // ""' \ + <<< "${RULESETS_JSON}") + else + # Only creation restriction needed (--only-restrict-creation) — fetch now. + if ! CREATION_RULESET_ID=$(gh api --paginate "repos/${REPO}/rulesets?includes_parents=false" \ + --jq "first(.[] | select(.name==\"${CREATION_RULESET_NAME}\") | .id)"); then + echo "Error: failed to query existing rulesets on ${REPO}." >&2 + echo " Aborting to avoid creating a duplicate ruleset." >&2 + exit 1 + fi fi CREATION_RULESET_ID="${CREATION_RULESET_ID%%$'\n'*}" diff --git a/docs/gh-protect-branch.md b/docs/gh-protect-branch.md index 9b52041..4501cb1 100644 --- a/docs/gh-protect-branch.md +++ b/docs/gh-protect-branch.md @@ -87,7 +87,7 @@ gh-protect-branch --help gh-protect-branch NSLS2/n2snscripts # Standard protection on a non-default branch -gh-protect-branch NSLS2/n2sndocs main +gh-protect-branch NSLS2/n2sndocs develop # With approver team restriction (team validated before applying) gh-protect-branch --approvers n2sn-admins NSLS2/n2snscripts From 442388f50c12608fc45f8f9739b12a687e3897bc Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 23 Jul 2026 16:50:02 -0400 Subject: [PATCH 07/40] fix(azoidcapp): escape OData filters; fix retry policy, error handling, and idempotency OData filter values were URL-encoded but not OData-escaped, so an apostrophe in --name broke every run; add _odata_str() (doubles ') at every filter site. Retry policy retried permanent 400s (~150s) while never retrying 429/5xx: retry (404,429,500,502,503,504), honour Retry-After, and retry 400 only on SP-replication-lag messages. Robustness: add a 30s urlopen timeout and catch URLError/TimeoutError/ JSONDecodeError as exit 4 (were uncaught tracebacks). SP creation and the grants POST now tolerate create-vs-get races (graph_or_exists / swallow_exists). Grant lookup filters consentType eq 'AllPrincipals' and adminConsentGranted is derived from reconciled state; the JSON output reports the merged redirect URIs; graph_list follows @odata.nextLink; -r values are deduped; non-404 owner-lookup failures exit 4; argparse usage errors exit 1 (2 is reserved for az-missing). Cleanups: drop the redundant az-account-show probe and the dead expect_no_content param; skip the requiredResourceAccess read-back when unchanged; add graph_json() to remove five type:ignore; move imports to top; urlencode the query string; teach _status_tag the 'added' status. Assisted-by: OpenCode:claude-fable-5 Assisted-by: ClaudeCode:claude-opus-4-8 --- bin/azoidcapp | 425 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 292 insertions(+), 133 deletions(-) diff --git a/bin/azoidcapp b/bin/azoidcapp index 3d18332..d01786a 100755 --- a/bin/azoidcapp +++ b/bin/azoidcapp @@ -42,6 +42,8 @@ from __future__ import annotations import argparse import json +import os +import socket import subprocess import sys import time @@ -124,26 +126,6 @@ def _run(args: list[str]) -> str: # --------------------------------------------------------------------------- -def check_az_login() -> None: - """Verify that 'az' is on PATH and a session is active.""" - try: - subprocess.run( - ["az", "account", "show"], - check=True, - capture_output=True, - ) - except FileNotFoundError: - die( - "'az' CLI not found on PATH. Install the Azure CLI: https://aka.ms/azure-cli", - code=2, - ) - except subprocess.CalledProcessError: - die( - "No active az session. Run 'az login' first.", - code=2, - ) - - def get_graph_token(tenant: str | None) -> str: """Obtain a Microsoft Graph bearer token via the az CLI.""" cmd = [ @@ -170,16 +152,29 @@ def get_graph_token(tenant: str | None) -> str: # --------------------------------------------------------------------------- +def _odata_str(value: str) -> str: + """ + Escape a string for safe interpolation into an OData filter literal. + + OData escapes a single quote by doubling it. Callers still URL-encode + the assembled filter expression (see _filter_path); this handles the + OData syntax layer so an apostrophe in a value cannot break the filter + or alter its semantics. + """ + return value.replace("'", "''") + + def _filter_path(resource: str, filter_expr: str, select: str | None = None) -> str: """ Build a Graph list path with a properly encoded $filter (and $select). - Encoding the filter value prevents urllib from rejecting URLs that + Encoding the query values prevents urllib from rejecting URLs that contain spaces or single-quotes (e.g. OData 'eq' expressions). """ - qs = f"$filter={urllib.parse.quote(filter_expr)}" + params = {"$filter": filter_expr} if select: - qs += f"&$select={urllib.parse.quote(select)}" + params["$select"] = select + qs = urllib.parse.urlencode(params, quote_via=urllib.parse.quote) return f"/{resource}?{qs}" @@ -196,20 +191,34 @@ def _parse_error(exc: urllib.error.HTTPError) -> tuple[int, str]: return exc.code, detail or f"HTTP {exc.code}" +# Non-HTTPError exceptions from a Graph call that still mean the call +# failed (network unreachable, timeout, or an unparsable response body). +# NOTE: HTTPError is a subclass of URLError, so any except clause using +# this tuple MUST catch urllib.error.HTTPError first. +_GRAPH_CALL_ERRORS = ( + urllib.error.URLError, + TimeoutError, + socket.timeout, + json.JSONDecodeError, +) + +# Network timeout (seconds) for a single Graph REST call. +_GRAPH_TIMEOUT = 30 + + def _graph_request( method: str, path: str, token: str, body: dict[str, Any] | None = None, - *, - expect_no_content: bool = False, ) -> dict[str, Any] | None: """ Make a single Microsoft Graph REST call. Returns the parsed JSON body on success, or None when the server - returns 204 No Content (or expect_no_content is True). - Raises urllib.error.HTTPError on HTTP failure. + returns an empty body (e.g. 204 No Content). + Raises urllib.error.HTTPError on HTTP failure; URLError/TimeoutError/ + json.JSONDecodeError on network or response-decoding failure. """ url = f"{GRAPH_BASE}{path}" data = json.dumps(body).encode() if body is not None else None @@ -219,9 +228,9 @@ def _graph_request( "Accept": "application/json", } req = urllib.request.Request(url, data=data, headers=headers, method=method) - with urllib.request.urlopen(req) as resp: + with urllib.request.urlopen(req, timeout=_GRAPH_TIMEOUT) as resp: raw = resp.read() - if expect_no_content or not raw: + if not raw: return None return json.loads(raw) @@ -232,16 +241,37 @@ def graph( token: str, body: dict[str, Any] | None = None, *, - expect_no_content: bool = False, description: str = "", ) -> dict[str, Any] | None: - """Graph call; dies with code 4 on HTTP failure.""" + """Graph call; dies with code 4 on HTTP, network, or decode failure.""" label = description or f"{method} {path}" try: - return _graph_request(method, path, token, body, expect_no_content=expect_no_content) + return _graph_request(method, path, token, body) except urllib.error.HTTPError as exc: code, detail = _parse_error(exc) die(f"{label} failed ({code}): {detail}", code=4) + except _GRAPH_CALL_ERRORS as exc: + die(f"{label} failed: {exc}", code=4) + + +def graph_json( + method: str, + path: str, + token: str, + body: dict[str, Any] | None = None, + *, + description: str = "", +) -> dict[str, Any]: + """ + Graph call that requires a JSON object response. + + Dies with code 4 on failure or when the server returns an empty body. + """ + label = description or f"{method} {path}" + resp = graph(method, path, token, body, description=description) + if resp is None: + die(f"{label} failed: expected a response body but got none", code=4) + return resp def _is_already_exists_error(http_code: int, message: str) -> bool: @@ -256,44 +286,74 @@ def _is_already_exists_error(http_code: int, message: str) -> bool: return True if http_code == 400: msg_lower = message.lower() - return any( - phrase in msg_lower - for phrase in ( - # "already exist" (no trailing 's') matches both: - # "One or more added object references already exist - # for the following modified properties: 'owners'." - # "Permission being assigned already exists." - "already exist", - "object references already exist", - ) - ) + # "already exist" (no trailing 's') matches both: + # "One or more added object references already exist for the + # following modified properties: 'owners'." + # "Permission being assigned already exists." + return "already exist" in msg_lower return False +def _is_sp_propagation_error(http_code: int, message: str) -> bool: + """ + Return True when a 400 is a transient service-principal replication lag. + + After a service principal is created, Microsoft Graph can take up to + ~60 s to make it visible to the admin-consent / grant endpoints, which + fail with a 400 whose message mentions the SP not existing yet. Only + those 400s are worth retrying; permanent 400s (bad payloads) are not. + """ + if http_code != 400: + return False + msg_lower = message.lower() + return any( + phrase in msg_lower + for phrase in ( + "does not exist", + "was not found", + "cannot find", + "service principal", + "replicat", # "replication", "not yet replicated" + ) + ) + + +def _retry_after_seconds(exc: urllib.error.HTTPError, default: float) -> float: + """Parse a Retry-After response header (delta-seconds); fall back to default.""" + value = exc.headers.get("Retry-After") if exc.headers else None + if value: + try: + return max(0.0, float(value.strip())) + except (TypeError, ValueError): + pass + return default + + def graph_or_exists( method: str, path: str, token: str, body: dict[str, Any] | None = None, *, - expect_no_content: bool = False, description: str = "", ) -> tuple[dict[str, Any] | None, bool]: """ Graph call that treats 'already exists' responses as success. Returns (response_body_or_None, already_existed). - Dies with code 4 on any other HTTP failure. + Dies with code 4 on any other failure. """ label = description or f"{method} {path}" try: - resp = _graph_request(method, path, token, body, expect_no_content=expect_no_content) + resp = _graph_request(method, path, token, body) return resp, False except urllib.error.HTTPError as exc: code, detail = _parse_error(exc) if _is_already_exists_error(code, detail): return None, True die(f"{label} failed ({code}): {detail}", code=4) + except _GRAPH_CALL_ERRORS as exc: + die(f"{label} failed: {exc}", code=4) def graph_with_retry( @@ -302,26 +362,29 @@ def graph_with_retry( token: str, body: dict[str, Any] | None = None, *, - expect_no_content: bool = False, description: str = "", retries: int = 6, backoff: float = 10.0, swallow_exists: bool = False, ) -> tuple[dict[str, Any] | None, bool]: """ - Graph call with exponential backoff on transient errors, optionally - treating 'already exists' as success. + Graph call with linear backoff on transient errors, optionally treating + 'already exists' as success. After creating a service principal, Microsoft Graph can take up to ~60 s to make the new SP visible to all endpoints (admin-consent, - role assignment, etc.). Retrying with a linear backoff handles this. + role assignment, etc.). Retrying with a linear backoff (backoff * attempt) + handles this. Retries on 404/429/5xx, and on a 400 only when its message + indicates SP-replication lag; honours a Retry-After response header. Returns (response_body_or_None, already_existed). """ label = description or f"{method} {path}" + # Status codes that are always worth retrying (transient/throttling). + retry_codes = (404, 429, 500, 502, 503, 504) for attempt in range(1, retries + 1): try: - resp = _graph_request(method, path, token, body, expect_no_content=expect_no_content) + resp = _graph_request(method, path, token, body) return resp, False except urllib.error.HTTPError as exc: code, detail = _parse_error(exc) @@ -329,15 +392,36 @@ def graph_with_retry( if swallow_exists and _is_already_exists_error(code, detail): return None, True - if code in (400, 404, 503) and attempt < retries: - wait = backoff * attempt + retryable = code in retry_codes or _is_sp_propagation_error(code, detail) + if retryable and attempt < retries: + wait = _retry_after_seconds(exc, backoff * attempt) warn(f"{label}: HTTP {code} (attempt {attempt}/{retries}), retrying in {wait:.0f}s\u2026") time.sleep(wait) continue die(f"{label} failed ({code}): {detail}", code=4) + except _GRAPH_CALL_ERRORS as exc: + if attempt < retries: + wait = backoff * attempt + warn(f"{label}: {exc} (attempt {attempt}/{retries}), retrying in {wait:.0f}s\u2026") + time.sleep(wait) + continue + die(f"{label} failed: {exc}", code=4) + + # Unreachable: the loop always either returns or dies on the final attempt. + raise AssertionError("graph_with_retry exhausted without returning") - die(f"{label}: all {retries} attempts failed", code=4) + +def _relativize_graph_url(url: str) -> str: + """Convert an absolute Graph @odata.nextLink to a GRAPH_BASE-relative path.""" + if url.startswith(GRAPH_BASE): + return url[len(GRAPH_BASE) :] + parsed = urllib.parse.urlsplit(url) + base_path = urllib.parse.urlsplit(GRAPH_BASE).path # e.g. "/v1.0" + path = parsed.path + if base_path and path.startswith(base_path): + path = path[len(base_path) :] + return urllib.parse.urlunsplit(("", "", path, parsed.query, "")) def graph_list( @@ -350,11 +434,19 @@ def graph_list( """ Return all items from a Graph list endpoint matching an OData filter. - Uses a properly encoded URL to avoid InvalidURL errors on spaces/quotes. + Follows @odata.nextLink so every page is accumulated, not just the + first. Uses a properly encoded URL to avoid InvalidURL errors on + spaces/quotes. """ - path = _filter_path(resource, filter_expr, select) - resp = graph("GET", path, token, description=description or f"list {resource}") - return resp.get("value", []) if resp else [] # type: ignore[union-attr] + path: str | None = _filter_path(resource, filter_expr, select) + label = description or f"list {resource}" + items: list[dict[str, Any]] = [] + while path is not None: + resp = graph_json("GET", path, token, description=label) + items.extend(resp.get("value", [])) + next_link = resp.get("@odata.nextLink") + path = _relativize_graph_url(next_link) if next_link else None + return items # --------------------------------------------------------------------------- @@ -423,10 +515,21 @@ def _graph_block_scope_ids(rra: list[dict[str, Any]]) -> set[str]: return set() +def _dedupe_preserving_order(values: list[str]) -> list[str]: + """Return values with duplicates removed, preserving first-seen order.""" + seen: set[str] = set() + out: list[str] = [] + for v in values: + if v not in seen: + seen.add(v) + out.append(v) + return out + + def _merge_redirect_uris(existing: list[str], desired: list[str]) -> tuple[list[str], bool]: """Union desired URIs into existing ones (add only). Returns (merged, changed).""" existing_set = set(existing) - to_add = [u for u in desired if u not in existing_set] + to_add = _dedupe_preserving_order([u for u in desired if u not in existing_set]) if not to_add: return existing, False return existing + to_add, True @@ -437,8 +540,20 @@ def _merge_redirect_uris(existing: list[str], desired: list[str]) -> tuple[list[ # --------------------------------------------------------------------------- +class _ArgumentParser(argparse.ArgumentParser): + """ArgumentParser whose usage errors exit 1, not argparse's default 2. + + Exit code 2 is reserved by this tool's contract for 'az missing / not + logged in'; a command-line usage error is exit 1. + """ + + def error(self, message: str) -> NoReturn: + self.print_usage(sys.stderr) + die(message, code=1) + + def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( + parser = _ArgumentParser( prog=PROG, description=( "Create or reconcile an Entra ID OIDC web application and " @@ -484,7 +599,11 @@ def parse_args() -> argparse.Namespace: default=False, help="Emit a JSON result instead of a human-readable summary", ) - return parser.parse_args() + args = parser.parse_args() + # Dedupe repeated -r values once so neither the create path (400 on + # duplicate redirectUris) nor the merge path can double them up. + args.redirect_uris = _dedupe_preserving_order(args.redirect_uris) + return args # --------------------------------------------------------------------------- @@ -493,8 +612,6 @@ def parse_args() -> argparse.Namespace: def _setup_colour() -> dict[str, str]: - import os - if sys.stdout.isatty() and not os.environ.get("NO_COLOR"): return { "reset": "\033[0m", @@ -509,8 +626,8 @@ def _setup_colour() -> dict[str, str]: def _status_tag(status: str, c: dict[str, str]) -> str: """Coloured status tag for human output.""" - if status == "created": - return f"{c['green']}created{c['reset']}" + if status in ("created", "added"): + return f"{c['green']}{status}{c['reset']}" if status == "updated": return f"{c['yellow']}updated{c['reset']}" if status in ("existed", "unchanged"): @@ -527,7 +644,8 @@ def main() -> None: # noqa: C901 — long but linear args = parse_args() # -- preflight ---------------------------------------------------------- - check_az_login() + # No separate 'az account show' probe: get_graph_token() already fails + # with exit 2 and the same diagnostics if az is missing or logged out. token = get_graph_token(args.tenant) # Tracks whether anything changed this run @@ -546,9 +664,15 @@ def main() -> None: # noqa: C901 — long but linear code, detail = _parse_error(exc) if code == 404: die(f"Owner '{args.owner}' not found in directory.", code=3) - die(f"Owner lookup failed ({code}): {detail}", code=3) - - owner_id: str = owner_obj["id"] # type: ignore[index] + # A real Graph failure (401/403/5xx) is a failed call, not a + # genuinely-absent owner: exit 4, not 3. + die(f"Owner lookup failed ({code}): {detail}", code=4) + except _GRAPH_CALL_ERRORS as exc: + die(f"Owner lookup failed: {exc}", code=4) + + if owner_obj is None: + die("Owner lookup returned an empty response.", code=4) + owner_id: str = owner_obj["id"] info(f"owner object ID: {owner_id}") # -- step 1: app registration (find-or-create + reconcile) ------------- @@ -557,7 +681,7 @@ def main() -> None: # noqa: C901 — long but linear apps = graph_list( "applications", - f"displayName eq '{args.name}'", + f"displayName eq '{_odata_str(args.name)}'", token, select="id,appId,web,requiredResourceAccess,displayName", description="Look up application by displayName", @@ -579,16 +703,17 @@ def main() -> None: # noqa: C901 — long but linear "web": {"redirectUris": args.redirect_uris}, "requiredResourceAccess": desired_rra, } - app = graph( + app = graph_json( "POST", "/applications", token, app_body, description="Create application", ) - app_id: str = app["appId"] # type: ignore[index] - app_obj_id: str = app["id"] # type: ignore[index] + app_id: str = app["appId"] + app_obj_id: str = app["id"] statuses["app"] = "created" + merged_uris = list(args.redirect_uris) any_changed = True info(f"appId: {app_id}") info(f"object ID: {app_obj_id}") @@ -619,7 +744,6 @@ def main() -> None: # noqa: C901 — long but linear f"/applications/{app_obj_id}", token, patch, - expect_no_content=True, description="Reconcile application", ) statuses["app"] = "updated" @@ -631,63 +755,65 @@ def main() -> None: # noqa: C901 — long but linear # Confirm every desired scope ID is present in requiredResourceAccess. # This catches drift between independent runs (e.g. grant set in step 5 # before the manifest was updated) and corrects it with one extra PATCH. - desired_scope_ids = set(DELEGATED_SCOPES.values()) - live_app = graph( - "GET", - f"/applications/{app_obj_id}?$select=requiredResourceAccess", - token, - description="Read back requiredResourceAccess", - ) - live_rra: list[dict[str, Any]] = (live_app or {}).get("requiredResourceAccess", []) - live_scope_ids = _graph_block_scope_ids(live_rra) - still_missing = desired_scope_ids - live_scope_ids - - if still_missing: - missing_names = [name for name, sid in DELEGATED_SCOPES.items() if sid in still_missing] - info( - f"read-back: {len(still_missing)} scope(s) missing from " - f"requiredResourceAccess after PATCH " - f"({', '.join(missing_names)}) — applying corrective PATCH" - ) - corrective_rra, _ = _merge_resource_access(live_rra, desired_rra) - graph( - "PATCH", - f"/applications/{app_obj_id}", - token, - {"requiredResourceAccess": corrective_rra}, - expect_no_content=True, - description="Corrective requiredResourceAccess PATCH", - ) - # Final verification — error loudly if still broken - live_app2 = graph( + # Only needed when we actually touched requiredResourceAccess; if the + # merge found nothing missing, the list query already proved it present. + if rra_changed: + desired_scope_ids = set(DELEGATED_SCOPES.values()) + live_app = graph_json( "GET", f"/applications/{app_obj_id}?$select=requiredResourceAccess", token, - description="Re-read requiredResourceAccess after corrective PATCH", + description="Read back requiredResourceAccess", ) - live_rra2: list[dict[str, Any]] = (live_app2 or {}).get("requiredResourceAccess", []) - still_missing2 = desired_scope_ids - _graph_block_scope_ids(live_rra2) - if still_missing2: - missing_names2 = [name for name, sid in DELEGATED_SCOPES.items() if sid in still_missing2] - die( - "requiredResourceAccess still missing scope(s) after " - f"corrective PATCH: {', '.join(missing_names2)}. " - "Check app manifest in the portal.", - code=4, + live_rra: list[dict[str, Any]] = live_app.get("requiredResourceAccess", []) + live_scope_ids = _graph_block_scope_ids(live_rra) + still_missing = desired_scope_ids - live_scope_ids + + if still_missing: + missing_names = [name for name, sid in DELEGATED_SCOPES.items() if sid in still_missing] + info( + f"read-back: {len(still_missing)} scope(s) missing from " + f"requiredResourceAccess after PATCH " + f"({', '.join(missing_names)}) — applying corrective PATCH" ) - statuses["app"] = "updated" - any_changed = True - info("read-back: corrective PATCH confirmed — all scopes now configured") - else: - info("read-back: all desired scopes present in requiredResourceAccess") - - info(f"app: {statuses['app']}") + corrective_rra, _ = _merge_resource_access(live_rra, desired_rra) + graph( + "PATCH", + f"/applications/{app_obj_id}", + token, + {"requiredResourceAccess": corrective_rra}, + description="Corrective requiredResourceAccess PATCH", + ) + # Final verification — error loudly if still broken + live_app2 = graph_json( + "GET", + f"/applications/{app_obj_id}?$select=requiredResourceAccess", + token, + description="Re-read requiredResourceAccess after corrective PATCH", + ) + live_rra2: list[dict[str, Any]] = live_app2.get("requiredResourceAccess", []) + still_missing2 = desired_scope_ids - _graph_block_scope_ids(live_rra2) + if still_missing2: + missing_names2 = [name for name, sid in DELEGATED_SCOPES.items() if sid in still_missing2] + die( + "requiredResourceAccess still missing scope(s) after " + f"corrective PATCH: {', '.join(missing_names2)}. " + "Check app manifest in the portal.", + code=4, + ) + statuses["app"] = "updated" + any_changed = True + info("read-back: corrective PATCH confirmed — all scopes now configured") + else: + info("read-back: all desired scopes present in requiredResourceAccess") + + info(f"app: {statuses['app']}") # -- step 2: service principal (find-or-create) ------------------------ step("Ensuring service principal\u2026") sps = graph_list( "servicePrincipals", - f"appId eq '{app_id}'", + f"appId eq '{_odata_str(app_id)}'", token, select="id", description="Look up service principal by appId", @@ -698,16 +824,34 @@ def main() -> None: # noqa: C901 — long but linear statuses["sp"] = "existed" info(f"found existing SP — object ID: {sp_obj_id}") else: - sp = graph( + # graph_or_exists() tolerates a create-vs-get race: if a concurrent + # run created the SP first, the 'already exists' response is treated + # as success and we re-fetch its object ID below. + sp, sp_existed = graph_or_exists( "POST", "/servicePrincipals", token, {"appId": app_id}, description="Create service principal", ) - sp_obj_id = sp["id"] # type: ignore[index] - statuses["sp"] = "created" - any_changed = True + if sp is not None and "id" in sp: + sp_obj_id = sp["id"] + statuses["sp"] = "existed" if sp_existed else "created" + else: + # Lost the race (empty/absent body): look the SP up by appId. + existing_sps = graph_list( + "servicePrincipals", + f"appId eq '{_odata_str(app_id)}'", + token, + select="id", + description="Re-look up service principal after race", + ) + if not existing_sps: + die("Service principal creation reported success but none was found.", code=4) + sp_obj_id = existing_sps[0]["id"] + statuses["sp"] = "existed" + if statuses["sp"] == "created": + any_changed = True info(f"SP object ID: {sp_obj_id}") # -- step 3: owners (app + SP) — swallow "already exists" -------------- @@ -719,7 +863,6 @@ def main() -> None: # noqa: C901 — long but linear f"/applications/{app_obj_id}/owners/$ref", token, owner_ref, - expect_no_content=True, description="Add application owner", ) statuses["app_owner"] = "existed" if app_owner_existed else "added" @@ -733,7 +876,6 @@ def main() -> None: # noqa: C901 — long but linear f"/servicePrincipals/{sp_obj_id}/owners/$ref", token, owner_ref, - expect_no_content=True, description="Add service principal owner", ) statuses["sp_owner"] = "existed" if sp_owner_existed else "added" @@ -745,7 +887,7 @@ def main() -> None: # noqa: C901 — long but linear step("Looking up Microsoft Graph service principal in tenant\u2026") graph_sps = graph_list( "servicePrincipals", - f"appId eq '{GRAPH_APP_ID}'", + f"appId eq '{_odata_str(GRAPH_APP_ID)}'", token, select="id", description="Get Graph service principal", @@ -762,15 +904,23 @@ def main() -> None: # noqa: C901 — long but linear scope_string = " ".join(DELEGATED_SCOPES.keys()) step(f"Ensuring delegated scope grant ({scope_string})\u2026") + # Filter on consentType eq 'AllPrincipals' so a per-user ('Principal') + # grant is never mistaken for the tenant-wide admin-consent grant. grants = graph_list( "oauth2PermissionGrants", - f"clientId eq '{sp_obj_id}' and resourceId eq '{graph_sp_id}'", + ( + f"clientId eq '{_odata_str(sp_obj_id)}' " + f"and resourceId eq '{_odata_str(graph_sp_id)}' " + "and consentType eq 'AllPrincipals'" + ), token, - select="id,scope", + select="id,scope,consentType", description="Look up delegated permission grant", ) if not grants: + # swallow_exists tolerates the 'already exists' 400 that Graph returns + # when a grant was created concurrently (or by an earlier partial run). graph_with_retry( "POST", "/oauth2PermissionGrants", @@ -782,9 +932,13 @@ def main() -> None: # noqa: C901 — long but linear "scope": scope_string, }, description="Grant delegated scopes", + swallow_exists=True, ) statuses["delegated_grant"] = "created" any_changed = True + # We just created the AllPrincipals grant with the full desired scope + # set (or swallow_exists confirmed one already exists). + admin_consent_granted = True else: grant = grants[0] if len(grants) > 1: @@ -797,6 +951,7 @@ def main() -> None: # noqa: C901 — long but linear if not missing: statuses["delegated_grant"] = "unchanged" + final_scopes = existing_scopes else: merged_scopes = " ".join(sorted(existing_scopes | desired_scopes)) graph( @@ -804,11 +959,15 @@ def main() -> None: # noqa: C901 — long but linear f"/oauth2PermissionGrants/{grant['id']}", token, {"scope": merged_scopes}, - expect_no_content=True, description="Add missing delegated scopes", ) statuses["delegated_grant"] = "updated" any_changed = True + final_scopes = existing_scopes | desired_scopes + + # This grant is AllPrincipals (filtered in the lookup); admin consent + # is granted iff every desired scope is present in its reconciled set. + admin_consent_granted = desired_scopes.issubset(final_scopes) info(f"delegated grant: {statuses['delegated_grant']}") @@ -822,11 +981,11 @@ def main() -> None: # noqa: C901 — long but linear "upn": args.owner, "objectId": owner_id, }, - "redirectUris": args.redirect_uris, + "redirectUris": merged_uris, "permissions": { "delegatedScopes": list(DELEGATED_SCOPES.keys()), }, - "adminConsentGranted": True, + "adminConsentGranted": admin_consent_granted, "changed": any_changed, "statuses": statuses, } From f1b5c1b2d1385334ab25c9abb064a9f423d2ebe5 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Fri, 24 Jul 2026 11:20:08 -0400 Subject: [PATCH 08/40] fix(azoidcapp): reconcile grant scopes on the already-exists path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delegated-grant POST discarded graph_with_retry's already_existed flag and unconditionally reported delegated_grant=created and adminConsentGranted=true. A grant is keyed only on (clientId, resourceId, consentType), so a pre-existing AllPrincipals grant with a scope subset (portal-created, or replication-lagged behind the initial lookup) was reported as fully consented while scopes like "openid email profile offline_access" were never granted — OIDC logins then fail with AADSTS65001 despite the tool reporting success. - When the swallow fires, re-list the grant (with linear-backoff retry, since Graph documents replication lag on this endpoint) and run the same scope union/PATCH reconciliation as the existing-grant branch; report existed/updated truthfully. - Give SP creation and both owner $ref POSTs retry via graph_with_retry(swallow_exists=True): they act on seconds-old objects and were the calls most exposed to the ~60s propagation lag the retry helper was written for. graph_or_exists is now unused and removed. - Add the "does not reference a valid application" appId-propagation message to _is_sp_propagation_error. - Escape "/" in the --owner UPN (quote(safe="")) so a slash cannot extend the URL path to a different Graph endpoint. - Drop the dead final_scopes/issubset bookkeeping. Assisted-by: ClaudeCode:claude-fable-5 Assisted-by: ClaudeCode:claude-sonnet-5 --- bin/azoidcapp | 127 ++++++++++++++++++++++++++++++++------------------ 1 file changed, 81 insertions(+), 46 deletions(-) diff --git a/bin/azoidcapp b/bin/azoidcapp index d01786a..cd37bb6 100755 --- a/bin/azoidcapp +++ b/bin/azoidcapp @@ -310,6 +310,7 @@ def _is_sp_propagation_error(http_code: int, message: str) -> bool: phrase in msg_lower for phrase in ( "does not exist", + "does not reference a valid application", # appId propagation lag "was not found", "cannot find", "service principal", @@ -329,33 +330,6 @@ def _retry_after_seconds(exc: urllib.error.HTTPError, default: float) -> float: return default -def graph_or_exists( - method: str, - path: str, - token: str, - body: dict[str, Any] | None = None, - *, - description: str = "", -) -> tuple[dict[str, Any] | None, bool]: - """ - Graph call that treats 'already exists' responses as success. - - Returns (response_body_or_None, already_existed). - Dies with code 4 on any other failure. - """ - label = description or f"{method} {path}" - try: - resp = _graph_request(method, path, token, body) - return resp, False - except urllib.error.HTTPError as exc: - code, detail = _parse_error(exc) - if _is_already_exists_error(code, detail): - return None, True - die(f"{label} failed ({code}): {detail}", code=4) - except _GRAPH_CALL_ERRORS as exc: - die(f"{label} failed: {exc}", code=4) - - def graph_with_retry( method: str, path: str, @@ -657,7 +631,7 @@ def main() -> None: # noqa: C901 — long but linear try: owner_obj = _graph_request( "GET", - f"/users/{urllib.parse.quote(args.owner)}", + f"/users/{urllib.parse.quote(args.owner, safe='')}", token, ) except urllib.error.HTTPError as exc: @@ -824,15 +798,17 @@ def main() -> None: # noqa: C901 — long but linear statuses["sp"] = "existed" info(f"found existing SP — object ID: {sp_obj_id}") else: - # graph_or_exists() tolerates a create-vs-get race: if a concurrent - # run created the SP first, the 'already exists' response is treated - # as success and we re-fetch its object ID below. - sp, sp_existed = graph_or_exists( + # graph_with_retry() tolerates both transient propagation errors (the + # appId may not be visible yet) and a create-vs-get race (concurrent + # run created the SP first). When swallow_exists fires, we re-fetch + # the object ID below. + sp, sp_existed = graph_with_retry( "POST", "/servicePrincipals", token, {"appId": app_id}, description="Create service principal", + swallow_exists=True, ) if sp is not None and "id" in sp: sp_obj_id = sp["id"] @@ -858,12 +834,13 @@ def main() -> None: # noqa: C901 — long but linear owner_ref = {"@odata.id": f"{GRAPH_BASE}/directoryObjects/{owner_id}"} step("Ensuring owner on application\u2026") - _, app_owner_existed = graph_or_exists( + _, app_owner_existed = graph_with_retry( "POST", f"/applications/{app_obj_id}/owners/$ref", token, owner_ref, description="Add application owner", + swallow_exists=True, ) statuses["app_owner"] = "existed" if app_owner_existed else "added" if not app_owner_existed: @@ -871,12 +848,13 @@ def main() -> None: # noqa: C901 — long but linear info(f"app owner: {statuses['app_owner']}") step("Ensuring owner on service principal\u2026") - _, sp_owner_existed = graph_or_exists( + _, sp_owner_existed = graph_with_retry( "POST", f"/servicePrincipals/{sp_obj_id}/owners/$ref", token, owner_ref, description="Add service principal owner", + swallow_exists=True, ) statuses["sp_owner"] = "existed" if sp_owner_existed else "added" if not sp_owner_existed: @@ -921,7 +899,9 @@ def main() -> None: # noqa: C901 — long but linear if not grants: # swallow_exists tolerates the 'already exists' 400 that Graph returns # when a grant was created concurrently (or by an earlier partial run). - graph_with_retry( + # When the swallow fires, Graph documents replication lag for this list + # endpoint, so we retry the lookup until the grant appears. + grant_resp, grant_existed = graph_with_retry( "POST", "/oauth2PermissionGrants", token, @@ -934,11 +914,67 @@ def main() -> None: # noqa: C901 — long but linear description="Grant delegated scopes", swallow_exists=True, ) - statuses["delegated_grant"] = "created" - any_changed = True - # We just created the AllPrincipals grant with the full desired scope - # set (or swallow_exists confirmed one already exists). - admin_consent_granted = True + + if grant_resp is not None and not grant_existed: + # Fresh creation: the response body holds the new grant with full scopes. + statuses["delegated_grant"] = "created" + any_changed = True + admin_consent_granted = True + else: + # The swallow fired (grant_existed=True): re-list with retry to find + # the grant, then reconcile scopes just like the existing-grant branch. + info("grant already existed (replication lag or concurrent create) — re-listing with retry…") + grant = None + for retry_attempt in range(1, 7): + refetch_grants = graph_list( + "oauth2PermissionGrants", + ( + f"clientId eq '{_odata_str(sp_obj_id)}' " + f"and resourceId eq '{_odata_str(graph_sp_id)}' " + "and consentType eq 'AllPrincipals'" + ), + token, + select="id,scope,consentType", + description=f"Re-list delegated permission grant (attempt {retry_attempt}/6)", + ) + if refetch_grants: + grant = refetch_grants[0] + break + if retry_attempt < 6: + wait = 10.0 * retry_attempt + warn(f"grant not yet visible (attempt {retry_attempt}/6), retrying in {wait:.0f}s…") + time.sleep(wait) + + if grant is None: + die( + "Grant POST returned 'already exists' but the grant never " + "appeared after 6 retries (replication timeout).", + code=4, + ) + + # Reconcile scopes (same logic as the existing-grant branch below). + existing_scopes = set((grant.get("scope") or "").split()) + desired_scopes = set(DELEGATED_SCOPES.keys()) + missing = desired_scopes - existing_scopes + + if not missing: + statuses["delegated_grant"] = "existed" + admin_consent_granted = True + else: + merged_scopes = " ".join(sorted(existing_scopes | desired_scopes)) + graph( + "PATCH", + f"/oauth2PermissionGrants/{grant['id']}", + token, + {"scope": merged_scopes}, + description="Add missing delegated scopes", + ) + statuses["delegated_grant"] = "updated" + any_changed = True + # Admin consent is granted only if the PATCH succeeded and all + # desired scopes are now present. The PATCH dies on failure + # (graph() has no swallow), so reaching here means success. + admin_consent_granted = True else: grant = grants[0] if len(grants) > 1: @@ -951,7 +987,8 @@ def main() -> None: # noqa: C901 — long but linear if not missing: statuses["delegated_grant"] = "unchanged" - final_scopes = existing_scopes + # All desired scopes already present; admin consent is granted. + admin_consent_granted = True else: merged_scopes = " ".join(sorted(existing_scopes | desired_scopes)) graph( @@ -963,11 +1000,9 @@ def main() -> None: # noqa: C901 — long but linear ) statuses["delegated_grant"] = "updated" any_changed = True - final_scopes = existing_scopes | desired_scopes - - # This grant is AllPrincipals (filtered in the lookup); admin consent - # is granted iff every desired scope is present in its reconciled set. - admin_consent_granted = desired_scopes.issubset(final_scopes) + # The PATCH succeeded (graph() dies on failure), so all desired + # scopes are now present and admin consent is granted. + admin_consent_granted = True info(f"delegated grant: {statuses['delegated_grant']}") From 9dcbf4864e5d722a0f60548128d9a403787db135 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 23 Jul 2026 16:51:42 -0400 Subject: [PATCH 09/40] fix(pemdecompose): re-raise fatal signals; align exit codes; cache openssl DN parse The INT/TERM/HUP trap ran cleanup and returned 0, so Ctrl-C let the script resume and exit ~0 with helpers killed. Keep cleanup on EXIT and re-raise each fatal signal (clear the signal+EXIT traps, then kill -SIG $$) so the process dies with 128+N and cleanup runs once (verified: Ctrl-C exits 130). An unreadable input file returned 3, which the docs define as 'openssl parse error'; reserve 3 for real parse failures and map an unreadable file to the usage-error code 1 (header + docs updated). The verify-failure reason no longer claims 'signature mismatch' for every openssl verify failure. Perf: parse subject+issuer in one 'openssl x509 -subject -issuer' call and cache per cert so the --verify loop reuses them instead of re-forking. Also drop a redundant QUIET test and collapse the two-sed usage(). Assisted-by: OpenCode:claude-fable-5 Assisted-by: ClaudeCode:claude-opus-4-8 --- bin/pemdecompose | 62 +++++++++++++++++++++++++------------------- docs/pemdecompose.md | 2 +- 2 files changed, 36 insertions(+), 28 deletions(-) diff --git a/bin/pemdecompose b/bin/pemdecompose index c21ffcd..2e09fd0 100755 --- a/bin/pemdecompose +++ b/bin/pemdecompose @@ -28,7 +28,7 @@ # # Exit codes: # 0 success -# 1 usage error +# 1 usage error (bad option, no input file, or an unreadable input file) # 2 no certificates found in at least one input file # 3 openssl parse error on at least one input file # 4 --verify failed for at least one chain link @@ -38,7 +38,7 @@ set -euo pipefail PROG="pemdecompose" usage() { - sed -n '2,/^$/p' "$0" | sed -e 's/^# \{0,1\}//' + sed -n '2,/^$/{ s/^# \{0,1\}//; p; }' "$0" } die() { @@ -124,7 +124,14 @@ cleanup() { fi return 0 } -trap cleanup EXIT INT TERM HUP +# Run cleanup once on normal exit. For fatal signals, clean up and then +# re-raise the signal after clearing both the signal and EXIT traps so the +# process dies with the conventional 128+N status and cleanup is not run a +# second time. +trap cleanup EXIT +trap 'cleanup; trap - INT EXIT; kill -INT $$' INT +trap 'cleanup; trap - TERM EXIT; kill -TERM $$' TERM +trap 'cleanup; trap - HUP EXIT; kill -HUP $$' HUP ensure_tmpdir() { if [[ -z "$TMPDIR_" ]]; then @@ -150,16 +157,12 @@ split_certs() { ' "$file" } -# Print just the subject DN (everything after "subject="). -cert_subject_only() { - openssl x509 -noout -subject -nameopt RFC2253 2> /dev/null | - sed -e 's/^subject=//' -} - -# Print just the issuer DN. -cert_issuer_only() { - openssl x509 -noout -issuer -nameopt RFC2253 2> /dev/null | - sed -e 's/^issuer=//' +# Print the subject and issuer DNs from a single openssl invocation. +# openssl emits one "subject=..." line and one "issuer=..." line; the +# caller strips the prefixes. Returns nonzero if openssl can't parse the +# block (pipefail propagates openssl's status past the reading pipe). +cert_subject_issuer() { + openssl x509 -noout -subject -issuer -nameopt RFC2253 2> /dev/null } # Print the top-level Signature Algorithm name (e.g. ecdsa-with-SHA384, @@ -181,7 +184,7 @@ process_file() { if [[ ! -r "$file" ]]; then warn "cannot read: $file" - return 3 + return 1 fi if [[ "$show_banner" -eq 1 ]]; then @@ -202,24 +205,30 @@ process_file() { return 2 fi - # Emit subject + issuer + sigalg per cert. + # Emit subject + issuer + sigalg per cert. Parse the subject and issuer + # once and cache them so the --verify loop can reuse them. + local -a subjects=() issuers=() local i for ((i = 0; i < count; i++)); do local idx=$((i + 1)) - local sub iss sigalg - if ! sub=$(printf '%s' "${blocks[$i]}" | cert_subject_only); then - warn "openssl failed to parse certificate #$idx in $file" - return 3 - fi - if ! iss=$(printf '%s' "${blocks[$i]}" | cert_issuer_only); then + local si sub="" iss="" sigalg line + if ! si=$(printf '%s' "${blocks[$i]}" | cert_subject_issuer); then warn "openssl failed to parse certificate #$idx in $file" return 3 fi + while IFS= read -r line; do + case "$line" in + subject=*) sub="${line#subject=}" ;; + issuer=*) iss="${line#issuer=}" ;; + esac + done <<< "$si" if ! sigalg=$(printf '%s' "${blocks[$i]}" | cert_sigalg); then warn "openssl failed to parse certificate #$idx in $file" return 3 fi [[ -z "$sigalg" ]] && sigalg="" + subjects[i]="$sub" + issuers[i]="$iss" printf '%s%2d%s %ssubject:%s %s\n' \ "$C_BOLD" "$idx" "$C_RESET" "$C_DIM" "$C_RESET" "$sub" printf ' %sissuer :%s %s\n' \ @@ -258,9 +267,8 @@ process_file() { local nxt="${cert_files[$((j + 1))]}" local cur_issuer nxt_subject - cur_issuer=$(printf '%s' "${blocks[$j]}" | cert_issuer_only) || true - nxt_subject=$(printf '%s' "${blocks[$((j + 1))]}" | - cert_subject_only) || true + cur_issuer="${issuers[$j]}" + nxt_subject="${subjects[$((j + 1))]}" local dn_ok=0 if [[ -n "$cur_issuer" && "$cur_issuer" == "$nxt_subject" ]]; then @@ -280,11 +288,11 @@ process_file() { link_ok=0 local reason="" if [[ $dn_ok -eq 0 && $sig_ok -eq 0 ]]; then - reason="DN mismatch and signature mismatch" + reason="issuer DN mismatch and signature verification failed" elif [[ $dn_ok -eq 0 ]]; then reason="issuer DN mismatch" else - reason="signature mismatch" + reason="signature verification failed" fi printf ' %d -> %d %sFAIL%s (%s)\n' \ "$cur_idx" "$nxt_idx" "$C_RED" "$C_RESET" "$reason" @@ -317,7 +325,7 @@ fi worst_rc=0 first=1 for file in "${FILES[@]}"; do - if [[ $first -eq 0 && $QUIET -eq 0 && $show_banner -eq 1 ]]; then + if [[ $first -eq 0 && $show_banner -eq 1 ]]; then printf '\n' fi first=0 diff --git a/docs/pemdecompose.md b/docs/pemdecompose.md index 8f0afce..64a3fce 100644 --- a/docs/pemdecompose.md +++ b/docs/pemdecompose.md @@ -32,7 +32,7 @@ pemdecompose --help | Code | Meaning | | --- | --- | | `0` | Success | -| `1` | Usage error | +| `1` | Usage error (bad option, no input file, or an unreadable input file) | | `2` | No certificates found in at least one input file | | `3` | `openssl` parse error on at least one certificate | | `4` | `--verify` failed for at least one chain link | From f05dca611ab6c5359a7664a53ceb568b7851769b Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Fri, 24 Jul 2026 11:16:54 -0400 Subject: [PATCH 10/40] fix(pemdecompose): check mktemp, halve openssl forks, document --no-colour - ensure_tmpdir now checks mktemp -d: a failing TMPDIR previously left TMPDIR_ empty and cascaded into misleading per-link "FAIL (signature verification failed)" (exit 4); it now reports the real problem and exits with a new documented operational-error code 5. - cert_info extracts subject, issuer, and signature algorithm from one openssl x509 invocation instead of two per certificate; output is byte-identical (verified against the previous version on a generated CA-signed chain, with and without --verify). - Document the --no-colour alias in --help and docs. Assisted-by: ClaudeCode:claude-fable-5 Assisted-by: ClaudeCode:claude-sonnet-5 --- bin/pemdecompose | 76 +++++++++++++++++++++++++------------------- docs/pemdecompose.md | 3 +- 2 files changed, 45 insertions(+), 34 deletions(-) diff --git a/bin/pemdecompose b/bin/pemdecompose index 2e09fd0..21d9a7a 100755 --- a/bin/pemdecompose +++ b/bin/pemdecompose @@ -20,11 +20,11 @@ # -h, --help Show this help and exit # --verify Verify each cert is issued by the next cert in the # file (signature + issuer/subject DN match). -# -q, --quiet Suppress the per-file banner and inter-file blank -# line when multiple files are given. -# --no-color Disable ANSI colour. Colour is also disabled -# automatically when stdout is not a TTY or when the -# NO_COLOR environment variable is set. +# -q, --quiet Suppress the per-file banner and inter-file blank +# line when multiple files are given. +# --no-color, Disable ANSI colour. Colour is also disabled +# --no-colour automatically when stdout is not a TTY or when the +# NO_COLOR environment variable is set. # # Exit codes: # 0 success @@ -32,6 +32,7 @@ # 2 no certificates found in at least one input file # 3 openssl parse error on at least one input file # 4 --verify failed for at least one chain link +# 5 operational error (e.g. cannot create the temporary directory) set -euo pipefail @@ -135,7 +136,10 @@ trap 'cleanup; trap - HUP EXIT; kill -HUP $$' HUP ensure_tmpdir() { if [[ -z "$TMPDIR_" ]]; then - TMPDIR_=$(mktemp -d -t pemdecompose.XXXXXX) + if ! TMPDIR_=$(mktemp -d -t pemdecompose.XXXXXX); then + warn "failed to create temporary directory" + return 1 + fi fi } @@ -157,23 +161,27 @@ split_certs() { ' "$file" } -# Print the subject and issuer DNs from a single openssl invocation. -# openssl emits one "subject=..." line and one "issuer=..." line; the -# caller strips the prefixes. Returns nonzero if openssl can't parse the -# block (pipefail propagates openssl's status past the reading pipe). -cert_subject_issuer() { - openssl x509 -noout -subject -issuer -nameopt RFC2253 2> /dev/null -} - -# Print the top-level Signature Algorithm name (e.g. ecdsa-with-SHA384, -# sha256WithRSAEncryption, ED25519). Returns nonzero if openssl can't -# parse the block. -cert_sigalg() { - openssl x509 -noout -text 2> /dev/null | - awk '/Signature Algorithm:/ { - sub(/^.*Signature Algorithm:[[:space:]]*/, "") - print; exit - }' +# Print the subject DN, issuer DN, and signature algorithm from a single +# openssl invocation. Output format: three lines "subject=...", "issuer=...", +# "sigalg=...". Returns nonzero if openssl can't parse the block. +cert_info() { + local output + if ! output=$(openssl x509 -noout -subject -issuer -text -nameopt RFC2253 2> /dev/null); then + return 1 + fi + local subject="" issuer="" sigalg="" + local line + while IFS= read -r line; do + case "$line" in + subject=*) subject="${line#subject=}" ;; + issuer=*) issuer="${line#issuer=}" ;; + *Signature\ Algorithm:*) + sigalg="${line##*Signature Algorithm:}" + sigalg="${sigalg#"${sigalg%%[![:space:]]*}"}" + ;; + esac + done <<< "$output" + printf 'subject=%s\nissuer=%s\nsigalg=%s\n' "$subject" "$issuer" "$sigalg" } # --- per-file processing ------------------------------------------------ @@ -205,14 +213,14 @@ process_file() { return 2 fi - # Emit subject + issuer + sigalg per cert. Parse the subject and issuer - # once and cache them so the --verify loop can reuse them. + # Emit subject + issuer + sigalg per cert. Parse all three fields from + # one openssl invocation and cache subject/issuer for the --verify loop. local -a subjects=() issuers=() local i for ((i = 0; i < count; i++)); do local idx=$((i + 1)) - local si sub="" iss="" sigalg line - if ! si=$(printf '%s' "${blocks[$i]}" | cert_subject_issuer); then + local info sub="" iss="" sigalg="" line + if ! info=$(printf '%s' "${blocks[$i]}" | cert_info); then warn "openssl failed to parse certificate #$idx in $file" return 3 fi @@ -220,12 +228,9 @@ process_file() { case "$line" in subject=*) sub="${line#subject=}" ;; issuer=*) iss="${line#issuer=}" ;; + sigalg=*) sigalg="${line#sigalg=}" ;; esac - done <<< "$si" - if ! sigalg=$(printf '%s' "${blocks[$i]}" | cert_sigalg); then - warn "openssl failed to parse certificate #$idx in $file" - return 3 - fi + done <<< "$info" [[ -z "$sigalg" ]] && sigalg="" subjects[i]="$sub" issuers[i]="$iss" @@ -247,7 +252,12 @@ process_file() { return 0 fi - ensure_tmpdir + # Map a tmpdir-creation failure to the documented operational-error + # code rather than 1 (which the exit-code table reserves for usage + # errors). + if ! ensure_tmpdir; then + return 5 + fi local link_ok=1 # Write each block to a temp file once. diff --git a/docs/pemdecompose.md b/docs/pemdecompose.md index 64a3fce..0182fbe 100644 --- a/docs/pemdecompose.md +++ b/docs/pemdecompose.md @@ -24,7 +24,7 @@ pemdecompose --help | --- | --- | | `--verify` | Verify each cert is issued by the next cert in the file (signature + issuer/subject DN match) | | `-q`, `--quiet` | Suppress the per-file banner and inter-file blank line when multiple files are given | -| `--no-color` | Disable ANSI colour output (also disabled automatically when stdout is not a TTY or `NO_COLOR` is set) | +| `--no-color`, `--no-colour` | Disable ANSI colour output (also disabled automatically when stdout is not a TTY or `NO_COLOR` is set) | | `-h`, `--help` | Show help and exit | ## Exit codes @@ -36,6 +36,7 @@ pemdecompose --help | `2` | No certificates found in at least one input file | | `3` | `openssl` parse error on at least one certificate | | `4` | `--verify` failed for at least one chain link | +| `5` | Operational error (e.g. cannot create the temporary directory) | ## Examples From b04108fb8862a7a2a0971ec5d142aadb2358b0fc Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 23 Jul 2026 23:56:47 -0400 Subject: [PATCH 11/40] perf(bwrap): dedupe path & binary-mask resolution - finalize_sandbox_path deduplicates the dynamically-discovered bin directories against each other and against the base SANDBOX_PATH, so the same dir is not appended twice. - build_binary_masks canonicalizes and dedupes the search prefixes once (on merged-/usr systems /bin, /sbin, /usr/bin, /usr/sbin resolve to the same dir) and resolves every candidate with a single readlink -f fork instead of one realpath fork per binary. - build_dynamic_tool_mounts preloads ${_HOME}/bin into _MOUNTED_PREFIXES so later mount attempts under it are de-duplicated. Assisted-by: OpenCode:claude-fable-5 Assisted-by: ClaudeCode:claude-opus-4-8 --- lib/bwrap_sandbox_lib.sh | 86 ++++++++++++++++++++++++++-------------- 1 file changed, 57 insertions(+), 29 deletions(-) diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index 216b375..886052b 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -1366,9 +1366,23 @@ init_sandbox_path() { } # Append dynamically-discovered bin directories to SANDBOX_PATH. +# Deduplicates: the same dir can be recorded more than once (e.g. an npm bin +# dir added both as a tool's on-PATH dir and as the npm prefix's bin/), and a +# dir already present in the base SANDBOX_PATH need not be appended again. finalize_sandbox_path() { local _extra_dir + declare -A _path_seen=() + # Seed with the base PATH entries so we don't re-append them. + local _base_dir + local -a _base_dirs=() + IFS=':' read -ra _base_dirs <<< "${SANDBOX_PATH}" + for _base_dir in "${_base_dirs[@]}"; do + [[ -n "${_base_dir}" ]] && _path_seen["${_base_dir}"]=1 + done for _extra_dir in "${_EXTRA_PATH_DIRS[@]+"${_EXTRA_PATH_DIRS[@]}"}"; do + [[ -z "${_extra_dir}" ]] && continue + [[ -n "${_path_seen["${_extra_dir}"]:-}" ]] && continue + _path_seen["${_extra_dir}"]=1 SANDBOX_PATH="${SANDBOX_PATH}:${_extra_dir}" done } @@ -1486,38 +1500,51 @@ build_binary_masks() { # Namespace / sandbox escape nsenter unshare chroot ) - # On merged-/usr systems /bin, /sbin, /usr/bin, /usr/sbin all point to the - # same directory. Multiple prefixes therefore resolve to the same realpath, - # which would emit duplicate --ro-bind /dev/null flags. Track which - # real paths have already been masked and skip repeats. - declare -A _MASKED_REAL_SEEN=() - local _bin _prefix _candidate _real + # Canonicalize + dedupe the search prefixes once. On merged-/usr systems + # /bin, /sbin, /usr/bin, /usr/sbin resolve to the same directory; collapsing + # them here avoids re-checking (and re-resolving) the same candidate under + # several aliases. Search prefixes cover RHEL, Debian, and Arch layouts. + declare -A _prefix_seen=() + local -a _canon_prefixes=() + local _p _pc + for _p in /usr/bin /usr/sbin /usr/lib/openssh /usr/libexec/openssh /bin /sbin; do + [[ -d "${_p}" ]] || continue + _pc="$(readlink -f "${_p}" 2> /dev/null || printf '%s' "${_p}")" + [[ -n "${_prefix_seen["${_pc}"]:-}" ]] && continue + _prefix_seen["${_pc}"]=1 + _canon_prefixes+=("${_pc}") + done + [[ ${#_canon_prefixes[@]} -gt 0 ]] || return 0 + + # Collect every existing candidate binary across the unique prefixes. + # -e follows symlinks: true only if the full chain resolves to a real file, + # so dangling alternatives entries are skipped (correct — nothing to mask). + local -a _candidates=() + local _bin _candidate for _bin in "${_MASKED_BINS[@]}"; do - # Search well-known prefixes covering RHEL, Debian, and Arch layouts. - for _prefix in /usr/bin /usr/sbin /usr/lib/openssh /usr/libexec/openssh \ - /bin /sbin; do - _candidate="${_prefix}/${_bin}" - # -e follows symlinks: true only if the full chain resolves to a - # real file. Broken alternatives entries (dangling symlinks) are - # skipped, which is correct — there is no binary to mask. - [[ -e "${_candidate}" ]] || continue - - # On RHEL/Debian the binary is often a symlink: - # /usr/bin/nc -> /etc/alternatives/nc -> /usr/bin/ncat - # bwrap processes --ro-bind arguments in order. At the point this - # bind is applied, /etc/alternatives has not yet been mounted in the - # sandbox, so bwrap cannot resolve the intermediate symlink and - # reports "Can't create file at /usr/bin/nc". - # - # Fix: resolve to the canonical (symlink-free) path on the host and - # use *that* as the bwrap destination. The real file is always - # directly accessible under /usr once --ro-bind /usr /usr is applied. - _real="$(realpath "${_candidate}")" - [[ -n "${_MASKED_REAL_SEEN[${_real}]:-}" ]] && continue - _MASKED_REAL_SEEN["${_real}"]=1 - BWRAP_ARGS+=(--ro-bind /dev/null "${_real}") + for _pc in "${_canon_prefixes[@]}"; do + _candidate="${_pc}/${_bin}" + [[ -e "${_candidate}" ]] && _candidates+=("${_candidate}") done done + [[ ${#_candidates[@]} -gt 0 ]] || return 0 + + # On RHEL/Debian the binary is often a symlink: + # /usr/bin/nc -> /etc/alternatives/nc -> /usr/bin/ncat + # bwrap processes --ro-bind arguments in order; at the point the bind is + # applied /etc/alternatives is not yet mounted, so bwrap cannot resolve the + # intermediate symlink ("Can't create file at /usr/bin/nc"). Resolve every + # candidate to its canonical, symlink-free path (always reachable under /usr + # once --ro-bind /usr /usr is applied) and mask *that*. A single readlink + # -f fork resolves all candidates at once (was one realpath fork each). + declare -A _masked_real_seen=() + local _real + while IFS= read -r _real; do + [[ -n "${_real}" ]] || continue + [[ -n "${_masked_real_seen["${_real}"]:-}" ]] && continue + _masked_real_seen["${_real}"]=1 + BWRAP_ARGS+=(--ro-bind /dev/null "${_real}") + done < <(readlink -f "${_candidates[@]}") } build_proc_dev_tmp() { @@ -2075,6 +2102,7 @@ build_dynamic_tool_mounts() { /usr /bin /sbin /lib /lib64 \ /proc /dev /tmp \ "${PIXI_HOME_DIR}" \ + "${_HOME}/bin" \ /nsls2/software/bin; do [[ -e "${_preloaded}" ]] && _MOUNTED_PREFIXES["${_preloaded}"]=1 done From 8312f8051cc49b94a40a03c62f8a5ce49b1c5701 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 20 Oct 2026 12:34:07 -0400 Subject: [PATCH 12/40] fix(bwrap): canonicalize path-safety for symlinked homes Candidate mount paths are canonicalized before the safety checks, but the $HOME-or-ancestor test compared them only against the logical $HOME. On hosts where home is reached through a symlink (NFS/automount), a canonical candidate such as the real home directory slipped through (e.g. PIXI_HOME= was accepted and the whole home tree mounted). - resolve_common_paths records _HOME_CANON (readlink -f $HOME), and _bwrap_path_blocked checks the ancestor-of-home condition against both the logical and the canonical home. The blocked list itself was already matched at symlink targets by _bwrap_load_blocked_prefixes. - _PWD is the physical working directory (pwd -P), and build_workdir_mount canonicalizes _BIND_DIR before _check_path_safe. A non-git working directory reached through a symlink into ~/.ssh was previously bound read-write; it is now refused. PWD in the sandbox now agrees with the physical bind (git rev-parse already returned a physical root). - _mount_ro_dir_if_needed judges safety on the canonical directory while still mounting the logical one, so a symlinked on-PATH tool directory (e.g. ~/bin2 -> ~/.ssh) is refused for every caller. - _emit_home_intermediate_dirs recognises paths under the canonical home. - USER/LOGNAME come from _USER (${USER:-$(bwrap_account_name)}), so the launchers no longer fail under `set -u` when $USER is unset (cron/CI). Assisted-by: OpenCode:claude-fable-5 Assisted-by: ClaudeCode:claude-opus-4-8 Assisted-by: ClaudeCode:claude-fable-5 Assisted-by: ClaudeCode:claude-sonnet-5 Assisted-by: OpenCode:claude-opus-5-5 --- lib/bwrap_sandbox_lib.sh | 85 ++++++++++++++++++++++++++++------------ 1 file changed, 61 insertions(+), 24 deletions(-) diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index 886052b..eab69de 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -152,6 +152,17 @@ _BWRAP_XDG_GIT_HAS_FILES=0 _BIND_DIR="" _GIT_ROOT="" +# Common paths (set by resolve_common_paths). _HOME is the logical $HOME; +# _HOME_CANON is its readlink -f form. They differ when home is reached +# through a symlink (NFS/automount), and the $HOME-or-ancestor check must +# consult both because candidate paths are canonicalized before it runs. +# _PWD is the physical (pwd -P) working directory. _USER is $USER, which can +# be unset under `set -u` (cron/CI), with a fallback to bwrap_account_name. +_HOME="" +_HOME_CANON="" +_PWD="" +_USER="" + # ═══════════════════════════════════════════════════════════════════ # Shared helper functions # ═══════════════════════════════════════════════════════════════════ @@ -602,9 +613,18 @@ _get_npm_prefix() { # file bind, or a read-write directory bind point). _emit_home_intermediate_dirs() { local dir="$1" mode="${2:-ancestors}" - [[ "${dir}" == "${_HOME}"/* ]] || return 0 - local _rel="${dir#"${_HOME}"/}" - local _accum="${_HOME}" + # A canonicalized bind target can sit under _HOME_CANON rather than the + # logical _HOME when home is symlinked; match against both. + local _home_base + if [[ "${dir}" == "${_HOME}"/* ]]; then + _home_base="${_HOME}" + elif [[ -n "${_HOME_CANON}" && "${dir}" == "${_HOME_CANON}"/* ]]; then + _home_base="${_HOME_CANON}" + else + return 0 + fi + local _rel="${dir#"${_home_base}"/}" + local _accum="${_home_base}" local _parts _i _last IFS='/' read -ra _parts <<< "${_rel}" if [[ "${mode}" == "inclusive" ]]; then @@ -644,10 +664,14 @@ _mount_ro_dir_if_needed() { [[ -z "${check}" ]] && check="/" done - # Safety check before emitting the mount. DIR is already a real - # directory path (callers pass dirname() of a resolved binary or a - # known path), so no additional readlink -f is needed. - _check_path_safe "${dir}" "dynamic mount" + # Safety check before emitting the mount, judged on the canonical target: + # DIR can be a symlinked on-PATH directory (e.g. ~/bin2 -> ~/.ssh) and + # bwrap follows the symlink when binding. DIR itself is still the mount + # point, so the tool stays reachable at the path it is invoked by. + local _dir_canon + _dir_canon="$(readlink -f -- "${dir}" 2> /dev/null || true)" + [[ -n "${_dir_canon}" ]] || _dir_canon="${dir}" + _check_path_safe "${_dir_canon}" "dynamic mount" # Create intermediate --dir entries for paths under $HOME (the # tmpfs $HOME has no subdirectories yet). DIR is the bind mount point, @@ -953,8 +977,11 @@ _bwrap_path_blocked() { # 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 + # "_is_prefix_of canon _HOME" means: canon is a prefix of _HOME. The + # canonical home is checked too: with a symlinked home (/home/u -> + # /nfs/home/u) a canonical candidate /nfs/home/u is not a prefix of _HOME. + if _is_prefix_of "${canon}" "${_HOME}" || + { [[ -n "${_HOME_CANON}" ]] && _is_prefix_of "${canon}" "${_HOME_CANON}"; }; then _BWRAP_BLOCKED_KIND="home" return 0 fi @@ -1306,7 +1333,14 @@ build_extra_path_mounts() { resolve_common_paths() { _HOME="${HOME}" - _PWD="${PWD}" + _HOME_CANON="$(readlink -f -- "${HOME}" 2> /dev/null || true)" + [[ -n "${_HOME_CANON}" ]] || _HOME_CANON="${HOME}" + # Physical working directory, so PWD in the sandbox agrees with the + # canonical working-directory bind (git rev-parse also returns physical + # paths). + _PWD="$(pwd -P 2> /dev/null || true)" + [[ -n "${_PWD}" ]] || _PWD="${PWD}" + _USER="${USER:-$(bwrap_account_name)}" # XDG defaults XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-${_HOME}/.config}" @@ -1676,20 +1710,23 @@ build_workdir_mount() { _BIND_DIR="${_GIT_ROOT}" fi + # Canonicalize before the safety checks. pwd -P and git rev-parse already + # return physical paths, but resolving once more guarantees a symlinked + # working directory (e.g. a symlink into ~/.ssh) cannot reach + # _check_path_safe in its logical form. + local _bind_canon + _bind_canon="$(readlink -f -- "${_BIND_DIR}" 2> /dev/null || true)" + [[ -n "${_bind_canon}" ]] && _BIND_DIR="${_bind_canon}" + # Guard: reject dangerous working / git-root directories via the same # rules applied to --ro-path / --rw-path. This catches $HOME itself, # ancestors of $HOME (/home, /), /root, ~/.ssh, and all other blocked - # prefixes. _BIND_DIR comes from git rev-parse or $PWD — both are - # already canonical paths, so no readlink -f step is needed here. + # prefixes. _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" + # ~/.config/gh) is refused too: the whole tree is bound read-write. + _check_dir_contains_blocked "${_BIND_DIR}" "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 @@ -2155,8 +2192,8 @@ build_env_vars() { # Always set BWRAP_ARGS+=( --setenv HOME "${_HOME}" - --setenv USER "${USER}" - --setenv LOGNAME "${USER}" + --setenv USER "${_USER}" + --setenv LOGNAME "${_USER}" --setenv SHELL "${SANDBOX_SHELL}" --setenv PATH "${SANDBOX_PATH}" --setenv PWD "${_PWD}" @@ -2290,8 +2327,8 @@ print_dry_run() { # exec -i -- <_TOOL_CMD> printf "exec %s -i \\\\\n" "${_ENV_BIN}" printf " HOME=%q \\\\\n" "${_HOME}" - printf " USER=%q \\\\\n" "${USER}" - printf " LOGNAME=%q \\\\\n" "${USER}" + printf " USER=%q \\\\\n" "${_USER}" + printf " LOGNAME=%q \\\\\n" "${_USER}" printf " PATH=%q \\\\\n" "/usr/bin:/bin" printf " LANG=%q \\\\\n" "${LANG:-C.UTF-8}" printf " LC_CTYPE=%q \\\\\n" "${LC_CTYPE:-C.UTF-8}" @@ -2341,8 +2378,8 @@ launch_sandbox() { exec "${_ENV_BIN}" -i \ HOME="${_HOME}" \ - USER="${USER}" \ - LOGNAME="${USER}" \ + USER="${_USER}" \ + LOGNAME="${_USER}" \ PATH="/usr/bin:/bin" \ LANG="${LANG:-C.UTF-8}" \ LC_CTYPE="${LC_CTYPE:-C.UTF-8}" \ From 38638572f591d8a8c100d4c543e6210d5176cbb9 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 20 Oct 2026 12:39:21 -0400 Subject: [PATCH 13/40] fix(bwrap): harden path & git-config input handling - Tilde expansion is centralized in _expand_leading_tilde, which handles ~, ~/path and ~user/path (via getent), for git include paths and --ro-path/--rw-path. The old ${v/#\~/${_HOME}} substitution corrupted "~alice/foo" into "${HOME}alice/foo"; an unresolvable ~user is now skipped (git include) or rejected (--ro-path/--rw-path). - parse_git_includes tracks the current section and only honours `path =` inside [include] and [includeIf "..."] (case-insensitive, quoted subsections, key on the header line), so a `path =` key in an unrelated section such as [difftool "x"] is no longer recorded as an include -- which mounted it, or aborted the launcher when it pointed into a blocked location. - _resolve_npm_prefix replaces the echo-based _get_npm_prefix. It was always called through $(...), so its cache was set in a subshell and lost; npm ran on every lookup and wrote ~/.npm/_logs on the host even under --dry-run. The cache now persists and npm_config_cache points at a throwaway directory for the lookup. - The on-PATH directory of an npm-symlinked tool is mounted as well as the npm prefix, so the path the tool is invoked by exists in the sandbox. Assisted-by: OpenCode:claude-fable-5 Assisted-by: ClaudeCode:claude-opus-4-8 Assisted-by: OpenCode:claude-opus-5-5 --- lib/bwrap_sandbox_lib.sh | 110 +++++++++++++++++++++++++++++++++------ 1 file changed, 93 insertions(+), 17 deletions(-) diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index eab69de..264119f 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -579,23 +579,68 @@ bwrap_resolve_tool() { exit 1 } +# _expand_leading_tilde VALUE +# Print VALUE with only a LEADING tilde expanded: +# "~" / "~/path" -> $HOME / $HOME/path +# "~user" / "~user/p" -> that user's home (getent passwd) [+ /p] +# anything else -> VALUE unchanged +# Returns 1 (printing nothing) when a ~user reference cannot be resolved. +# A blind ${VALUE/#\~/${_HOME}} substitution turns "~alice/foo" into +# "${HOME}alice/foo", silently corrupting the path. +_expand_leading_tilde() { + local value="$1" _rest _user _subpath="" _uhome + # The patterns match a literal leading tilde (SC2088 false positive). + # shellcheck disable=SC2088 + case "${value}" in + "~") + printf '%s' "${_HOME}" + ;; + "~/"*) + printf '%s' "${_HOME}/${value#\~/}" + ;; + "~"*) + _rest="${value#\~}" + _user="${_rest%%/*}" + [[ "${_rest}" == */* ]] && _subpath="/${_rest#*/}" + _uhome="$(getent passwd "${_user}" 2> /dev/null | cut -d: -f6)" + [[ -n "${_uhome}" ]] || return 1 + printf '%s' "${_uhome}${_subpath}" + ;; + *) + printf '%s' "${value}" + ;; + esac +} + # ── Dynamic mount helpers ──────────────────────────────────────── # _MOUNTED_PREFIXES: tracks directory trees already covered by a bwrap # bind so we never emit duplicate (or redundant sub-path) mounts. # Keys are canonical paths; value is always 1. # Pre-populated with unconditional mounts in build_dynamic_tool_mounts. -# _get_npm_prefix -# Echo the npm global prefix (`npm prefix -g`), caching the result so the -# subprocess is forked at most once per run even when several symlinked -# tools are resolved. Echoes the empty string if npm is not installed or -# the lookup fails. -_get_npm_prefix() { - if [[ "${_NPM_PREFIX_RESOLVED}" -eq 0 ]]; then +# _resolve_npm_prefix +# Set _NPM_PREFIX to the npm global prefix (`npm prefix -g`), forking npm +# at most once per run; empty if npm is absent or the lookup fails. +# Must NOT be called inside $(...): the cache globals would be set in the +# subshell and lost, so npm would run again on every lookup. Callers invoke +# it directly and then read ${_NPM_PREFIX}. +# `npm prefix -g` writes a log under $npm_config_cache/_logs (default +# ~/.npm/_logs); the cache is pointed at a throwaway directory so no run, +# including --dry-run, leaves that behind on the host. +_resolve_npm_prefix() { + [[ "${_NPM_PREFIX_RESOLVED}" -eq 1 ]] && return 0 + _NPM_PREFIX_RESOLVED=1 + _NPM_PREFIX="" + command -v npm > /dev/null 2>&1 || return 0 + + local _npm_tmp + _npm_tmp="$(mktemp -d 2> /dev/null || true)" + if [[ -n "${_npm_tmp}" ]]; then + _NPM_PREFIX="$(npm_config_cache="${_npm_tmp}" npm prefix -g 2> /dev/null || true)" + rm -rf -- "${_npm_tmp}" + else _NPM_PREFIX="$(npm prefix -g 2> /dev/null || true)" - _NPM_PREFIX_RESOLVED=1 fi - printf '%s' "${_NPM_PREFIX}" } # _emit_home_intermediate_dirs DIR [MODE] @@ -704,9 +749,14 @@ resolve_and_mount_tool() { if [[ -L "${bin_path}" ]]; then real_path="$(readlink -f "${bin_path}")" - npm_prefix="$(_get_npm_prefix)" + _resolve_npm_prefix + npm_prefix="${_NPM_PREFIX}" if [[ -n "${npm_prefix}" ]] && [[ "${real_path}" == "${npm_prefix}"/* ]]; then - # npm-installed: mount the whole prefix so lib/node_modules is reachable. + # npm-installed: mount the whole prefix so lib/node_modules is + # reachable. Also mount the on-PATH directory holding the symlink + # (e.g. ~/.local/bin/claude -> npm prefix): otherwise the path the + # tool is invoked by does not exist inside the sandbox. + _mount_ro_dir_if_needed "${cmd_dir}" _mount_npm_global_prefix "${npm_prefix}" return 0 fi @@ -729,7 +779,8 @@ resolve_and_mount_tool() { _mount_npm_global_prefix() { local npm_prefix="${1:-}" if [[ -z "${npm_prefix}" ]]; then - npm_prefix="$(_get_npm_prefix)" + _resolve_npm_prefix + npm_prefix="${_NPM_PREFIX}" fi [[ -n "${npm_prefix}" ]] && [[ -d "${npm_prefix}" ]] || return 0 @@ -815,7 +866,26 @@ parse_git_includes() { # Extract path values from [include] and [includeIf "..."] sections. # Matches lines like: path = /some/path or path = ~/relative # Pure bash — no grep/sed/xargs subprocesses. + # + # `path` is only an include directive inside an include section: git's + # parser is section-scoped, and a `path =` key elsewhere (for example + # [difftool "x"] path = ...) must not be mounted, nor abort the launcher + # when it points into a blocked location. Section names are + # case-insensitive; a key may follow the header on the same line. + local _section_re='^[[:space:]]*\[[[:space:]]*([A-Za-z0-9.-]+)([[:space:]]+"([^"\\]|\\.)*")?[[:space:]]*\](.*)$' + local _section_name in_include=0 while IFS= read -r line || [[ -n "${line}" ]]; do + if [[ "${line}" =~ ${_section_re} ]]; then + _section_name="${BASH_REMATCH[1],,}" + line="${BASH_REMATCH[4]}" + if [[ "${_section_name}" == "include" || "${_section_name}" == "includeif" ]]; then + in_include=1 + else + in_include=0 + fi + fi + [[ "${in_include}" -eq 1 ]] || continue + # 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. @@ -824,8 +894,9 @@ parse_git_includes() { local path_val="${_BWRAP_GIT_VALUE}" [[ -z "${path_val}" ]] && continue - # Resolve ~/ prefix - path_val="${path_val/#\~/${_HOME}}" + # Expand a leading ~ or ~user (git does both); skip the include when + # ~user cannot be resolved rather than corrupting the path. + path_val="$(_expand_leading_tilde "${path_val}")" || continue # Resolve relative paths (relative to the config file's directory) if [[ "${path_val}" != /* ]]; then @@ -1243,15 +1314,20 @@ _bwrap_include_lexical_path() { # On failure: prints an error to stderr and exits 1. # # Steps: -# 1. Expand leading ~/ then resolve to canonical path via readlink -f. +# 1. Expand a leading ~ or ~user, then resolve to a canonical path via +# readlink -f. # 2. Verify the path exists on the host. # 3. Delegate safety checks to _check_path_safe. validate_extra_path() { local raw_path="$1" local mode="$2" - # Expand a leading ~/ to $HOME (readlink -f won't do this for us). - local expanded_path="${raw_path/#\~/${_HOME}}" + # Expand a leading ~ or ~user (readlink -f won't do this for us). + local expanded_path + if ! expanded_path="$(_expand_leading_tilde "${raw_path}")"; then + echo "Error: --${mode}-path '${raw_path}': cannot resolve the '~user' home directory." >&2 + exit 1 + fi # Resolve to canonical path (follows symlinks, removes . and ..). local canon From 391a7c48b00891389643ba11ca95c43fb0a4297b Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 20 Oct 2026 12:43:02 -0400 Subject: [PATCH 14/40] feat(bwrap): allow nesting --ro-path under --rw-path to protect subdirs build_extra_path_mounts emitted every --ro-bind before every --bind, so `--rw-path DIR --ro-path DIR/sub` left DIR/sub silently WRITABLE (the later parent --bind covered it). Read-only directories also went through the _MOUNTED_PREFIXES dedup, which silently dropped any --ro-path under a pre-registered prefix such as /tmp (a tmpfs in the sandbox, so the path was simply absent) and a read-only subdirectory of the working directory. Emit the user paths ancestors-first across both modes instead: bwrap applies binds in order and the last one covering a mount point wins, so a nested child of either mode overrides its parent. Both `--rw-path DIR --ro-path DIR/sub` and the reverse (read-only tree with one writable subdir) now work. - Validate every path up front, propagating validate_extra_path's exit from the command substitution explicitly; reject a path given as both --ro-path and --rw-path. - Sort the combined set with LC_ALL=C (an ancestor is a byte prefix of its descendants) and emit user paths directly, bypassing the dedup. - Skip a path's intermediate --dir entries when an ancestor is already bind-mounted (a --dir there would clobber the bind); prior bind destinations are collected by walking BWRAP_ARGS. - _bwrap_flag_arity sets _BWRAP_FLAG_ARITY instead of echoing, so the BWRAP_ARGS walks here and in print_dry_run do not fork per argument. - build_workdir_mount registers the working-directory bind in _MOUNTED_PREFIXES, so a tool resolved from inside the repo (node_modules/.bin, .pixi/envs/*/bin) no longer gets an --ro-bind on top of the read-write bind (EROFS on npm/pixi install). - Document the nesting in every launcher's help and docs/bw-wrappers.md. Assisted-by: ClaudeCode:claude-opus-4-8 Assisted-by: OpenCode:claude-fable-5 Assisted-by: ClaudeCode:claude-fable-5 Assisted-by: ClaudeCode:claude-sonnet-5 Assisted-by: OpenCode:claude-opus-5-5 --- bin/bwclaude | 5 ++ bin/bwcodex | 5 ++ bin/bwcopilot | 5 ++ bin/bwopencode | 5 ++ bin/bwopencode2 | 5 ++ docs/bw-wrappers.md | 6 ++ lib/bwrap_sandbox_lib.sh | 150 +++++++++++++++++++++++++++------------ 7 files changed, 136 insertions(+), 45 deletions(-) diff --git a/bin/bwclaude b/bin/bwclaude index ffd9281..11b43a3 100755 --- a/bin/bwclaude +++ b/bin/bwclaude @@ -69,6 +69,8 @@ # /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. +# A nested --ro-path/--rw-path overrides the one it sits under, +# so `--rw-path DIR --ro-path DIR/sub` protects DIR/sub. set -euo pipefail @@ -188,6 +190,9 @@ Wrapper options (consumed before claude sees the command line): 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. + --ro-path/--rw-path may nest: a nested path overrides the one + it sits under, so `--rw-path DIR --ro-path DIR/sub` keeps DIR + writable but DIR/sub read-only. --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: diff --git a/bin/bwcodex b/bin/bwcodex index dbc1a7e..d8364a2 100755 --- a/bin/bwcodex +++ b/bin/bwcodex @@ -92,6 +92,8 @@ # /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. +# A nested --ro-path/--rw-path overrides the one it sits under, +# so `--rw-path DIR --ro-path DIR/sub` protects DIR/sub. set -euo pipefail @@ -224,6 +226,9 @@ Wrapper options (consumed before codex sees the command line): 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. + --ro-path/--rw-path may nest: a nested path overrides the one + it sits under, so `--rw-path DIR --ro-path DIR/sub` keeps DIR + writable but DIR/sub read-only. --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: diff --git a/bin/bwcopilot b/bin/bwcopilot index c7f3fe7..38f0290 100755 --- a/bin/bwcopilot +++ b/bin/bwcopilot @@ -63,6 +63,8 @@ # /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. +# A nested --ro-path/--rw-path overrides the one it sits under, +# so `--rw-path DIR --ro-path DIR/sub` protects DIR/sub. set -euo pipefail @@ -174,6 +176,9 @@ Wrapper options (consumed before copilot sees the command line): 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. + --ro-path/--rw-path may nest: a nested path overrides the one + it sits under, so `--rw-path DIR --ro-path DIR/sub` keeps DIR + writable but DIR/sub read-only. --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: diff --git a/bin/bwopencode b/bin/bwopencode index 4acad60..9d9743b 100755 --- a/bin/bwopencode +++ b/bin/bwopencode @@ -59,6 +59,8 @@ # /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. +# A nested --ro-path/--rw-path overrides the one it sits under, +# so `--rw-path DIR --ro-path DIR/sub` protects DIR/sub. set -euo pipefail @@ -170,6 +172,9 @@ Wrapper options (consumed before opencode sees the command line): 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. + --ro-path/--rw-path may nest: a nested path overrides the one + it sits under, so `--rw-path DIR --ro-path DIR/sub` keeps DIR + writable but DIR/sub read-only. --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: diff --git a/bin/bwopencode2 b/bin/bwopencode2 index 3a6aade..45fe3e5 100755 --- a/bin/bwopencode2 +++ b/bin/bwopencode2 @@ -76,6 +76,8 @@ # /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. +# A nested --ro-path/--rw-path overrides the one it sits under, +# so `--rw-path DIR --ro-path DIR/sub` protects DIR/sub. # --github-tokens Forward any GH_TOKEN_* environment variables into the sandbox. # # There is no --init-auth: OpenCode 2 stores credentials in its database @@ -183,6 +185,9 @@ Wrapper options (consumed before opencode2 sees the command line): 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. + --ro-path/--rw-path may nest: a nested path overrides the one + it sits under, so `--rw-path DIR --ro-path DIR/sub` keeps DIR + writable but DIR/sub read-only. --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: diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index 295ce2b..c8d0327 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -60,6 +60,12 @@ Every `bw*` wrapper accepts these options: | `--ro-path PATH` | Read-only mount an existing path; its canonical target is mounted at the same canonical destination. Use only narrow paths. | | `--rw-path PATH` | Read-write mount an existing path; its canonical target is mounted at the same canonical destination. Use only narrow paths. | +`--ro-path` and `--rw-path` may be nested: a path nested under another +overrides it, so `--rw-path DIR --ro-path DIR/sub` mounts `DIR` read-write but +keeps `DIR/sub` read-only (the reverse, a read-only tree with one writable +subdirectory, works too). A path given as both `--ro-path` and `--rw-path` is +rejected. + Tool-specific options are listed in each wrapper's section below. ## Account check diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index 264119f..b5c9687 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -1355,53 +1355,104 @@ validate_extra_path() { # Mount all paths accumulated in EXTRA_RO_PATHS and EXTRA_RW_PATHS. # Each path is validated by validate_extra_path before mounting. # -# Read-only paths: use _mount_ro_dir_if_needed for directories -# (deduplication via _MOUNTED_PREFIXES); for files, emit --ro-bind -# directly (no dedup needed — an individual file bind never subsumes -# a directory tree). +# Binds are emitted ANCESTORS-FIRST across both modes: bwrap applies binds +# in order and the last one covering a mount point wins, so a path nested +# under another is emitted after its parent and overrides it. This lets +# --rw-path DIR --ro-path DIR/sub +# keep DIR writable but DIR/sub read-only (and the reverse: a read-only +# tree with one writable subdirectory). A path given as both --ro-path and +# --rw-path is rejected. # -# Read-write paths: emit --bind directly for both files and directories. -# For paths under $HOME, intermediate --dir entries are created first -# so the bind mount point exists in the tmpfs. +# User paths are emitted DIRECTLY, not through the _MOUNTED_PREFIXES dedup +# used for automatic mounts: that dedup would skip a child "covered" by a +# parent bind (defeating the nesting, or a read-only subdirectory of the +# read-write working directory), and would silently drop any path under a +# pre-registered prefix such as /tmp, which is a tmpfs in the sandbox. # -# Must be called AFTER build_dynamic_tool_mounts (which pre-populates -# _MOUNTED_PREFIXES) and AFTER build_home_tmpfs (which creates the -# $HOME tmpfs that intermediate --dir entries populate). +# Must be called AFTER the automatic mount steps (working directory, tools, +# system, home) so an explicit path overrides them, and AFTER +# build_home_tmpfs (which creates the $HOME tmpfs that intermediate --dir +# entries populate). build_extra_path_mounts() { - local _raw_path _canon _target + [[ ${#EXTRA_RO_PATHS[@]} -eq 0 && ${#EXTRA_RW_PATHS[@]} -eq 0 ]] && return 0 - # ── Read-only extra paths ──────────────────────────────────── - for _raw_path in "${EXTRA_RO_PATHS[@]+"${EXTRA_RO_PATHS[@]}"}"; do - _canon="$(validate_extra_path "${_raw_path}" "ro")" + local _raw_path _canon _mode + local -A _mode_of=() - if [[ -d "${_canon}" ]]; then - # Directories: go through the dedup helper. - _mount_ro_dir_if_needed "${_canon}" - else - # Files: --ro-bind directly. Pre-create the file's parent - # directory chain if it lives under $HOME (the tmpfs has no - # subdirs yet); the file itself is the bind mount point. - _emit_home_intermediate_dirs "${_canon%/*}" inclusive - BWRAP_ARGS+=(--ro-bind "${_canon}" "${_canon}") + # Validate everything up front. validate_extra_path runs in a command + # substitution, where its `exit 1` only leaves the subshell, so the + # failure is propagated explicitly. + for _raw_path in "${EXTRA_RO_PATHS[@]+"${EXTRA_RO_PATHS[@]}"}"; do + _canon="$(validate_extra_path "${_raw_path}" "ro")" || exit 1 + _mode_of["${_canon}"]="ro" + done + for _raw_path in "${EXTRA_RW_PATHS[@]+"${EXTRA_RW_PATHS[@]}"}"; do + _canon="$(validate_extra_path "${_raw_path}" "rw")" || exit 1 + if [[ "${_mode_of["${_canon}"]:-}" == "ro" ]]; then + echo "Error: '${_canon}' was given as both --ro-path and --rw-path." >&2 + exit 1 fi + _mode_of["${_canon}"]="rw" done - # ── Read-write extra paths ─────────────────────────────────── - for _raw_path in "${EXTRA_RW_PATHS[@]+"${EXTRA_RW_PATHS[@]}"}"; do - _canon="$(validate_extra_path "${_raw_path}" "rw")" - - # Pre-create the intermediate --dir chain when the path is under - # $HOME. For a directory the chain includes the target itself (the - # subsequent --bind lands on top of that --dir); for a file the - # chain stops at the parent directory. - if [[ -d "${_canon}" ]]; then - _target="${_canon}" - else - _target="${_canon%/*}" + # Ancestors first: an ancestor is a byte prefix of its descendants, so a + # plain LC_ALL=C sort orders it before them. + local -a _sorted=() + mapfile -t _sorted < <(printf '%s\n' "${!_mode_of[@]}" | LC_ALL=C sort) + + # Destinations of every bind emitted so far. A path with a bound + # ancestor needs no intermediate --dir entries (the ancestor's host tree + # already contains it), and a --dir there would clobber that bind. + local -A _bound_dests=() + local _bi=0 _bflag + while [[ ${_bi} -lt ${#BWRAP_ARGS[@]} ]]; do + _bflag="${BWRAP_ARGS[${_bi}]}" + _bwrap_flag_arity "${_bflag}" + case "${_bflag}" in + --bind | --bind-try | --ro-bind | --ro-bind-try | --dev-bind | --dev-bind-try) + _bound_dests["${BWRAP_ARGS[$((_bi + 2))]}"]=1 + ;; + esac + _bi=$((_bi + 1 + _BWRAP_FLAG_ARITY)) + done + + local _anc _has_bound_ancestor + for _canon in "${_sorted[@]}"; do + _mode="${_mode_of["${_canon}"]}" + + _has_bound_ancestor=0 + _anc="${_canon%/*}" + [[ -n "${_anc}" ]] || _anc="/" + while :; do + if [[ -n "${_bound_dests["${_anc}"]:-}" ]]; then + _has_bound_ancestor=1 + break + fi + [[ "${_anc}" == "/" ]] && break + _anc="${_anc%/*}" + [[ -n "${_anc}" ]] || _anc="/" + done + + # Pre-create the intermediate --dir chain under $HOME. A read-write + # directory includes the target itself (the --bind lands on top of + # that --dir); a read-only directory is its own mount point; a file + # needs its parent chain. + if [[ ${_has_bound_ancestor} -eq 0 ]]; then + if [[ -d "${_canon}" && "${_mode}" == "rw" ]]; then + _emit_home_intermediate_dirs "${_canon}" inclusive + elif [[ -d "${_canon}" ]]; then + _emit_home_intermediate_dirs "${_canon}" + else + _emit_home_intermediate_dirs "${_canon%/*}" inclusive + fi fi - _emit_home_intermediate_dirs "${_target}" inclusive - BWRAP_ARGS+=(--bind "${_canon}" "${_canon}") + if [[ "${_mode}" == "ro" ]]; then + BWRAP_ARGS+=(--ro-bind "${_canon}" "${_canon}") + else + BWRAP_ARGS+=(--bind "${_canon}" "${_canon}") + fi + _bound_dests["${_canon}"]=1 done } @@ -1811,6 +1862,11 @@ build_workdir_mount() { BWRAP_ARGS+=( --bind "${_BIND_DIR}" "${_BIND_DIR}" ) + # Register the read-write tree so a tool resolved from inside it (e.g. + # node_modules/.bin or .pixi/envs/*/bin on PATH) does not get an --ro-bind + # on top, which would make that subtree read-only (EROFS on install). + # build_workdir_mount runs before build_dynamic_tool_mounts everywhere. + _MOUNTED_PREFIXES["${_BIND_DIR}"]=1 } # ── Git config (read-only) ────────────────────────────────────── @@ -2356,28 +2412,31 @@ resolve_env_bin() { } # _bwrap_flag_arity FLAG -# Echo the number of value arguments that bwrap flag FLAG consumes (0, 1, -# or 2). Used by print_dry_run to group flags with their values WITHOUT +# Set _BWRAP_FLAG_ARITY to the number of value arguments that bwrap flag +# FLAG consumes (0, 1, or 2). Used to walk BWRAP_ARGS (print_dry_run, +# build_extra_path_mounts) grouping flags with their values WITHOUT # guessing from the value's leading characters — a path value that happens # to start with "--" (e.g. a user-supplied --ro-path) must not be mistaken # for a new flag. Only the flags this library actually emits are listed; -# the default of 1 is a safe fallback for any single-value flag. +# the default of 1 is a safe fallback for any single-value flag. Sets a +# global rather than echoing so the walks do not fork once per argument. +_BWRAP_FLAG_ARITY=1 _bwrap_flag_arity() { case "$1" in --unshare-* | --share-net | --die-with-parent | --new-session | \ --clearenv | --as-pid-1) - echo 0 + _BWRAP_FLAG_ARITY=0 ;; --ro-bind | --ro-bind-try | --bind | --bind-try | --dev-bind | \ --dev-bind-try | --symlink | --setenv | \ --file | --bind-data | --ro-bind-data) - echo 2 + _BWRAP_FLAG_ARITY=2 ;; --dir | --tmpfs | --proc | --dev | --unsetenv | --chdir | --hostname) - echo 1 + _BWRAP_FLAG_ARITY=1 ;; *) - echo 1 + _BWRAP_FLAG_ARITY=1 ;; esac } @@ -2416,7 +2475,8 @@ print_dry_run() { local i=0 arg _arity _j while [[ $i -lt ${#BWRAP_ARGS[@]} ]]; do arg="${BWRAP_ARGS[$i]}" - _arity="$(_bwrap_flag_arity "${arg}")" + _bwrap_flag_arity "${arg}" + _arity="${_BWRAP_FLAG_ARITY}" printf " %s" "${arg}" i=$((i + 1)) for ((_j = 0; _j < _arity && i < ${#BWRAP_ARGS[@]}; _j++)); do From d2243838325878142893a83aaa3efccc6ba0699b Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 20 Oct 2026 12:44:47 -0400 Subject: [PATCH 15/40] fix(bwrap): shell-quote --dry-run output print_dry_run wrapped bwrap argument values in bare single quotes ('%s'), so a path containing a single quote produced a command that could not be pasted back into a shell. Quote flags and values with %q instead; the REDACTED placeholder for secret-looking --setenv values is unchanged. Verified by re-parsing the printed command with eval: for all five launchers the argument vectors are identical to the previous output, and a --ro-path containing ', " and spaces now round-trips exactly. Assisted-by: ClaudeCode:claude-fable-5 Assisted-by: ClaudeCode:claude-sonnet-5 Assisted-by: OpenCode:claude-opus-5-5 --- lib/bwrap_sandbox_lib.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index b5c9687..6ae19e8 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -2472,12 +2472,14 @@ print_dry_run() { # Print bwrap arguments one flag-plus-values per line. Each flag's # value count comes from _bwrap_flag_arity, so a value beginning with # "--" is grouped with its flag instead of being misread as a new flag. + # Values are %q-quoted so a path containing a quote, space or other + # special character still yields a valid, pasteable command. local i=0 arg _arity _j while [[ $i -lt ${#BWRAP_ARGS[@]} ]]; do arg="${BWRAP_ARGS[$i]}" _bwrap_flag_arity "${arg}" _arity="${_BWRAP_FLAG_ARITY}" - printf " %s" "${arg}" + printf " %q" "${arg}" i=$((i + 1)) for ((_j = 0; _j < _arity && i < ${#BWRAP_ARGS[@]}; _j++)); do # Redact the VALUE of secret-looking --setenv variables (only the @@ -2486,7 +2488,7 @@ print_dry_run() { _is_secret_env_name "${BWRAP_ARGS[$((i - 1))]}"; then printf " 'REDACTED'" else - printf " '%s'" "${BWRAP_ARGS[$i]}" + printf " %q" "${BWRAP_ARGS[$i]}" fi i=$((i + 1)) done From 12602991936cf3a1cd906ed02e18d8b232151738 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 20 Oct 2026 12:48:12 -0400 Subject: [PATCH 16/40] fix(bw*): --dry-run no longer mutates host state --dry-run is documented as printing the bwrap command without running it, but every launcher still created directories and files on the host first: ensure_host_dirs (mkdir/touch), the --init-auth stub files (opencode and codex auth.json, claude settings.json and .credentials.json, copilot config.json), bwclaude's ~/.claude.json, and the per-repository project-dotfiles directory of bwopencode/bwopencode2. A --dry-run against a fresh $HOME created 9-18 host paths per launcher; it now creates none. - Skip ensure_host_dirs and every stub/mkdir under --dry-run, in all five launchers. - Keep the printed command faithful to a real run: binds whose source a real run would have created are still emitted. The new library helper _persist_bind_wanted covers the --init-auth stubs, bwclaude always binds its state file, and the project-dotfiles bind is unconditional. - Create bwclaude's --init-auth settings.json stub with umask 077 (was 022, world-readable). Assisted-by: opencode:claude-sonnet-4-6 Assisted-by: OpenCode:claude-fable-5 Assisted-by: ClaudeCode:claude-opus-4-8 Assisted-by: OpenCode:claude-opus-5-5 --- bin/bwclaude | 32 +++++++++++++++++++++++--------- bin/bwcodex | 14 ++++++++++---- bin/bwcopilot | 14 ++++++++++---- bin/bwopencode | 22 ++++++++++++++++------ bin/bwopencode2 | 11 +++++++++-- lib/bwrap_sandbox_lib.sh | 8 ++++++++ 6 files changed, 76 insertions(+), 25 deletions(-) diff --git a/bin/bwclaude b/bin/bwclaude index 11b43a3..b19ad9f 100755 --- a/bin/bwclaude +++ b/bin/bwclaude @@ -284,13 +284,16 @@ build_claude_home_mount() { # With --init-auth, create a stub settings.json on the host if none exists, # so it can be bind-mounted read-write and persist across sessions. - if [[ "${INIT_AUTH}" -eq 1 ]] && [[ ! -e "${_settings_path}" ]]; then - ( umask 022 && printf '{}' > "${_settings_path}" ) + # File creation is skipped on --dry-run to keep that mode side-effect-free. + if [[ "${INIT_AUTH}" -eq 1 ]] && [[ ! -e "${_settings_path}" ]] && [[ "${DRY_RUN}" -eq 0 ]]; then + ( umask 077 && printf '{}' > "${_settings_path}" ) echo "Created ${_settings_path} — settings will persist after this session." >&2 fi - # settings.json: read-write if it exists (claude may update it during the session) - if [[ -e "${_settings_path}" ]]; then + # settings.json: read-write if it exists (claude may update it during the + # session). _persist_bind_wanted also emits the bind under + # --dry-run --init-auth, where the stub above is not created. + if _persist_bind_wanted "${_settings_path}"; then BWRAP_ARGS+=(--bind "${_settings_path}" "${_settings_path}") fi @@ -301,12 +304,15 @@ build_claude_home_mount() { fi # Credentials: ephemeral by default, persistent with --init-auth. + # File creation is skipped on --dry-run to keep that mode side-effect-free. if [[ "${INIT_AUTH}" -eq 1 ]]; then - if [[ ! -f "${CLAUDE_CREDENTIALS_FILE}" ]]; then + if [[ ! -f "${CLAUDE_CREDENTIALS_FILE}" ]] && [[ "${DRY_RUN}" -eq 0 ]]; then ( umask 077 && printf '{}' > "${CLAUDE_CREDENTIALS_FILE}" ) echo "Created ${CLAUDE_CREDENTIALS_FILE} — credentials will persist after this session." >&2 fi - BWRAP_ARGS+=(--bind "${CLAUDE_CREDENTIALS_FILE}" "${CLAUDE_CREDENTIALS_FILE}") + if _persist_bind_wanted "${CLAUDE_CREDENTIALS_FILE}"; then + BWRAP_ARGS+=(--bind "${CLAUDE_CREDENTIALS_FILE}" "${CLAUDE_CREDENTIALS_FILE}") + fi elif [[ -f "${CLAUDE_CREDENTIALS_FILE}" ]]; then # Normal mode: copy credentials into tmpfs via --file so host auth is # available but changes don't persist. @@ -319,10 +325,15 @@ build_claude_home_mount() { # UI/session state (onboarding flags, feature caches, project history) — # not a credential. Without this, $HOME is tmpfs and claude triggers # the onboarding flow on every invocation. - if [[ ! -f "${CLAUDE_STATE_FILE}" ]]; then + # File creation is skipped on --dry-run to keep that mode side-effect-free. + if [[ ! -f "${CLAUDE_STATE_FILE}" ]] && [[ "${DRY_RUN}" -eq 0 ]]; then ( umask 077 && printf '{}' > "${CLAUDE_STATE_FILE}" ) fi - BWRAP_ARGS+=(--bind "${CLAUDE_STATE_FILE}" "${CLAUDE_STATE_FILE}") + # A real run always creates and binds the state file, so emit the bind + # under --dry-run too (where the stub above is not created). + if [[ -f "${CLAUDE_STATE_FILE}" ]] || [[ "${DRY_RUN}" -eq 1 ]]; then + BWRAP_ARGS+=(--bind "${CLAUDE_STATE_FILE}" "${CLAUDE_STATE_FILE}") + fi } # ── Claude cache (read-write) ──────────────────────────────────── @@ -477,7 +488,10 @@ main() { resolve_common_paths resolve_claude_paths detect_shell - ensure_host_dirs + # Skip host-state mutations on dry-run so --dry-run remains side-effect-free. + if [[ "${DRY_RUN}" -eq 0 ]]; then + ensure_host_dirs + fi parse_all_git_includes init_sandbox_path diff --git a/bin/bwcodex b/bin/bwcodex index d8364a2..6e46bf6 100755 --- a/bin/bwcodex +++ b/bin/bwcodex @@ -379,11 +379,14 @@ build_codex_home_mount() { # auth.json: only persist if it already exists on the host. # Use --init-auth on first run to bootstrap persistent ChatGPT-login auth. + # File creation is skipped on --dry-run to keep that mode side-effect-free. if [[ "${INIT_AUTH}" -eq 1 ]] && [[ ! -f "${CODEX_AUTH_FILE}" ]]; then - ( umask 077 && printf '{}\n' > "${CODEX_AUTH_FILE}" ) - echo "Created ${CODEX_AUTH_FILE} — auth tokens will persist after this session." >&2 + if [[ "${DRY_RUN}" -eq 0 ]]; then + ( umask 077 && printf '{}\n' > "${CODEX_AUTH_FILE}" ) + echo "Created ${CODEX_AUTH_FILE} — auth tokens will persist after this session." >&2 + fi fi - if [[ -f "${CODEX_AUTH_FILE}" ]]; then + if _persist_bind_wanted "${CODEX_AUTH_FILE}"; then BWRAP_ARGS+=(--bind "${CODEX_AUTH_FILE}" "${CODEX_AUTH_FILE}") fi } @@ -477,7 +480,10 @@ main() { resolve_common_paths resolve_codex_paths detect_shell - ensure_host_dirs + # Skip host-state mutations on dry-run so --dry-run remains side-effect-free. + if [[ "${DRY_RUN}" -eq 0 ]]; then + ensure_host_dirs + fi check_codex_config parse_all_git_includes init_sandbox_path diff --git a/bin/bwcopilot b/bin/bwcopilot index 38f0290..00b6f9a 100755 --- a/bin/bwcopilot +++ b/bin/bwcopilot @@ -302,14 +302,17 @@ build_copilot_home_mount() { fi done - # Handle config.json: copy or bind depending on --init-auth + # Handle config.json: copy or bind depending on --init-auth. + # File creation is skipped on --dry-run to keep that mode side-effect-free. if [[ "${INIT_AUTH}" -eq 1 ]]; then # --init-auth: bind-mount config.json so changes persist - if [[ ! -f "${COPILOT_CONFIG_FILE}" ]]; then + if [[ ! -f "${COPILOT_CONFIG_FILE}" ]] && [[ "${DRY_RUN}" -eq 0 ]]; then ( umask 077 && printf '{"banner": "never"}\n' > "${COPILOT_CONFIG_FILE}" ) echo "Created ${COPILOT_CONFIG_FILE} — auth tokens will persist after this session." >&2 fi - BWRAP_ARGS+=(--bind "${COPILOT_CONFIG_FILE}" "${COPILOT_CONFIG_FILE}") + if _persist_bind_wanted "${COPILOT_CONFIG_FILE}"; then + BWRAP_ARGS+=(--bind "${COPILOT_CONFIG_FILE}" "${COPILOT_CONFIG_FILE}") + fi elif [[ -f "${COPILOT_CONFIG_FILE}" ]]; then # Normal mode: copy config.json into tmpfs via --file # This makes host auth available but changes don't persist @@ -428,7 +431,10 @@ main() { resolve_common_paths resolve_copilot_paths detect_shell - ensure_host_dirs + # Skip host-state mutations on dry-run so --dry-run remains side-effect-free. + if [[ "${DRY_RUN}" -eq 0 ]]; then + ensure_host_dirs + fi parse_all_git_includes init_sandbox_path diff --git a/bin/bwopencode b/bin/bwopencode index 9d9743b..163a5f9 100755 --- a/bin/bwopencode +++ b/bin/bwopencode @@ -311,7 +311,11 @@ build_persistent_dotdir() { _repo_hash="$(printf '%s' "${_GIT_ROOT}" | sha256sum)" _repo_hash="${_repo_hash:0:16}" local _persist_dir="${OC_DATA_DIR}/project-dotfiles/${_repo_hash}" - mkdir -p "${_persist_dir}" + # The host mkdir is skipped on --dry-run (no host-state changes); the + # bind is still emitted so the printed command matches a real run. + if [[ "${DRY_RUN}" -eq 0 ]]; then + mkdir -p "${_persist_dir}" + fi BWRAP_ARGS+=( --bind "${_persist_dir}" "${_GIT_ROOT}/.opencode" @@ -359,12 +363,15 @@ build_opencode_data_mounts() { # auth.json: only persist if it already exists on the host. # Use --init-auth on first run to bootstrap persistent auth. + # File creation is skipped on --dry-run to keep that mode side-effect-free. if [[ "${INIT_AUTH}" -eq 1 ]] && [[ ! -f "${OC_AUTH_FILE}" ]]; then - # Use umask to ensure file is created with 0600 permissions atomically - ( umask 077 && printf '{}\n' > "${OC_AUTH_FILE}" ) - echo "Created ${OC_AUTH_FILE} — auth tokens will persist after this session." >&2 + if [[ "${DRY_RUN}" -eq 0 ]]; then + # Use umask to ensure file is created with 0600 permissions atomically + ( umask 077 && printf '{}\n' > "${OC_AUTH_FILE}" ) + echo "Created ${OC_AUTH_FILE} — auth tokens will persist after this session." >&2 + fi fi - if [[ -f "${OC_AUTH_FILE}" ]]; then + if _persist_bind_wanted "${OC_AUTH_FILE}"; then BWRAP_ARGS+=(--bind "${OC_AUTH_FILE}" "${OC_AUTH_FILE}") fi } @@ -508,7 +515,10 @@ main() { resolve_common_paths resolve_opencode_paths detect_shell - ensure_host_dirs + # Skip host-state mutations on dry-run so --dry-run remains side-effect-free. + if [[ "${DRY_RUN}" -eq 0 ]]; then + ensure_host_dirs + fi parse_all_git_includes resolve_config_symlinks init_sandbox_path diff --git a/bin/bwopencode2 b/bin/bwopencode2 index 45fe3e5..36fee9d 100755 --- a/bin/bwopencode2 +++ b/bin/bwopencode2 @@ -467,7 +467,11 @@ build_persistent_dotdir() { _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}" + # The host mkdir is skipped on --dry-run (no host-state changes); the + # bind is still emitted so the printed command matches a real run. + if [[ "${DRY_RUN}" -eq 0 ]]; then + mkdir -p "${_persist_dir}" + fi BWRAP_ARGS+=( --bind "${_persist_dir}" "${_GIT_ROOT}/.opencode" @@ -697,7 +701,10 @@ main() { resolve_common_paths resolve_opencode2_paths detect_shell - ensure_host_dirs + # Skip host-state mutations on dry-run so --dry-run remains side-effect-free. + if [[ "${DRY_RUN}" -eq 0 ]]; then + ensure_host_dirs + fi parse_all_git_includes resolve_config_symlinks init_sandbox_path diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index 6ae19e8..c31658a 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -579,6 +579,14 @@ bwrap_resolve_tool() { exit 1 } +# _persist_bind_wanted PATH +# True if PATH exists, or under --dry-run --init-auth: there a real run +# would have created the stub file and bound it, so the bind is emitted for +# an accurate dry-run even though the stub was not created. +_persist_bind_wanted() { + [[ -e "$1" ]] || { [[ "${INIT_AUTH:-0}" -eq 1 ]] && [[ "${DRY_RUN}" -eq 1 ]]; } +} + # _expand_leading_tilde VALUE # Print VALUE with only a LEADING tilde expanded: # "~" / "~/path" -> $HOME / $HOME/path From f36b7e2032e78549cc2a4d9b469acc5690fa0ac9 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 20 Oct 2026 12:49:55 -0400 Subject: [PATCH 17/40] fix(bwclaude,bwopencode): persist plans/; correct plans/ persistence docs bwclaude: ensure_host_dirs created ~/.claude/plans on the host, but the persistence loop in build_claude_home_mount never bound it, so plan-mode plans written inside the sandbox landed on the ~/.claude tmpfs and were silently discarded while an empty directory sat on the host. Bind plans/ read-write like the other persisted directories (as bwopencode already does), so plans survive the session and are shared with unsandboxed claude. Plan files are plain markdown, not configuration that runs outside the sandbox. bwopencode: the file header and the build_opencode_data_mounts comment described plans/ as ephemeral, but the persist loop has always included it. Update both, and the persisted list in docs/bw-wrappers.md. docs: list what bwclaude persists under ~/.claude. Assisted-by: opencode:claude-sonnet-4-6 Assisted-by: OpenCode:claude-opus-5-5 --- bin/bwclaude | 3 ++- bin/bwopencode | 4 ++-- docs/bw-wrappers.md | 7 ++++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/bin/bwclaude b/bin/bwclaude index b19ad9f..a55338f 100755 --- a/bin/bwclaude +++ b/bin/bwclaude @@ -15,6 +15,7 @@ # - statsig/: bind-mounted read-write (persistent) # - file-history/: bind-mounted read-write (per-file edit history for undo/restore) # - sessions/: bind-mounted read-write (session metadata for --continue) +# - plans/: bind-mounted read-write (plan-mode plan files) # - history.jsonl: bind-mounted read-write (prompt history across sessions) # - .last-cleanup: bind-mounted read-write (throttles Claude's cleanup runs) # - settings.json: bind-mounted read-write (if it exists; --init-auth creates a stub) @@ -270,7 +271,7 @@ build_claude_home_mount() { # Persistent read-write directories local _subdir - for _subdir in logs projects todos statsig file-history sessions; do + for _subdir in logs projects todos statsig file-history plans sessions; do BWRAP_ARGS+=(--bind "${CLAUDE_HOME_DIR}/${_subdir}" "${CLAUDE_HOME_DIR}/${_subdir}") done diff --git a/bin/bwopencode b/bin/bwopencode index 163a5f9..039db6d 100755 --- a/bin/bwopencode +++ b/bin/bwopencode @@ -12,7 +12,7 @@ # - OpenCode data dir: ephemeral tmpfs with selective persistence # - auth.json: persisted only if it already exists on host; # otherwise created in tmpfs (discarded on exit) -# - Database, logs, snapshots, storage, tool-output, +# - Database, logs, plans, snapshots, storage, tool-output, # project-dotfiles: always persisted # - OpenCode cache: read-write # - Git config + includes: read-only @@ -342,7 +342,7 @@ build_opencode_config_mount() { # behaviour for shared accounts. On a personal account, run with # --init-auth once to create the file and bootstrap persistent auth. # -# Any files NOT in the persist list (e.g. mcp-auth.json, plans/) +# Any files NOT in the persist list (e.g. mcp-auth.json) # will be writable inside the sandbox but discarded on exit. build_opencode_data_mounts() { BWRAP_ARGS+=( diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index c8d0327..4d2803d 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -215,7 +215,7 @@ Launches [opencode](https://opencode.ai) inside a bubblewrap sandbox. bwopencode [bwopencode-options] [opencode arguments...] ``` -Persists database, logs, snapshots, storage, tool-output, and +Persists database, logs, plans, snapshots, storage, tool-output, and project-dotfiles to the host data dir; `auth.json` is persisted only if it already exists on the host (use `--init-auth` once to create it for personal accounts). @@ -397,6 +397,11 @@ Additional options: | `--debug` | Enable verbose debug logging: sets `ANTHROPIC_LOG=debug`, `NODE_DEBUG=http,https,tls`, and passes `--debug --verbose` to `claude` | | `--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` | +Persists `logs`, `projects`, `todos`, `statsig`, `file-history`, `plans` +(plan-mode plan files) and `sessions` under `~/.claude`, plus +`history.jsonl` and `~/.claude.json`. Other new files under `~/.claude` are +discarded on exit. + On shared accounts, auth is ephemeral. On a personal machine, run `bwclaude --init-auth` once to persist credentials. From be8bab9404117939bf220ceefa15ea418631665a Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 20 Oct 2026 12:52:56 -0400 Subject: [PATCH 18/40] fix(bwcodex): eliminate config.toml temp-file leak The default (non --persist-config) path staged config.toml to a mktemp file and registered a trap to delete it on EXIT. However, launch_sandbox uses exec to replace the shell process with bwrap, and bash does NOT run EXIT traps across exec. The trap fired only on the --dry-run path (print_dry_run calls exit 0) or if a signal arrived during setup, leaving a copy of the user's config.toml in $TMPDIR on every normal run. The INT/TERM trap also ran rm -f without re-raising the signal. - Open the staged copy as an fd immediately after writing it, then unlink the temp file before exec. The kernel keeps the data alive via the open fd; bwrap receives it via --file and creates a plain writable file in the sandbox tmpfs from it. No trap and no cleanup are needed. - Copy with plain cp instead of cp -p: -p re-applied the source's mode (typically 0644) over mktemp's 0600, leaving a world-readable copy in the shared temp directory until the unlink. - Under --dry-run, stage nothing and print a placeholder: a real fd number could not be reproduced by copy-paste. - Update the file-header comment to describe the fd-based strategy. Verified with a real run: the sandbox sees the config and can append to it, the host file is unchanged, and no bwcodex-config.*.toml is left in $TMPDIR (main leaves one per run). Assisted-by: opencode:claude-sonnet-4-6 Assisted-by: ClaudeCode:claude-fable-5 Assisted-by: ClaudeCode:claude-sonnet-5 Assisted-by: OpenCode:claude-fable-5 Assisted-by: ClaudeCode:claude-opus-4-8 Assisted-by: OpenCode:claude-opus-5-5 --- bin/bwcodex | 41 ++++++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/bin/bwcodex b/bin/bwcodex index 6e46bf6..76d64c3 100755 --- a/bin/bwcodex +++ b/bin/bwcodex @@ -25,10 +25,11 @@ # - sessions/: bind-mounted read-write (for `codex resume` / `codex fork`) # - log/: bind-mounted read-write (RUST_LOG / opt-in plaintext logs) # - history.jsonl: bind-mounted read-write (prompt history) -# - config.toml: by default, copied to a per-session staging file and -# bind-mounted RW so Codex can persist project-trust grants for the -# session without touching the host file (the staging copy is removed -# on script exit). With --persist-config, the host config.toml is +# - config.toml: by default, opened as a file descriptor and passed to +# bwrap via --file so Codex can persist project-trust grants for the +# session without touching the host file (the temp copy is unlinked +# immediately after opening; no cleanup needed on exit). +# With --persist-config, the host config.toml is # bind-mounted RW so writes persist across sessions at the cost of # weaker isolation. Either the user file or the system file # (/etc/codex/config.toml) is sufficient for fail-fast. @@ -346,22 +347,36 @@ build_codex_home_mount() { fi # User config.toml mount strategy (see file header): - # - Default: stage a writable copy in /tmp, bind RW into sandbox. - # Codex can persist project trust / other mutations for the session, - # but the host ~/.codex/config.toml is never touched. The staging - # file is cleaned up on script exit via the EXIT trap. + # - Default: open a temporary copy of config.toml as an fd and pass it + # to bwrap via --file. bwrap creates a plain (writable) file in the + # sandbox tmpfs from the fd content, so Codex can persist project-trust + # grants for the session without touching the host file. The temp + # file is unlinked immediately after the fd is opened, so no cleanup + # is needed on exit — the fd holds the only reference and closes when + # bwrap exec's into the tool. # - --persist-config: bind host config.toml RW so writes persist. # If the user file does not exist (managed host using only system # config), skip the user mount entirely. if [[ -f "${CODEX_CONFIG_FILE}" ]]; then if [[ "${PERSIST_CONFIG}" -eq 1 ]]; then BWRAP_ARGS+=(--bind "${CODEX_CONFIG_FILE}" "${CODEX_CONFIG_FILE}") + elif [[ "${DRY_RUN}" -eq 1 ]]; then + # --dry-run stages nothing on the host, and a real fd number could + # not be reproduced by copy-paste: print a placeholder instead. + BWRAP_ARGS+=(--file "" "${CODEX_CONFIG_FILE}") else - _STAGED_CONFIG="$(mktemp --tmpdir bwcodex-config.XXXXXX.toml)" - cp -p "${CODEX_CONFIG_FILE}" "${_STAGED_CONFIG}" - chmod u+w "${_STAGED_CONFIG}" - trap 'rm -f "${_STAGED_CONFIG}"' EXIT INT TERM - BWRAP_ARGS+=(--bind "${_STAGED_CONFIG}" "${CODEX_CONFIG_FILE}") + local _config_fd _staged_config + _staged_config="$(mktemp --tmpdir bwcodex-config.XXXXXX.toml)" + # Plain cp (not cp -p) keeps mktemp's 0600 mode; -p would apply + # the source's mode (typically 0644), leaving a world-readable + # copy in the shared temp directory until the unlink below. + cp "${CODEX_CONFIG_FILE}" "${_staged_config}" + # Open the copy as an fd then immediately unlink it. The kernel + # keeps the data alive via the open fd; the filename disappears + # from /tmp so there is nothing to clean up on exit or signal. + exec {_config_fd}< "${_staged_config}" + rm -f "${_staged_config}" + BWRAP_ARGS+=(--file "${_config_fd}" "${CODEX_CONFIG_FILE}") fi fi From ed3d69f27bd88e197d6c65fb2c172221d5b66f3b Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 20 Oct 2026 12:55:32 -0400 Subject: [PATCH 19/40] fix(bwcodex,bwcopilot): remove duplicate CODEX_* setenv; remove dead guards bwcodex #3: build_codex_env called pass_through_if_set for CODEX_HOME, CODEX_SQLITE_HOME, CODEX_API_KEY, CODEX_ACCESS_TOKEN, and CODEX_CA_CERTIFICATE individually and then emitted each of them a second time via the generic CODEX_* while-read sweep. The duplicates are harmless (bwrap last-write wins, same value) but noisy and misleading. Remove the five redundant explicit calls; only SSL_CERT_FILE and RUST_LOG genuinely need the explicit lines because they do not match the CODEX_* prefix. bwcopilot #2: the skills/ and agents/ bind loop guarded each path with `[[ -e "${_path}" ]]`, but ensure_host_dirs unconditionally creates both directories, making the guards always true. Remove the dead existence checks and bind unconditionally. On --dry-run (where ensure_host_dirs is now skipped) the bind args are still emitted so the dry-run output accurately reflects the real invocation. Assisted-by: opencode:claude-sonnet-4-6 --- bin/bwcodex | 11 ++++------- bin/bwcopilot | 13 +++++++------ 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/bin/bwcodex b/bin/bwcodex index 76d64c3..6d4e941 100755 --- a/bin/bwcodex +++ b/bin/bwcodex @@ -426,16 +426,13 @@ build_codex_env() { pass_through_if_set OPENAI_API_KEY fi - # Documented Codex env vars - pass_through_if_set CODEX_HOME - pass_through_if_set CODEX_SQLITE_HOME - pass_through_if_set CODEX_API_KEY - pass_through_if_set CODEX_ACCESS_TOKEN - pass_through_if_set CODEX_CA_CERTIFICATE + # Non-CODEX_* vars that Codex consults. pass_through_if_set SSL_CERT_FILE pass_through_if_set RUST_LOG - # Generic CODEX_* sweep so user-defined overrides flow through. + # Generic CODEX_* sweep — covers CODEX_HOME, CODEX_SQLITE_HOME, + # CODEX_API_KEY, CODEX_ACCESS_TOKEN, CODEX_CA_CERTIFICATE, and any + # user-defined CODEX_* overrides in a single pass. local _key while IFS= read -r _key; do if [[ "${_key}" == CODEX_* ]]; then diff --git a/bin/bwcopilot b/bin/bwcopilot index 00b6f9a..13cbfcd 100755 --- a/bin/bwcopilot +++ b/bin/bwcopilot @@ -293,13 +293,14 @@ build_copilot_home_mount() { fi done - # Bind-mount user-writable customization directories (read-write) + # Bind-mount user-writable customization directories (read-write). # Skills and agents are user-created files, not installed packages. - for _cfg in skills agents; do - local _path="${COPILOT_HOME_DIR}/${_cfg}" - if [[ -e "${_path}" ]]; then - BWRAP_ARGS+=(--bind "${_path}" "${_path}") - fi + # ensure_host_dirs always creates both directories, so no existence + # check is needed (on --dry-run the directories may not exist yet, + # but the bind args are still emitted for accurate dry-run output). + local _cust_dir + for _cust_dir in skills agents; do + BWRAP_ARGS+=(--bind "${COPILOT_HOME_DIR}/${_cust_dir}" "${COPILOT_HOME_DIR}/${_cust_dir}") done # Handle config.json: copy or bind depending on --init-auth. From e3fd433cb262c68a1b1b4829bbd5a7626956f404 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 20 Oct 2026 12:58:53 -0400 Subject: [PATCH 20/40] refactor(bw*): add forward_env_by_prefix helper; deduplicate prefix sweeps The GH_TOKEN_* passthrough block was duplicated verbatim in all five launchers, each with its own --dry-run REDACTED substitution, and similar prefix sweeps existed for CLAUDE_*, CODEX_* and COPILOT_PROVIDER_*. - Add forward_env_by_prefix PREFIX to the library: iterate `compgen -e` with `while IFS= read -r` and append a --setenv per matching variable. - Use it for GH_TOKEN_ (all five launchers), CLAUDE_ (bwclaude), CODEX_ (bwcodex) and COPILOT_PROVIDER_ (bwcopilot). This also replaces bwcopilot's word-splitting `for _key in $(compgen -e)` loop. - Drop the per-launcher REDACTED swap: print_dry_run already redacts every --setenv whose name matches _is_secret_env_name (*TOKEN*), and the real launch path is unchanged. - bwcopilot keeps its explicit COPILOT_HOME / COPILOT_CACHE_HOME / COPILOT_MODEL allowlist, and bwopencode/bwopencode2 keep their OPENCODE_* allowlists; neither is replaced by a prefix sweep. No behaviour change: with CLAUDE_*, CODEX_*, COPILOT_PROVIDER_*, COPILOT_OTHER and GH_TOKEN_* set, the forwarded variables are identical before and after for every launcher, in --dry-run and in a real run. Assisted-by: opencode:claude-sonnet-4-6 Assisted-by: OpenCode:claude-opus-5-5 --- bin/bwclaude | 24 ++++-------------------- bin/bwcodex | 24 ++++-------------------- bin/bwcopilot | 24 ++++-------------------- bin/bwopencode | 17 +++-------------- bin/bwopencode2 | 17 +++-------------- lib/bwrap_sandbox_lib.sh | 17 +++++++++++++++++ 6 files changed, 35 insertions(+), 88 deletions(-) diff --git a/bin/bwclaude b/bin/bwclaude index a55338f..aabb20c 100755 --- a/bin/bwclaude +++ b/bin/bwclaude @@ -409,28 +409,12 @@ build_claude_env() { fi # Forward any host CLAUDE_* env vars (incl. user overrides for verbosity, etc.). - local _key - while IFS= read -r _key; do - if [[ "${_key}" == CLAUDE_* ]]; then - BWRAP_ARGS+=(--setenv "${_key}" "${!_key}") - fi - done < <(compgen -e) + forward_env_by_prefix CLAUDE_ - # 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. + # GitHub CLI token passthrough (opt-in via --github-tokens). In + # --dry-run, print_dry_run redacts the values (the names match *TOKEN*). 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) + forward_env_by_prefix GH_TOKEN_ fi # Enforce after the loop so host env cannot override these. diff --git a/bin/bwcodex b/bin/bwcodex index 6d4e941..775db3e 100755 --- a/bin/bwcodex +++ b/bin/bwcodex @@ -433,33 +433,17 @@ build_codex_env() { # Generic CODEX_* sweep — covers CODEX_HOME, CODEX_SQLITE_HOME, # CODEX_API_KEY, CODEX_ACCESS_TOKEN, CODEX_CA_CERTIFICATE, and any # user-defined CODEX_* overrides in a single pass. - local _key - while IFS= read -r _key; do - if [[ "${_key}" == CODEX_* ]]; then - BWRAP_ARGS+=(--setenv "${_key}" "${!_key}") - fi - done < <(compgen -e) + forward_env_by_prefix CODEX_ # --debug wrapper flag: force verbose logging regardless of host env. if [[ "${DEBUG_MODE}" -eq 1 ]]; then BWRAP_ARGS+=(--setenv RUST_LOG "debug") 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. + # GitHub CLI token passthrough (opt-in via --github-tokens). In + # --dry-run, print_dry_run redacts the values (the names match *TOKEN*). 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) + forward_env_by_prefix GH_TOKEN_ fi } diff --git a/bin/bwcopilot b/bin/bwcopilot index 13cbfcd..9561b3c 100755 --- a/bin/bwcopilot +++ b/bin/bwcopilot @@ -388,28 +388,12 @@ build_copilot_env() { pass_through_if_set COPILOT_MODEL # Pass through any COPILOT_PROVIDER_* env vars - local _key - for _key in $(compgen -e); do - if [[ "${_key}" == COPILOT_PROVIDER_* ]]; then - BWRAP_ARGS+=(--setenv "${_key}" "${!_key}") - fi - done + forward_env_by_prefix COPILOT_PROVIDER_ - # 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. + # GitHub CLI token passthrough (opt-in via --github-tokens). In + # --dry-run, print_dry_run redacts the values (the names match *TOKEN*). 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) + forward_env_by_prefix GH_TOKEN_ fi } diff --git a/bin/bwopencode b/bin/bwopencode index 039db6d..c54246b 100755 --- a/bin/bwopencode +++ b/bin/bwopencode @@ -478,21 +478,10 @@ build_opencode_env() { 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. + # GitHub CLI token passthrough (opt-in via --github-tokens). In + # --dry-run, print_dry_run redacts the values (the names match *TOKEN*). 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) + forward_env_by_prefix GH_TOKEN_ fi } diff --git a/bin/bwopencode2 b/bin/bwopencode2 index 36fee9d..d6c45ca 100755 --- a/bin/bwopencode2 +++ b/bin/bwopencode2 @@ -650,21 +650,10 @@ build_opencode2_env() { 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. + # GitHub CLI token passthrough (opt-in via --github-tokens). In + # --dry-run, print_dry_run redacts the values (the names match *TOKEN*). 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) + forward_env_by_prefix GH_TOKEN_ fi # Separate XDG homes for OpenCode 2 (see build_xdg_mirror_mounts for the diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index c31658a..69876c1 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -967,6 +967,23 @@ pass_through_if_set() { fi } +# forward_env_by_prefix PREFIX +# Append a --setenv entry to BWRAP_ARGS for every exported environment +# variable whose name starts with PREFIX. Uses `while IFS= read -r` over +# `compgen -e`, so names are never word-split. Values are passed as-is; +# print_dry_run redacts names that match _is_secret_env_name. +# +# Example: +# forward_env_by_prefix GH_TOKEN_ # GH_TOKEN_NSLS2, GH_TOKEN_PERSONAL, ... +forward_env_by_prefix() { + local _prefix="$1" _key + while IFS= read -r _key; do + if [[ "${_key}" == "${_prefix}"* ]]; then + BWRAP_ARGS+=(--setenv "${_key}" "${!_key}") + fi + done < <(compgen -e) +} + # ── Path safety validation ─────────────────────────────────────── # _is_prefix_of A B From 2610ce7236b30d3d035c2e96dc2520af10cf4a1b Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 20 Oct 2026 13:01:47 -0400 Subject: [PATCH 21/40] fix(bw*): --help no longer requires the tool; fix --exec example and --debug - --help: every launcher resolved its tool binary before checking SHOW_HELP, so `bw* --help` aborted with "not found" / "installed incorrectly" before printing anything when the tool was missing. Handle --help first; the new library helper bwrap_show_tool_help then appends the tool's own help (re-exec with --exec "TOOL --help", as before) only when the tool is usable, and otherwise says so and exits 0. - --exec example: `--exec 'env | sort'` cannot work -- CMD is split on whitespace and run without a shell, so `|` became a literal argument. Document `--exec CMD [ARGS...]` with `--exec bash -c 'env | sort'` (arguments after CMD are passed through unchanged) in all five launchers, docs/bw-wrappers.md and the README. - bwclaude --debug no longer prepends `--debug --verbose` to an --exec command (`bwclaude --debug --exec bash` ran `bash --debug --verbose`). This is the minimal form of the shared-front-end refactor on mnt/more_cleanup; the parser itself stays per launcher for now. Assisted-by: opencode:claude-sonnet-4-6 Assisted-by: OpenCode:claude-fable-5 Assisted-by: ClaudeCode:claude-opus-4-8 Assisted-by: OpenCode:claude-opus-5-5 --- README.md | 4 ++-- bin/bwclaude | 20 +++++++++++--------- bin/bwcodex | 11 +++++++---- bin/bwcopilot | 11 +++++++---- bin/bwopencode | 11 +++++++---- bin/bwopencode2 | 11 +++++++---- docs/bw-wrappers.md | 4 ++-- lib/bwrap_sandbox_lib.sh | 16 ++++++++++++++++ 8 files changed, 59 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index d2ebf32..bd5e300 100644 --- a/README.md +++ b/README.md @@ -92,9 +92,9 @@ Common wrapper options: | Option | Effect | | --- | --- | -| `--help`, `-h` | Show wrapper help plus the underlying tool's help | +| `--help`, `-h` | Show wrapper help, then the underlying tool's help when the tool is installed | | `--dry-run` | Print the `bwrap` command without executing it | -| `--exec CMD` | Run `CMD` inside the sandbox instead of the tool | +| `--exec CMD [ARGS...]` | Run `CMD` inside the sandbox instead of the tool. `CMD` is split on whitespace; following arguments are passed unchanged, e.g. `--exec bash -c 'env \| sort'` | | `--init-auth` | Persist auth credentials to the host (first-time setup) | | `--new-session` | Force `bwrap --new-session` (stricter isolation; breaks SIGWINCH) | diff --git a/bin/bwclaude b/bin/bwclaude index aabb20c..3d75f2a 100755 --- a/bin/bwclaude +++ b/bin/bwclaude @@ -168,7 +168,9 @@ Wrapper options (consumed before claude sees the command line): Use this to run scripts, diagnostic tools, or any command with the same filesystem isolation and environment as claude itself. Example: bwclaude --exec bash - bwclaude --exec 'env | sort' + bwclaude --exec bash -c 'env | sort' + CMD itself is split on whitespace; pass arguments that need + quoting as separate words after it, as above. --init-auth Persist auth credentials to host (run once on personal machines). --debug Enable verbose debug logging (sets ANTHROPIC_LOG=debug, NODE_DEBUG=http,https,tls, and passes --debug --verbose to claude). @@ -211,7 +213,7 @@ Authentication: HELP_EOF # Delegate to claude --help inside the sandbox so the user sees the full # claude option set in the same invocation. - exec "$0" --exec "${_TOOL_BIN} --help" + bwrap_show_tool_help claude } resolve_claude_paths() { @@ -436,11 +438,10 @@ build_claude_env() { if [[ "${DEBUG_MODE}" -eq 1 ]]; then BWRAP_ARGS+=(--setenv ANTHROPIC_LOG debug) BWRAP_ARGS+=(--setenv NODE_DEBUG "http,https,tls") - # Prepend --debug --verbose to the args passed to claude. - if [[ "${#TOOL_ARGS[@]}" -gt 0 ]]; then - TOOL_ARGS=(--debug --verbose "${TOOL_ARGS[@]}") - else - TOOL_ARGS=(--debug --verbose) + # Prepend --debug --verbose to the args passed to claude (not to an + # --exec command, which is not claude). + if [[ -z "${TEST_CMD}" ]]; then + TOOL_ARGS=(--debug --verbose ${TOOL_ARGS[@]+"${TOOL_ARGS[@]}"}) fi fi } @@ -461,13 +462,14 @@ build_claude_bin_mount() { main() { bwrap_check_account parse_wrapper_args "$@" - bwrap_resolve_tool claude if [[ "${SHOW_HELP}" -eq 1 ]]; then show_help - # show_help does exec, never returns + # show_help never returns fi + bwrap_resolve_tool claude + detect_bwrap_capabilities detect_kernel_capabilities resolve_common_paths diff --git a/bin/bwcodex b/bin/bwcodex index 775db3e..c460e8a 100755 --- a/bin/bwcodex +++ b/bin/bwcodex @@ -196,7 +196,9 @@ Wrapper options (consumed before codex sees the command line): Use this to run scripts, diagnostic tools, or any command with the same filesystem isolation and environment as codex itself. Example: bwcodex --exec bash - bwcodex --exec 'env | sort' + bwcodex --exec bash -c 'env | sort' + CMD itself is split on whitespace; pass arguments that need + quoting as separate words after it, as above. --init-auth Create ~/.codex/auth.json on the host so that ChatGPT-login OAuth tokens persist across sessions. Only meaningful for the BNL ITD ChatGPT subscription auth path; has no effect for @@ -249,7 +251,7 @@ Authentication: HELP_EOF # Delegate to codex --help inside the sandbox so the user sees the full # codex option set in the same invocation. - exec "$0" --exec "${_TOOL_BIN} --help" + bwrap_show_tool_help codex } resolve_codex_paths() { @@ -464,13 +466,14 @@ build_codex_bin_mount() { main() { bwrap_check_account parse_wrapper_args "$@" - bwrap_resolve_tool codex if [[ "${SHOW_HELP}" -eq 1 ]]; then show_help - # show_help does exec, never returns + # show_help never returns fi + bwrap_resolve_tool codex + detect_bwrap_capabilities detect_kernel_capabilities resolve_common_paths diff --git a/bin/bwcopilot b/bin/bwcopilot index 9561b3c..0330cc2 100755 --- a/bin/bwcopilot +++ b/bin/bwcopilot @@ -159,7 +159,9 @@ Wrapper options (consumed before copilot sees the command line): Use this to run scripts, diagnostic tools, or any command with the same filesystem isolation and environment as copilot itself. Example: bwcopilot --exec bash - bwcopilot --exec 'env | sort' + bwcopilot --exec bash -c 'env | sort' + CMD itself is split on whitespace; pass arguments that need + quoting as separate words after it, as above. --init-auth Persist auth tokens to host (run once on personal machines). --new-session Force bwrap to call setsid(2), detaching the sandbox from the controlling terminal. This is the default on older kernels @@ -205,7 +207,7 @@ Known limitation: HELP_EOF # Delegate to copilot --help inside the sandbox so the user sees the full # copilot option set in the same invocation. - exec "$0" --exec "${_TOOL_BIN} --help" + bwrap_show_tool_help copilot } # ── Copilot path resolution ───────────────────────────────────── @@ -404,13 +406,14 @@ build_copilot_env() { main() { bwrap_check_account parse_wrapper_args "$@" - 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 - # show_help does exec, never returns + # show_help never returns fi + bwrap_resolve_tool copilot "Install the standalone GitHub Copilot CLI from https://github.com/github/copilot-cli" + detect_bwrap_capabilities detect_kernel_capabilities resolve_common_paths diff --git a/bin/bwopencode b/bin/bwopencode index c54246b..85aa88a 100755 --- a/bin/bwopencode +++ b/bin/bwopencode @@ -153,7 +153,9 @@ Wrapper options (consumed before opencode sees the command line): Use this to run scripts, diagnostic tools, or any command with the same filesystem isolation and environment as opencode itself. Example: bwopencode --exec bash - bwopencode --exec 'env | sort' + bwopencode --exec bash -c 'env | sort' + CMD itself is split on whitespace; pass arguments that need + quoting as separate words after it, as above. --init-auth Create auth.json on the host so GitHub OAuth tokens persist across sessions. Run once on a personal account; skip on shared accounts (tokens will be ephemeral by default). @@ -188,7 +190,7 @@ HELP_EOF # opencode option set in the same invocation. We re-exec this script with # --exec so the sandbox is fully constructed; opencode --help is passed # as the sandbox command. - exec "$0" --exec "${_TOOL_BIN} --help" + bwrap_show_tool_help opencode } resolve_opencode_paths() { @@ -492,13 +494,14 @@ build_opencode_env() { main() { bwrap_check_account parse_wrapper_args "$@" - bwrap_resolve_tool opencode if [[ "${SHOW_HELP}" -eq 1 ]]; then show_help - # show_help does exec, never returns + # show_help never returns fi + bwrap_resolve_tool opencode + detect_bwrap_capabilities detect_kernel_capabilities resolve_common_paths diff --git a/bin/bwopencode2 b/bin/bwopencode2 index d6c45ca..20e5948 100755 --- a/bin/bwopencode2 +++ b/bin/bwopencode2 @@ -169,7 +169,9 @@ Wrapper options (consumed before opencode2 sees the command line): 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' + bwopencode2 --exec bash -c 'env | sort' + CMD itself is split on whitespace; pass arguments that need + quoting as separate words after it, as above. --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 @@ -224,7 +226,7 @@ HELP_EOF # 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" + bwrap_show_tool_help opencode2 } # ── Sandbox-escape guard (arguments) ───────────────────────────── @@ -673,13 +675,14 @@ build_opencode2_env() { main() { bwrap_check_account parse_wrapper_args "$@" - bwrap_resolve_tool opencode2 "OpenCode 2 is installed by the ansible base role." if [[ "${SHOW_HELP}" -eq 1 ]]; then show_help - # show_help does exec, never returns + # show_help never returns fi + bwrap_resolve_tool opencode2 "OpenCode 2 is installed by the ansible base role." + # --exec runs an arbitrary command, so its arguments are not opencode2's. if [[ -z "${TEST_CMD}" ]]; then guard_tool_args diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index 4d2803d..5ec5217 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -52,9 +52,9 @@ Every `bw*` wrapper accepts these options: | Option | Effect | | --- | --- | -| `--help`, `-h` | Show wrapper help plus the underlying tool's help | +| `--help`, `-h` | Show wrapper help, then the underlying tool's help when the tool is installed | | `--dry-run` | Print the `bwrap` command without executing it | -| `--exec CMD` | Run `CMD` inside the sandbox instead of the tool | +| `--exec CMD [ARGS...]` | Run `CMD` inside the sandbox instead of the tool. `CMD` is split on whitespace; following arguments are passed unchanged, e.g. `--exec bash -c 'env \| sort'` | | `--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. | diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index 69876c1..417d7da 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -520,6 +520,22 @@ _bwrap_symlink_is_restricted() { done } +# bwrap_show_tool_help NAME +# Finish a launcher's --help after its own text has been printed. When the +# tool NAME is usable, re-exec the launcher as `--exec "NAME --help"` so the +# tool's own help comes from inside the fully built sandbox. Otherwise say +# so and exit 0: the wrapper help must not depend on the tool being +# installed. Never returns. +bwrap_show_tool_help() { + local _name="$1" _bin + _bin="$(bwrap_find_tool "${_name}" 2> /dev/null || true)" + if [[ -n "${_bin}" && -x "${_bin}" ]]; then + exec "$0" --exec "${_bin} --help" + fi + echo "('${_name}' is not available here, so its own --help is not shown.)" + exit 0 +} + # 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 From 365a93684d2b1bd1e47f55e3f9b7fd071a3a8934 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 20 Oct 2026 13:04:15 -0400 Subject: [PATCH 22/40] fix(bwcopilot): dry-run fd placeholder for config.json Under --dry-run, bwcopilot still opened ~/.copilot/config.json as a live fd and printed its number (`--file 10 ...`), which cannot be reproduced by copy-paste. Emit a `` placeholder instead, matching bwcodex. A real run is unchanged: the sandbox still receives a copy of config.json. Assisted-by: ClaudeCode:claude-fable-5 Assisted-by: ClaudeCode:claude-sonnet-5 Assisted-by: OpenCode:claude-opus-5-5 --- bin/bwcopilot | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/bin/bwcopilot b/bin/bwcopilot index 0330cc2..24ebd5c 100755 --- a/bin/bwcopilot +++ b/bin/bwcopilot @@ -319,10 +319,16 @@ build_copilot_home_mount() { elif [[ -f "${COPILOT_CONFIG_FILE}" ]]; then # Normal mode: copy config.json into tmpfs via --file # This makes host auth available but changes don't persist - local _fd - exec {_fd}< "${COPILOT_CONFIG_FILE}" - BWRAP_ARGS+=(--file "${_fd}" "${COPILOT_CONFIG_FILE}") - # Note: fd stays open until bwrap exec's, then it's inherited and used + if [[ "${DRY_RUN}" -eq 1 ]]; then + # A real fd number could not be reproduced by copy-paste: print a + # placeholder for the --file mount instead. + BWRAP_ARGS+=(--file "" "${COPILOT_CONFIG_FILE}") + else + local _fd + exec {_fd}< "${COPILOT_CONFIG_FILE}" + BWRAP_ARGS+=(--file "${_fd}" "${COPILOT_CONFIG_FILE}") + # Note: fd stays open until bwrap exec's, then it's inherited and used + fi fi } From ef37a2c0de8a5c94ca5d902212221132090a37b6 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 20 Oct 2026 13:07:17 -0400 Subject: [PATCH 23/40] fix(bwcodex): mount the SSL_CERT_FILE target SSL_CERT_FILE was forwarded into the sandbox but the file it names was never mounted. A bundle under $HOME (hidden by the home tmpfs), under /tmp (a sandbox tmpfs) or anywhere else outside the mounted system trees was therefore missing inside the sandbox and Codex's TLS failed confusingly. build_ssl_cert_file_mount canonicalizes the path, read-only binds the file at its canonical location and points SSL_CERT_FILE there. When the path is not a readable file, or resolves into a blocked location (checked with the non-exiting _bwrap_path_blocked), it warns and does not forward the variable. Assisted-by: ClaudeCode:claude-fable-5 Assisted-by: ClaudeCode:claude-sonnet-5 Assisted-by: OpenCode:claude-opus-5-5 --- bin/bwcodex | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/bin/bwcodex b/bin/bwcodex index c460e8a..706e8cb 100755 --- a/bin/bwcodex +++ b/bin/bwcodex @@ -408,6 +408,31 @@ build_codex_home_mount() { fi } +# ── SSL_CERT_FILE (read-only) ──────────────────────────────────── +# Codex honours SSL_CERT_FILE, but forwarding only the variable is not +# enough: a bundle under $HOME (or anywhere else not mounted) is absent in +# the sandbox and TLS fails. Mount the canonical file read-only and point +# SSL_CERT_FILE at it; refuse with a warning (and do not forward) when it +# does not resolve to a readable file or resolves into a blocked location. +build_ssl_cert_file_mount() { + [[ -n "${SSL_CERT_FILE:-}" ]] || return 0 + local _canon + _canon="$(readlink -f -- "${SSL_CERT_FILE}" 2> /dev/null || true)" + if [[ -z "${_canon}" || ! -f "${_canon}" || ! -r "${_canon}" ]]; then + echo "Warning: SSL_CERT_FILE=${SSL_CERT_FILE} is not a readable file; not forwarding it." >&2 + return 0 + fi + if _bwrap_path_blocked "${_canon}"; then + echo "Warning: SSL_CERT_FILE=${SSL_CERT_FILE} resolves into a blocked location; not forwarding it." >&2 + return 0 + fi + # Bound even when a parent tree is already mounted (harmless there), so + # a file under a sandbox tmpfs such as /tmp is still provided. + _emit_home_intermediate_dirs "${_canon%/*}" inclusive + BWRAP_ARGS+=(--ro-bind "${_canon}" "${_canon}") + BWRAP_ARGS+=(--setenv SSL_CERT_FILE "${_canon}") +} + # ── Codex env vars ─────────────────────────────────────────────── # Pass through AIFAPIM_* (Hermes routing) and the documented Codex # env vars; default OPENAI_API_KEY to "dummy" if unset so the Codex @@ -429,7 +454,7 @@ build_codex_env() { fi # Non-CODEX_* vars that Codex consults. - pass_through_if_set SSL_CERT_FILE + build_ssl_cert_file_mount pass_through_if_set RUST_LOG # Generic CODEX_* sweep — covers CODEX_HOME, CODEX_SQLITE_HOME, From 580c415b7ab50b163f1a7f3ee689d917906f8ebe Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 20 Oct 2026 13:16:21 -0400 Subject: [PATCH 24/40] fix(bwclaude): key on CLAUDE_CONFIG_DIR, stop persisting settings.json The wrapper keyed its mounts on CLAUDE_HOME / CLAUDE_CACHE_HOME, which the Claude CLI does not read; the CLI reads CLAUDE_CONFIG_DIR and XDG_CACHE_HOME. A host-exported CLAUDE_CONFIG_DIR was forwarded by the CLAUDE_* sweep while the mounts stayed on ~/.claude, so persistence and credentials silently broke. - Key the Claude home on CLAUDE_CONFIG_DIR (default ~/.claude); an override is canonicalized and safety-checked with _bwrap_canonicalize_safe. The state file is $CLAUDE_CONFIG_DIR/.claude.json when the override is set, else ~/.claude.json, and is safety-checked before it is bound read-write. Derive the cache dir from XDG_CACHE_HOME. CLAUDE_HOME and CLAUDE_CACHE_HOME are no longer consulted. - Set CLAUDE_CONFIG_DIR in the sandbox only when the host sets it (to the canonical directory the mounts use). Otherwise it stays unset, so sandboxed and unsandboxed claude share ~/.claude and ~/.claude.json. - settings.json can define hooks/apiKeyHelper/env that later run UNSANDBOXED on the host; a sandboxed session writing it was a sandbox-escape channel. Provide it as an ephemeral --file copy by default (like .credentials.json); bind it read-write only under --init-auth. - Require the foundry key and host only when claude itself is launched, not under --dry-run or --exec (this also lets `bwclaude --help` show claude's own help without credentials). - Emit / placeholders under --dry-run instead of opening live fds whose numbers are unreproducible. - Document the Claude home, state file and settings.json handling in the header, --help and docs/bw-wrappers.md. The header and build_claude_home_mount comments described ~/.claude.json as "only UI/session state ... not a credential, safe to persist". Per the Claude Code docs it also holds the OAuth session, per-project trust decisions and personal MCP servers; correct the comments and docs and mark the read-write bind as a known gap: a sandboxed session can add an MCP server or trust decision that unsandboxed claude will honour later. Tightening that is left for later. Assisted-by: ClaudeCode:claude-fable-5 Assisted-by: ClaudeCode:claude-sonnet-5 Assisted-by: OpenCode:claude-opus-5-5 --- bin/bwclaude | 163 +++++++++++++++++++++++++++----------------- docs/bw-wrappers.md | 19 ++++-- 2 files changed, 116 insertions(+), 66 deletions(-) diff --git a/bin/bwclaude b/bin/bwclaude index 3d75f2a..e63ef75 100755 --- a/bin/bwclaude +++ b/bin/bwclaude @@ -8,7 +8,7 @@ # # The sandbox provides filesystem isolation: # - Current directory: read-write -# - Claude home (~/.claude or $CLAUDE_HOME): tmpfs (ephemeral by default) +# - Claude home (~/.claude, or $CLAUDE_CONFIG_DIR when set): tmpfs (ephemeral by default) # - logs/: bind-mounted read-write (persistent) # - projects/: bind-mounted read-write (persistent) # - todos/: bind-mounted read-write (persistent) @@ -18,13 +18,18 @@ # - plans/: bind-mounted read-write (plan-mode plan files) # - history.jsonl: bind-mounted read-write (prompt history across sessions) # - .last-cleanup: bind-mounted read-write (throttles Claude's cleanup runs) -# - settings.json: bind-mounted read-write (if it exists; --init-auth creates a stub) +# - settings.json: ephemeral copy (via --file) by default, so a session cannot +# persist hooks/apiKeyHelper/env that would later run unsandboxed on the +# host; bind-mounted read-write only with --init-auth (which creates a stub) # - settings.local.json: bind-mounted read-only (if it exists) # - Use --init-auth to make auth credentials and settings persistent -# - Claude state file (~/.claude.json): bind-mounted read-write (persistent) -# Contains only UI/session state (onboarding flags, feature caches, project history); -# not a credential, safe to persist unconditionally. -# - Claude cache (~/.cache/claude or $CLAUDE_CACHE_HOME): read-write +# - Claude state file (~/.claude.json, or $CLAUDE_CONFIG_DIR/.claude.json when +# CLAUDE_CONFIG_DIR is set): bind-mounted read-write (persistent), shared +# with unsandboxed claude. Besides UI state and onboarding flags it holds +# the OAuth session (if any), per-project trust decisions and personal MCP +# server definitions. KNOWN GAP: a session can add an MCP server or a trust +# decision here that unsandboxed claude will honour later. +# - Claude cache ($XDG_CACHE_HOME/claude, default ~/.cache/claude): read-write # - Git config + includes: read-only # - Pixi cache: read-write # - Pixi config: read-only @@ -52,7 +57,8 @@ # --exec CMD Run CMD inside the sandbox instead of claude. # Useful for running one-off commands, scripts, or diagnostic tools # inside the fully-configured sandbox environment. -# --init-auth Persist auth credentials to host (run once on personal machines). +# --init-auth Persist auth credentials and settings.json to host (run once on +# personal machines). # --debug Enable verbose debug logging (ANTHROPIC_LOG=debug, NODE_DEBUG, --debug --verbose). # NOTE: shadows claude's own -d/--debug [filter] flag. To pass a debug # filter to claude directly, place it after a non-wrapper argument so @@ -172,6 +178,8 @@ Wrapper options (consumed before claude sees the command line): CMD itself is split on whitespace; pass arguments that need quoting as separate words after it, as above. --init-auth Persist auth credentials to host (run once on personal machines). + Also makes settings.json writable and persistent; without it + the sandbox sees a throwaway copy of settings.json. --debug Enable verbose debug logging (sets ANTHROPIC_LOG=debug, NODE_DEBUG=http,https,tls, and passes --debug --verbose to claude). NOTE: shadows claude's own -d/--debug [filter] flag. To pass @@ -217,28 +225,30 @@ HELP_EOF } resolve_claude_paths() { - # Claude home: config, credentials, projects, logs, todos. - # Override: CLAUDE_HOME env var. - CLAUDE_HOME_DIR="${CLAUDE_HOME:-${_HOME}/.claude}" - - # Claude cache: follows XDG on Linux. - # Override: CLAUDE_CACHE_HOME env var. - if [[ -n "${CLAUDE_CACHE_HOME:-}" ]]; then - CLAUDE_CACHE_DIR="${CLAUDE_CACHE_HOME}" + # Claude home: config, credentials, projects, logs, todos. The Claude CLI + # reads CLAUDE_CONFIG_DIR (default ~/.claude); the mounts must be keyed on + # the same directory or persistence and credentials silently break. + # The state file (onboarding flags, project history, ...) is + # ~/.claude.json by default and $CLAUDE_CONFIG_DIR/.claude.json when + # CLAUDE_CONFIG_DIR is set. It is shared with unsandboxed claude. + if [[ -n "${CLAUDE_CONFIG_DIR:-}" ]]; then + _bwrap_canonicalize_safe "${CLAUDE_CONFIG_DIR}" "CLAUDE_CONFIG_DIR" + CLAUDE_HOME_DIR="${_BWRAP_CANON}" + CLAUDE_STATE_FILE="${CLAUDE_HOME_DIR}/.claude.json" else - CLAUDE_CACHE_DIR="${XDG_CACHE_HOME}/claude" + CLAUDE_HOME_DIR="${_HOME}/.claude" + _check_dir_safe "${CLAUDE_HOME_DIR}" "CLAUDE_CONFIG_DIR" + CLAUDE_STATE_FILE="${_HOME}/.claude.json" fi + # The state file is bound read-write: refuse one that resolves into a + # blocked location (e.g. a ~/.claude.json symlink into ~/.ssh). + _bwrap_canonicalize_safe "${CLAUDE_STATE_FILE}" "Claude state file" - CLAUDE_CREDENTIALS_FILE="${CLAUDE_HOME_DIR}/.credentials.json" - - # State file at $HOME/.claude.json (not inside .claude/) tracks - # hasCompletedOnboarding, numStartups, userID, project history. - CLAUDE_STATE_FILE="${_HOME}/.claude.json" + # Claude cache: the CLI follows XDG_CACHE_HOME. + CLAUDE_CACHE_DIR="${XDG_CACHE_HOME}/claude" + _check_dir_safe "${CLAUDE_CACHE_DIR}" "XDG_CACHE_HOME (claude cache)" - # Validate the user-controllable root directories once here so that all - # sub-path mounts derived from them are implicitly covered. - _check_dir_safe "${CLAUDE_HOME_DIR}" "CLAUDE_HOME" - _check_dir_safe "${CLAUDE_CACHE_DIR}" "CLAUDE_CACHE_HOME" + CLAUDE_CREDENTIALS_FILE="${CLAUDE_HOME_DIR}/.credentials.json" } # Ensure writable directories exist on host. @@ -285,19 +295,29 @@ build_claude_home_mount() { local _settings_path="${CLAUDE_HOME_DIR}/settings.json" - # With --init-auth, create a stub settings.json on the host if none exists, - # so it can be bind-mounted read-write and persist across sessions. - # File creation is skipped on --dry-run to keep that mode side-effect-free. - if [[ "${INIT_AUTH}" -eq 1 ]] && [[ ! -e "${_settings_path}" ]] && [[ "${DRY_RUN}" -eq 0 ]]; then - ( umask 077 && printf '{}' > "${_settings_path}" ) - echo "Created ${_settings_path} — settings will persist after this session." >&2 - fi - - # settings.json: read-write if it exists (claude may update it during the - # session). _persist_bind_wanted also emits the bind under - # --dry-run --init-auth, where the stub above is not created. - if _persist_bind_wanted "${_settings_path}"; then - BWRAP_ARGS+=(--bind "${_settings_path}" "${_settings_path}") + # settings.json can define hooks, apiKeyHelper and env that run + # UNSANDBOXED the next time claude is used on the host, so a sandboxed + # session must not be able to persist it. By default it is provided as + # an ephemeral copy via --file (like .credentials.json); only --init-auth + # binds it read-write, creating a stub when it does not exist yet. + if [[ "${INIT_AUTH}" -eq 1 ]]; then + # File creation is skipped on --dry-run to keep that mode side-effect-free. + if [[ ! -e "${_settings_path}" ]] && [[ "${DRY_RUN}" -eq 0 ]]; then + ( umask 077 && printf '{}' > "${_settings_path}" ) + echo "Created ${_settings_path} — settings will persist after this session." >&2 + fi + if _persist_bind_wanted "${_settings_path}"; then + BWRAP_ARGS+=(--bind "${_settings_path}" "${_settings_path}") + fi + elif [[ -f "${_settings_path}" ]]; then + if [[ "${DRY_RUN}" -eq 1 ]]; then + # A real fd number could not be reproduced by copy-paste. + BWRAP_ARGS+=(--file "" "${_settings_path}") + else + local _settings_fd + exec {_settings_fd}< "${_settings_path}" + BWRAP_ARGS+=(--file "${_settings_fd}" "${_settings_path}") + fi fi # Persistent read-only config file (if it exists) @@ -319,15 +339,21 @@ build_claude_home_mount() { elif [[ -f "${CLAUDE_CREDENTIALS_FILE}" ]]; then # Normal mode: copy credentials into tmpfs via --file so host auth is # available but changes don't persist. - local _fd - exec {_fd}< "${CLAUDE_CREDENTIALS_FILE}" - BWRAP_ARGS+=(--file "${_fd}" "${CLAUDE_CREDENTIALS_FILE}") + if [[ "${DRY_RUN}" -eq 1 ]]; then + BWRAP_ARGS+=(--file "" "${CLAUDE_CREDENTIALS_FILE}") + else + local _fd + exec {_fd}< "${CLAUDE_CREDENTIALS_FILE}" + BWRAP_ARGS+=(--file "${_fd}" "${CLAUDE_CREDENTIALS_FILE}") + fi fi - # State file (~/.claude.json): bind RW unconditionally. Contains only - # UI/session state (onboarding flags, feature caches, project history) — - # not a credential. Without this, $HOME is tmpfs and claude triggers - # the onboarding flow on every invocation. + # State file (~/.claude.json, or $CLAUDE_CONFIG_DIR/.claude.json): bind RW + # unconditionally. Without it, $HOME is tmpfs and claude runs the + # onboarding flow on every invocation. The file is not only UI state: it + # also holds the OAuth session (if any), per-project trust decisions and + # personal MCP server definitions, which unsandboxed claude reads too. + # Writing those from inside the sandbox is a known, not yet closed, gap. # File creation is skipped on --dry-run to keep that mode side-effect-free. if [[ ! -f "${CLAUDE_STATE_FILE}" ]] && [[ "${DRY_RUN}" -eq 0 ]]; then ( umask 077 && printf '{}' > "${CLAUDE_STATE_FILE}" ) @@ -376,16 +402,27 @@ build_claude_env() { # ignores ANTHROPIC_BASE_URL / ANTHROPIC_API_KEY, so we no longer set # those defaults. - # Foundry mode requires an API key. Fail fast with a clear message - # rather than letting Claude Code fall through to the Azure SDK - # credential chain (az login, managed identity, etc.) and emit a - # wall of CredentialUnavailableError output. - if [[ -z "${ANTHROPIC_FOUNDRY_API_KEY:-}" ]] && [[ -z "${AIFAPIM_API_KEY:-}" ]]; then - echo "Error: bwclaude (foundry mode) requires an API key." >&2 - echo "Export one of:" >&2 - echo " ANTHROPIC_FOUNDRY_API_KEY= # direct foundry key" >&2 - echo " AIFAPIM_API_KEY= # NSLS-II AIFAPIM key (preferred)" >&2 - exit 1 + # Foundry mode requires an API key and a gateway. Fail fast with a clear + # message rather than letting Claude Code fall through to the Azure SDK + # credential chain (az login, managed identity, etc.) and emit a wall of + # CredentialUnavailableError output. Only checked when claude itself is + # launched: --dry-run inspects the sandbox and --exec runs another + # command, neither needs credentials. + if [[ "${DRY_RUN}" -eq 0 ]] && [[ -z "${TEST_CMD}" ]]; then + if [[ -z "${ANTHROPIC_FOUNDRY_API_KEY:-}" ]] && [[ -z "${AIFAPIM_API_KEY:-}" ]]; then + echo "Error: bwclaude (foundry mode) requires an API key." >&2 + echo "Export one of:" >&2 + echo " ANTHROPIC_FOUNDRY_API_KEY= # direct foundry key" >&2 + echo " AIFAPIM_API_KEY= # NSLS-II AIFAPIM key (preferred)" >&2 + exit 1 + fi + if [[ -z "${ANTHROPIC_FOUNDRY_BASE_URL:-}" ]] && [[ -z "${AIFAPIM_HOST:-}" ]]; then + echo "Error: bwclaude requires AIFAPIM_HOST (or ANTHROPIC_FOUNDRY_BASE_URL) to be set." >&2 + echo "Export one of:" >&2 + echo " AIFAPIM_HOST= # NSLS-II AIFAPIM gateway hostname" >&2 + echo " ANTHROPIC_FOUNDRY_BASE_URL=https://... # full Foundry base URL override" >&2 + exit 1 + fi fi # Foundry API key: prefer host's ANTHROPIC_FOUNDRY_API_KEY; fall back to @@ -397,22 +434,24 @@ build_claude_env() { fi # Foundry base URL: prefer explicit ANTHROPIC_FOUNDRY_BASE_URL override; - # otherwise derive from AIFAPIM_HOST (required). + # otherwise derive from AIFAPIM_HOST. if [[ -n "${ANTHROPIC_FOUNDRY_BASE_URL:-}" ]]; then pass_through_if_set ANTHROPIC_FOUNDRY_BASE_URL elif [[ -n "${AIFAPIM_HOST:-}" ]]; then BWRAP_ARGS+=(--setenv ANTHROPIC_FOUNDRY_BASE_URL "https://${AIFAPIM_HOST}/anthropic") - else - echo "Error: bwclaude requires AIFAPIM_HOST (or ANTHROPIC_FOUNDRY_BASE_URL) to be set." >&2 - echo "Export one of:" >&2 - echo " AIFAPIM_HOST= # NSLS-II AIFAPIM gateway hostname" >&2 - echo " ANTHROPIC_FOUNDRY_BASE_URL=https://... # full Foundry base URL override" >&2 - exit 1 fi # Forward any host CLAUDE_* env vars (incl. user overrides for verbosity, etc.). forward_env_by_prefix CLAUDE_ + # A host CLAUDE_CONFIG_DIR is forwarded above as written; point it at the + # canonical directory the mounts were built on (--setenv: last one wins). + # When the host does not set it, neither does the sandbox, so claude uses + # ~/.claude and ~/.claude.json exactly as outside the sandbox. + if [[ -n "${CLAUDE_CONFIG_DIR:-}" ]]; then + BWRAP_ARGS+=(--setenv CLAUDE_CONFIG_DIR "${CLAUDE_HOME_DIR}") + fi + # GitHub CLI token passthrough (opt-in via --github-tokens). In # --dry-run, print_dry_run redacts the values (the names match *TOKEN*). if [[ "${FORWARD_GH_TOKENS}" -eq 1 ]]; then diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index 5ec5217..d4ee92a 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -397,10 +397,21 @@ Additional options: | `--debug` | Enable verbose debug logging: sets `ANTHROPIC_LOG=debug`, `NODE_DEBUG=http,https,tls`, and passes `--debug --verbose` to `claude` | | `--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` | -Persists `logs`, `projects`, `todos`, `statsig`, `file-history`, `plans` -(plan-mode plan files) and `sessions` under `~/.claude`, plus -`history.jsonl` and `~/.claude.json`. Other new files under `~/.claude` are -discarded on exit. +The Claude home is `~/.claude`, or `$CLAUDE_CONFIG_DIR` when that is set on the +host (it is then also set inside the sandbox, pointing at the same directory). +`bwclaude` persists `logs`, `projects`, `todos`, `statsig`, `file-history`, +`plans` (plan-mode plan files) and `sessions` there, plus `history.jsonl` and +the state file (`~/.claude.json`, or `$CLAUDE_CONFIG_DIR/.claude.json`), which +is shared with unsandboxed `claude`. Other new files under the Claude home are +discarded on exit. The state file is not only UI state: it also holds the OAuth +session (if any), per-project trust decisions and personal MCP server +definitions, so a sandboxed session can change what unsandboxed `claude` trusts +or starts. This is a known gap. + +`settings.json` can define hooks, `apiKeyHelper` and `env` that run outside +the sandbox the next time `claude` is used on the host, so by default the +sandbox only gets a throwaway copy of it. `--init-auth` bind-mounts it +read-write (creating it if needed) along with the credentials. On shared accounts, auth is ephemeral. On a personal machine, run `bwclaude --init-auth` once to persist credentials. From f629652d6c5cc3262ac4bf88e351de1fec488669 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 20 Oct 2026 13:18:10 -0400 Subject: [PATCH 25/40] docs(bw-wrappers): list --github-tokens with the common options --github-tokens is accepted by all five launchers, but docs/bw-wrappers.md repeated the same row in the bwopencode, bwclaude, bwcopilot and bwcodex sections (and described it separately for bwopencode2), while the common options table omitted it. Move it to the common table, drop the per-wrapper copies (and bwcopilot's now-empty "Additional options" table), and add it, together with --ro-path/--rw-path, to the README's common options table. Assisted-by: ClaudeCode:claude-fable-5 Assisted-by: ClaudeCode:claude-sonnet-5 Assisted-by: OpenCode:claude-opus-5-5 --- README.md | 2 ++ docs/bw-wrappers.md | 16 ++-------------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index bd5e300..6df0d6e 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,8 @@ Common wrapper options: | `--exec CMD [ARGS...]` | Run `CMD` inside the sandbox instead of the tool. `CMD` is split on whitespace; following arguments are passed unchanged, e.g. `--exec bash -c 'env \| sort'` | | `--init-auth` | Persist auth credentials to the host (first-time setup) | | `--new-session` | Force `bwrap --new-session` (stricter isolation; breaks SIGWINCH) | +| `--ro-path PATH`, `--rw-path PATH` | Mount an existing path read-only / read-write (may be repeated and nested) | +| `--github-tokens` | Forward `GH_TOKEN_*` variables so `gh` can authenticate inside the sandbox (off by default) | `bwclaude` also supports `--debug`. `bwopencode2` has no `--init-auth` (sign-in persists in its data directory), always runs OpenCode 2 with `--standalone` diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index d4ee92a..ed8fcec 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -59,6 +59,7 @@ Every `bw*` wrapper accepts these options: | `--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. | +| `--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` | `--ro-path` and `--rw-path` may be nested: a path nested under another overrides it, so `--rw-path DIR --ro-path DIR/sub` mounts `DIR` read-write but @@ -220,10 +221,6 @@ project-dotfiles to the host data dir; `auth.json` is persisted only if it already exists on the host (use `--init-auth` once to create it for personal accounts). -| Option | Description | -| --- | --- | -| `--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 @@ -270,8 +267,7 @@ bwopencode2 [bwopencode2-options] [opencode2 arguments...] 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`). +Accepts the common options above except `--init-auth`. ### Isolation from OpenCode 1 @@ -395,7 +391,6 @@ Additional options: | Option | Effect | | --- | --- | | `--debug` | Enable verbose debug logging: sets `ANTHROPIC_LOG=debug`, `NODE_DEBUG=http,https,tls`, and passes `--debug --verbose` to `claude` | -| `--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` | The Claude home is `~/.claude`, or `$CLAUDE_CONFIG_DIR` when that is set on the host (it is then also set inside the sandbox, pointing at the same directory). @@ -425,12 +420,6 @@ inside a bubblewrap sandbox. bwcopilot [bwcopilot-options] [copilot arguments...] ``` -Additional options: - -| Option | Effect | -| --- | --- | -| `--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` | - On shared accounts, auth tokens are ephemeral. On a personal machine, run `bwcopilot --init-auth` once to persist tokens. @@ -474,7 +463,6 @@ Additional options: | --- | --- | | `--debug` | Enable verbose Codex logging (sets `RUST_LOG=debug`) | | `--persist-config` | Bind-mount `~/.codex/config.toml` read-write into the sandbox so changes (e.g. project-trust grants) persist across sessions. Without this flag, `config.toml` is staged read-write into a per-session copy that is discarded on exit. Concurrent `--persist-config` sessions may race on writes | -| `--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` | Note: Codex CLI also enforces its own inner Landlock-based sandbox for agent tool calls. The outer `bwrap` here is complementary — it scopes From 6d7474ed65e969ec7dd667c423c31fb6b613c109 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 20 Oct 2026 13:25:28 -0400 Subject: [PATCH 26/40] fix(bwrap): inject sandbox secrets via env -i, not bwrap's argv Forwarded credentials (AIFAPIM_API_KEY, ANTHROPIC_FOUNDRY_API_KEY, CODEX_API_KEY, GH_TOKEN, GH_TOKEN_*, ...) were passed as `--setenv NAME VALUE` on bwrap's command line. bwrap stays resident for the whole session, and /proc//cmdline is world-readable, so any local user could read them with `ps auxww`. The --dry-run redaction on main only covered the printed text. /proc//environ, unlike cmdline, is readable only by the process owner. We already launch bwrap through `env -i`; dropping --clearenv makes bwrap forward that curated environment straight into the sandbox. The values touch only `env`'s own argv for the instant before it execs bwrap. - New SANDBOX_ENV array of NAME=VALUE entries, appended to the `env -i` line in launch_sandbox. - pass_through_if_set and forward_env_by_prefix append to SANDBOX_ENV instead of BWRAP_ARGS; so do bwclaude's ANTHROPIC_FOUNDRY_API_KEY (from AIFAPIM_API_KEY) and the OPENCODE_* allowlist loops of bwopencode and bwopencode2. Non-secret enforced overrides stay as --setenv, which bwrap applies on top of the inherited environment, so they still win. - build_env_vars drops --clearenv and the --unsetenv fallback for bwrap < 0.5 (bwrap's environment is already exactly what env -i gives it); HAS_CLEARENV is removed. launch_sandbox no longer injects LANG / LC_CTYPE defaults, which would now have reached the sandbox. - print_dry_run prints the SANDBOX_ENV entries on the `env -i` line, with secret-looking values REDACTED as before. - Drop the stale --clearenv notes from the headers, docs and README, and document the new channel under "Credentials and secrets handling". Verified with real bwrap runs: the environment seen inside the sandbox is identical before and after for all five launchers (with and without LANG set), and while a sandbox runs no process's cmdline contains the forwarded secrets (before: the bwrap process did). Assisted-by: ClaudeCode:claude-opus-4-8 Assisted-by: OpenCode:claude-opus-5-5 --- README.md | 1 - bin/bwclaude | 4 +- bin/bwcodex | 1 - bin/bwcopilot | 1 - bin/bwopencode | 3 +- bin/bwopencode2 | 8 ++-- docs/bw-wrappers.md | 16 +++++--- lib/bwrap_sandbox_lib.sh | 85 +++++++++++++++++++--------------------- 8 files changed, 58 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 6df0d6e..78ec05a 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,6 @@ Add those three exports to your `~/.bashrc` or `~/.zshrc` for persistence. - `bash` >= 4.x (the `bw*` wrappers use `[[ ]]` and arrays) - `bubblewrap` (`bwrap`) >= 0.4.0 for the `bw*` wrappers - - 0.5.0+ enables `--clearenv` - 0.6.3+ enables bind-over-ro-bind binary masking - `gpg(1)` for `gpg-passwd.sh` - `openssl(1)` for `pemdecompose` diff --git a/bin/bwclaude b/bin/bwclaude index e63ef75..82e2260 100755 --- a/bin/bwclaude +++ b/bin/bwclaude @@ -3,7 +3,6 @@ # # 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) # # The sandbox provides filesystem isolation: @@ -430,7 +429,8 @@ build_claude_env() { if [[ -n "${ANTHROPIC_FOUNDRY_API_KEY:-}" ]]; then pass_through_if_set ANTHROPIC_FOUNDRY_API_KEY elif [[ -n "${AIFAPIM_API_KEY:-}" ]]; then - BWRAP_ARGS+=(--setenv ANTHROPIC_FOUNDRY_API_KEY "${AIFAPIM_API_KEY}") + # Through SANDBOX_ENV, not --setenv: keeps the key off bwrap's argv. + SANDBOX_ENV+=("ANTHROPIC_FOUNDRY_API_KEY=${AIFAPIM_API_KEY}") fi # Foundry base URL: prefer explicit ANTHROPIC_FOUNDRY_BASE_URL override; diff --git a/bin/bwcodex b/bin/bwcodex index 706e8cb..d718f1f 100755 --- a/bin/bwcodex +++ b/bin/bwcodex @@ -3,7 +3,6 @@ # # 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) # # Two approved auth paths are supported (configured in ~/.codex/config.toml): diff --git a/bin/bwcopilot b/bin/bwcopilot index 24ebd5c..22c77a2 100755 --- a/bin/bwcopilot +++ b/bin/bwcopilot @@ -3,7 +3,6 @@ # # 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) # # The sandbox provides filesystem isolation: diff --git a/bin/bwopencode b/bin/bwopencode index 85aa88a..45a9e8f 100755 --- a/bin/bwopencode +++ b/bin/bwopencode @@ -3,7 +3,6 @@ # # 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) # # The sandbox provides filesystem isolation: @@ -469,7 +468,7 @@ build_opencode_env() { fi done if [[ "${_ok}" -eq 1 ]]; then - BWRAP_ARGS+=(--setenv "${_key}" "${!_key}") + SANDBOX_ENV+=("${_key}=${!_key}") else _dropped+=("${_key}") fi diff --git a/bin/bwopencode2 b/bin/bwopencode2 index 20e5948..790d914 100755 --- a/bin/bwopencode2 +++ b/bin/bwopencode2 @@ -3,7 +3,6 @@ # # 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 the ansible base @@ -604,7 +603,8 @@ build_opencode2_install_mount() { } # 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), +# host pass-through from build_env_vars (--setenv overrides the forwarded +# environment), # so OpenCode 2 uses its own directories even if `opencode2` on PATH is a # plain binary rather than the ansible wrapper. build_opencode2_env() { @@ -626,7 +626,7 @@ build_opencode2_env() { # AIFAPIM gateway config pass_through_if_set AIFAPIM_HOST # The API key is passed through unchanged; print_dry_run redacts the value - # of any --setenv whose name looks secret (KEY/TOKEN/PASSWORD/...). + # of any variable whose name looks secret (KEY/TOKEN/PASSWORD/...). pass_through_if_set AIFAPIM_API_KEY local _key _allowed _name @@ -641,7 +641,7 @@ build_opencode2_env() { fi done if [[ "${_allowed}" -eq 1 ]]; then - BWRAP_ARGS+=(--setenv "${_key}" "${!_key}") + SANDBOX_ENV+=("${_key}=${!_key}") else _dropped+=("${_key}") fi diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index ed8fcec..7e81a95 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -16,7 +16,6 @@ option list; the tables below summarise the common surface. - `bash` >= 4.x (the wrappers use `[[ ]]` and arrays) - `bubblewrap` (`bwrap`) >= 0.4.0 - - 0.5.0+ enables `--clearenv` - 0.6.3+ enables bind-over-ro-bind binary masking ## Managed system paths @@ -203,10 +202,17 @@ cases. 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 - printed text is redacted; the real launch passes the real values. +- **Forwarded variables stay off the command line.** Values passed through + from the host (API keys, `GH_TOKEN_*`, ...) are given to `bwrap` in its + environment via `env -i`, not as `--setenv` arguments, so they do not + appear in `ps` output for the running sandbox (`/proc//environ` is + readable only by you). They are visible on `env`'s own command line only + for the instant before it starts `bwrap`. +- **`--dry-run` redacts secrets.** Every printed variable (on the `env -i` + line or as `--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` diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index 417d7da..ab7d1c8 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -29,7 +29,6 @@ # # 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) # Guard against double-sourcing. @@ -78,9 +77,13 @@ _NPM_PREFIX_RESOLVED=0 # Sandbox environment SANDBOX_PATH="" SANDBOX_SHELL="" +# NAME=VALUE entries for variables forwarded from the host (pass_through_if_set, +# forward_env_by_prefix) and other values that may be secret. They are given +# to bwrap through `env -i` (see launch_sandbox) rather than as --setenv +# arguments, so they never appear on bwrap's command line. +SANDBOX_ENV=() # bwrap capability flags (set by detect_bwrap_capabilities) -HAS_CLEARENV=0 HAS_BIND_OVER_RO=0 # Kernel capability flags (set by detect_kernel_capabilities) @@ -183,18 +186,12 @@ _version_ge() { # grepping --help. # # Version history: -# 0.5.0 - added --clearenv # 0.6.3 - bind over ro-bind works (can mask binaries inside /usr) detect_bwrap_capabilities() { local _bwrap_ver _bwrap_ver="$(bwrap --version)" _bwrap_ver="${_bwrap_ver##* }" # "bubblewrap 0.11.0" -> "0.11.0" - # --clearenv: Start with an empty environment (added in 0.5.0) - # Fallback: manually unset all host env vars with --unsetenv - HAS_CLEARENV=0 - if _version_ge "${_bwrap_ver}" "0.5.0"; then HAS_CLEARENV=1; fi - # Bind over ro-bind: ability to bind-mount on top of a read-only bind # mount (e.g., masking /usr/bin/ssh after --ro-bind /usr /usr). # This works in 0.6.3+; earlier versions fail with "Permission denied". @@ -975,19 +972,24 @@ parse_all_git_includes() { # ── Env var passthrough helper ─────────────────────────────────── +# pass_through_if_set NAME +# Forward the host variable NAME into the sandbox when it is non-empty. The +# value goes into SANDBOX_ENV (bwrap's environment), not onto bwrap's command +# line. A later `--setenv NAME` in BWRAP_ARGS still overrides it. pass_through_if_set() { local var_name="$1" local var_val="${!var_name:-}" if [[ -n "${var_val}" ]]; then - BWRAP_ARGS+=(--setenv "${var_name}" "${var_val}") + SANDBOX_ENV+=("${var_name}=${var_val}") fi } # forward_env_by_prefix PREFIX -# Append a --setenv entry to BWRAP_ARGS for every exported environment -# variable whose name starts with PREFIX. Uses `while IFS= read -r` over -# `compgen -e`, so names are never word-split. Values are passed as-is; -# print_dry_run redacts names that match _is_secret_env_name. +# Forward every exported environment variable whose name starts with PREFIX +# (as NAME=VALUE entries in SANDBOX_ENV, like pass_through_if_set). Uses +# `while IFS= read -r` over `compgen -e`, so names are never word-split. +# Values are passed as-is; print_dry_run redacts names that match +# _is_secret_env_name. # # Example: # forward_env_by_prefix GH_TOKEN_ # GH_TOKEN_NSLS2, GH_TOKEN_PERSONAL, ... @@ -995,7 +997,7 @@ forward_env_by_prefix() { local _prefix="$1" _key while IFS= read -r _key; do if [[ "${_key}" == "${_prefix}"* ]]; then - BWRAP_ARGS+=(--setenv "${_key}" "${!_key}") + SANDBOX_ENV+=("${_key}=${!_key}") fi done < <(compgen -e) } @@ -2335,33 +2337,12 @@ build_dynamic_tool_mounts() { } # ── Environment variables ──────────────────────────────────────── -# Start from a clean environment and only pass through what we need. +# The sandbox environment is exactly bwrap's environment, which +# launch_sandbox builds from scratch with `env -i` (a few base variables plus +# SANDBOX_ENV), so no host variable reaches it unless passed through here. +# bwrap runs WITHOUT --clearenv so that environment is handed to the sandbox; +# the --setenv entries below are applied on top of it and take precedence. build_env_vars() { - if [[ "${HAS_CLEARENV}" -eq 1 ]]; then - BWRAP_ARGS+=(--clearenv) - else - # Fallback for bwrap < 0.5.0: manually unset all host env vars. - # We use compgen -e to list all exported vars and unset each one. - # Note: we already exec with env -i, but --unsetenv ensures any - # vars inherited through other means are also cleared. - # - # Read line-by-line (not `for x in $(...)`) so names are not - # word-split on IFS, and skip anything that is not a portable - # shell identifier — bash exports function definitions under names - # like `BASH_FUNC_module%%`, which would become invalid --unsetenv - # arguments. - # - # On HPC login nodes the exported-var count can reach the hundreds; - # each becomes a separate --unsetenv pair, so this path can produce - # a long bwrap command line. That is unavoidable here and harmless - # (well under ARG_MAX); the modern --clearenv path above avoids it. - local _envvar - while IFS= read -r _envvar; do - [[ "${_envvar}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue - BWRAP_ARGS+=(--unsetenv "${_envvar}") - done < <(compgen -e) - fi - # Always set BWRAP_ARGS+=( --setenv HOME "${_HOME}" @@ -2500,14 +2481,23 @@ print_dry_run() { _ENV_BIN="$(resolve_env_bin)" # Mirror the exact invocation used by launch_sandbox: - # exec -i -- <_TOOL_CMD> + # exec -i -- <_TOOL_CMD> + # Values of secret-looking variables (_is_secret_env_name) are printed as + # REDACTED, on the env line and in --setenv; only the printed text changes. printf "exec %s -i \\\\\n" "${_ENV_BIN}" printf " HOME=%q \\\\\n" "${_HOME}" printf " USER=%q \\\\\n" "${_USER}" printf " LOGNAME=%q \\\\\n" "${_USER}" printf " PATH=%q \\\\\n" "/usr/bin:/bin" - printf " LANG=%q \\\\\n" "${LANG:-C.UTF-8}" - printf " LC_CTYPE=%q \\\\\n" "${LC_CTYPE:-C.UTF-8}" + local _entry _name + for _entry in ${SANDBOX_ENV[@]+"${SANDBOX_ENV[@]}"}; do + _name="${_entry%%=*}" + if _is_secret_env_name "${_name}"; then + printf " %s='REDACTED' \\\\\n" "${_name}" + else + printf " %s=%q \\\\\n" "${_name}" "${_entry#*=}" + fi + done printf " %s \\\\\n" "${_BWRAP_BIN}" # Print bwrap arguments one flag-plus-values per line. Each flag's @@ -2547,6 +2537,12 @@ print_dry_run() { # Launch bwrap itself with a scrubbed environment so /proc/1/environ # does not leak host variables (SSH_AUTH_SOCK, DBUS_SESSION_BUS_ADDRESS, etc.). # +# Forwarded values (SANDBOX_ENV, which includes API keys and tokens) travel in +# bwrap's environment, not on its command line: /proc//cmdline of the +# long-running bwrap process is readable by every local user (`ps`), while +# /proc//environ is readable only by its owner. They are on `env`'s own +# command line only for the instant before it execs bwrap. +# # `env` is resolved via resolve_env_bin (prefers /usr/bin/env) rather than # via PATH lookup at exec time — this prevents a user shell script named # "env" earlier on PATH from hijacking the launch. See resolve_env_bin. @@ -2560,7 +2556,6 @@ launch_sandbox() { USER="${_USER}" \ LOGNAME="${_USER}" \ PATH="/usr/bin:/bin" \ - LANG="${LANG:-C.UTF-8}" \ - LC_CTYPE="${LC_CTYPE:-C.UTF-8}" \ + ${SANDBOX_ENV[@]+"${SANDBOX_ENV[@]}"} \ "${_BWRAP_BIN}" "${BWRAP_ARGS[@]}" -- "${_TOOL_CMD[@]}" ${TOOL_ARGS[@]+"${TOOL_ARGS[@]}"} } From db52f7c2e4dfbeaf5276820c4f7e9a82e63c854e Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 20 Oct 2026 13:36:29 -0400 Subject: [PATCH 27/40] feat(bwrap): mount personal skills directories The personal skills directories the tools read were mostly missing from the sandboxes: $HOME is a tmpfs and only the deployment-managed skills (/etc/*/skills, /opt/nsls2/skills) were mounted. bwcodex and bwopencode bound ~/.codex/skills and ~/.config/opencode/skills as one unit, so a skill symlinked in from a separate repository dangled inside the sandbox. Add build_personal_skills_mounts DIR... to the library. Each existing DIR is scanned one level deep and every entry (one skill) is resolved with readlink -f and bound read-write at its logical path, so the target repository does not need to be mounted and the agent can update existing skills (for a symlinked skill, that edits its source repository). Only existing entries are host-backed: a new top-level skill created inside the sandbox lands on the tmpfs and is discarded on exit. An entry that resolves into a blocked location, or is a directory that contains one, is skipped with a warning; a DIR already covered by an earlier bind is left alone. - bwclaude: $CLAUDE_CONFIG_DIR/skills (default ~/.claude/skills) - bwcodex: ~/.codex/skills (replacing its whole-directory bind) and ~/.agents/skills - bwcopilot: ~/.agents/skills (~/.copilot/skills stays read-write with the Copilot home) - bwopencode: ~/.config/opencode/skills (now skipped by resolve_config_symlinks), ~/.claude/skills, ~/.agents/skills - bwopencode2: ~/.config/opencode2/opencode/skills (skipped by its resolve_config_symlinks), ~/.claude/skills, ~/.agents/skills The deployment-managed skills mounts are unchanged. Documented in the launcher headers and a new "Personal skills directories" section of docs/bw-wrappers.md. The bwcodex header list is also reordered so the /etc/codex entries no longer sit inside the ~/.codex sub-list. Assisted-by: opencode:claude-sonnet-5 Assisted-by: OpenCode:claude-opus-5-5 --- bin/bwclaude | 4 +++ bin/bwcodex | 19 +++++++------- bin/bwcopilot | 5 ++++ bin/bwopencode | 8 ++++++ bin/bwopencode2 | 8 ++++++ docs/bw-wrappers.md | 24 +++++++++++++++++ lib/bwrap_sandbox_lib.sh | 56 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 114 insertions(+), 10 deletions(-) diff --git a/bin/bwclaude b/bin/bwclaude index 82e2260..e8fa36c 100755 --- a/bin/bwclaude +++ b/bin/bwclaude @@ -28,6 +28,9 @@ # the OAuth session (if any), per-project trust decisions and personal MCP # server definitions. KNOWN GAP: a session can add an MCP server or a trust # decision here that unsandboxed claude will honour later. +# - Personal skills (~/.claude/skills, or $CLAUDE_CONFIG_DIR/skills): +# read-write (if present); each skill is resolved individually, so skills +# symlinked in from elsewhere (e.g. a shared skills repository) work too # - Claude cache ($XDG_CACHE_HOME/claude, default ~/.cache/claude): read-write # - Git config + includes: read-only # - Pixi cache: read-write @@ -531,6 +534,7 @@ main() { build_home_tmpfs build_workdir_mount build_claude_home_mount + build_personal_skills_mounts "${CLAUDE_HOME_DIR}/skills" build_claude_cache_mount build_git_mounts build_pixi_mounts diff --git a/bin/bwcodex b/bin/bwcodex index d718f1f..a16e3e7 100755 --- a/bin/bwcodex +++ b/bin/bwcodex @@ -32,17 +32,19 @@ # bind-mounted RW so writes persist across sessions at the cost of # weaker isolation. Either the user file or the system file # (/etc/codex/config.toml) is sufficient for fail-fast. -# - /etc/codex/config.toml: system-wide provider/defaults config (if -# present); bind-mounted read-only. Codex layers user config on -# top of this file. -# - /etc/codex/requirements.toml: admin-enforced restrictions (if -# present); bind-mounted read-only. Users cannot loosen these. # - AGENTS.md: bind-mounted read-only (if it exists) -# - skills/: bind-mounted read-only (if it exists) # - packages/: bind-mounted read-only (if it exists, installer cache) # - auth.json: bind-mounted read-write only if it already exists on host; # --init-auth creates a {} stub for first-time ChatGPT-login setup # - Everything else (tmp/, state.toml, features/, ...): tmpfs, discarded on exit +# - Personal skills (~/.codex/skills, ~/.agents/skills): read-write (if +# present); each skill is resolved individually, so skills symlinked in +# from elsewhere (e.g. a shared skills repository) work too +# - /etc/codex/config.toml: system-wide provider/defaults config (if +# present); bind-mounted read-only. Codex layers user config on +# top of this file. +# - /etc/codex/requirements.toml: admin-enforced restrictions (if +# present); bind-mounted read-only. Users cannot loosen these. # - Codex CLI binary + npm prefix: read-only (handled by build_dynamic_tool_mounts) # - Git config + includes: read-only # - Pixi cache: read-write @@ -385,10 +387,6 @@ build_codex_home_mount() { BWRAP_ARGS+=(--ro-bind "${CODEX_AGENTS_FILE}" "${CODEX_AGENTS_FILE}") fi - if [[ -d "${CODEX_SKILLS_DIR}" ]]; then - BWRAP_ARGS+=(--ro-bind "${CODEX_SKILLS_DIR}" "${CODEX_SKILLS_DIR}") - fi - if [[ -d "${CODEX_PACKAGES_DIR}" ]]; then BWRAP_ARGS+=(--ro-bind "${CODEX_PACKAGES_DIR}" "${CODEX_PACKAGES_DIR}") fi @@ -521,6 +519,7 @@ main() { build_home_tmpfs build_workdir_mount build_codex_home_mount + build_personal_skills_mounts "${CODEX_SKILLS_DIR}" "${_HOME}/.agents/skills" build_git_mounts build_pixi_mounts build_ccache_mounts diff --git a/bin/bwcopilot b/bin/bwcopilot index 22c77a2..9929896 100755 --- a/bin/bwcopilot +++ b/bin/bwcopilot @@ -15,6 +15,10 @@ # - installed-plugins/: bind-mounted read-only (prevents agent from installing plugins) # - config.json: COPIED into tmpfs (host auth available, changes ephemeral) # - Use --init-auth to make config.json persistent (for personal machines) +# - Personal skills (~/.agents/skills): read-write (if present); each skill +# is resolved individually, so skills symlinked in from elsewhere (e.g. a +# shared skills repository) work too (~/.copilot/skills is the skills/ +# bind above) # - Copilot cache (~/.cache/copilot or $COPILOT_CACHE_HOME): read-write # - GitHub CLI auth (~/.config/gh/hosts.yml): read-only (if it exists) # - Git config + includes: read-only @@ -440,6 +444,7 @@ main() { build_home_tmpfs build_workdir_mount build_copilot_home_mount + build_personal_skills_mounts "${_HOME}/.agents/skills" build_copilot_cache_mount build_gh_auth_mount build_git_mounts diff --git a/bin/bwopencode b/bin/bwopencode index 45a9e8f..2ff53ab 100755 --- a/bin/bwopencode +++ b/bin/bwopencode @@ -8,6 +8,10 @@ # The sandbox provides filesystem isolation: # - Current directory: read-write # - OpenCode config: read-only (symlinks resolved) +# - Personal skills (~/.config/opencode/skills, ~/.claude/skills, +# ~/.agents/skills): read-write (if present); each skill is resolved +# individually, so skills symlinked in from elsewhere (e.g. a shared +# skills repository) work too # - OpenCode data dir: ephemeral tmpfs with selective persistence # - auth.json: persisted only if it already exists on host; # otherwise created in tmpfs (discarded on exit) @@ -249,6 +253,9 @@ resolve_config_symlinks() { local entry local_resolved for entry in "${OC_CONFIG_DIR}"/*; do [[ -e "${entry}" ]] || continue + # Personal skills are mounted by build_personal_skills_mounts, one + # skill at a time, so symlinked skills resolve in the sandbox. + [[ "${entry##*/}" == skills ]] && continue _bwrap_canonicalize_safe "${entry}" "opencode config entry '${entry}'" local_resolved="${_BWRAP_CANON}" OC_CONFIG_BINDS+=("--ro-bind" "${local_resolved}" "${entry}") @@ -526,6 +533,7 @@ main() { build_workdir_mount build_persistent_dotdir build_opencode_config_mount + build_personal_skills_mounts "${OC_CONFIG_DIR}/skills" "${_HOME}/.claude/skills" "${_HOME}/.agents/skills" build_opencode_data_mounts build_opencode_cache_mount build_git_mounts diff --git a/bin/bwopencode2 b/bin/bwopencode2 index 790d914..e2b7bb6 100755 --- a/bin/bwopencode2 +++ b/bin/bwopencode2 @@ -13,6 +13,10 @@ # - Current directory: read-write # - OpenCode 2 config (~/.config/opencode2/opencode): read-only (symlinks resolved); # service.json / service-*.json (background-service settings) are not mounted +# - Personal skills (~/.config/opencode2/opencode/skills, ~/.claude/skills, +# ~/.agents/skills): read-write (if present); each skill is resolved +# individually, so skills symlinked in from elsewhere (e.g. a shared +# skills repository) work too # - 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 @@ -391,6 +395,9 @@ resolve_config_symlinks() { # file is not mounted. case "${entry##*/}" in service*.json) continue ;; + # Personal skills are mounted by build_personal_skills_mounts, one + # skill at a time, so symlinked skills resolve in the sandbox. + skills) continue ;; esac _bwrap_canonicalize_safe "${entry}" "opencode2 config entry '${entry}'" local_resolved="${_BWRAP_CANON}" @@ -714,6 +721,7 @@ main() { build_workdir_mount build_persistent_dotdir build_opencode2_config_mount + build_personal_skills_mounts "${OC2_CONFIG_DIR}/skills" "${_HOME}/.claude/skills" "${_HOME}/.agents/skills" build_opencode2_data_mount build_opencode2_cache_mount build_opencode2_state_dir diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index 7e81a95..b7ded84 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -45,6 +45,30 @@ read-only. Contribute or update skills through the deployment's documented contribution workflow; do not edit its deployed checkout or managed aliases directly. +## Personal skills directories + +Each wrapper also mounts, read-write and only when they exist, the personal +skills directories its tool reads: + +| Wrapper | Personal skills directories | +| --- | --- | +| `bwopencode` | `~/.config/opencode/skills`, `~/.claude/skills`, `~/.agents/skills` | +| `bwopencode2` | `~/.config/opencode2/opencode/skills`, `~/.claude/skills`, `~/.agents/skills` | +| `bwclaude` | `~/.claude/skills` (`$CLAUDE_CONFIG_DIR/skills` when that is set) | +| `bwcopilot` | `~/.agents/skills`; `~/.copilot/skills` is mounted read-write with the Copilot home | +| `bwcodex` | `~/.codex/skills`, `~/.agents/skills` | + +Each directory is scanned one level deep and every entry (one skill) is +resolved with `readlink -f` and mounted at its usual path. A common setup keeps +skills in a separate repository and symlinks each one in (for example +`~/.agents/skills/some-skill -> ~/src/.../skills/some-skill`); that repository +does not have to be mounted for the skill to work in the sandbox. An entry that +resolves into a blocked location (such as `~/.ssh`), or is a directory that +contains one, is skipped with a warning. Existing skills can be edited from +inside the sandbox (for a symlinked skill, that edits its source repository), +but a new top-level skill created there is discarded on exit, and skills added +on the host while a sandbox is running do not appear in it. + ## Common options Every `bw*` wrapper accepts these options: diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index ab7d1c8..c2dfadd 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -2293,6 +2293,62 @@ build_user_bin_mount() { fi } +# ── Personal skills directories (read-write) ──────────────────── +# build_personal_skills_mounts DIR... +# Make the user's personal skills directories (the ones the tool reads, +# e.g. ~/.claude/skills or ~/.agents/skills) available read-write, so the +# agent can update existing skills. Missing directories are skipped. +# +# Each DIR is scanned one level deep and every entry (one skill) is +# resolved with readlink -f and bound at its logical path, instead of +# binding DIR as one unit. A common layout keeps the skills in a separate +# repository and symlinks each one in (~/.agents/skills/x -> ~/src/.../x); +# that repository is not otherwise mounted, so binding DIR whole would +# leave every such skill dangling. An entry that resolves into a blocked +# location, or is a directory containing one, is skipped with a warning. +# A DIR already covered by an earlier bind (e.g. inside the working +# directory) is left alone. Only the existing entries are host-backed: a +# new top-level skill created inside the sandbox lands on the tmpfs and is +# discarded on exit. +# +# Must be called AFTER the tool's own home mount: that mount may be a tmpfs +# that would hide a skills directory bound underneath it. +build_personal_skills_mounts() { + local _dir _entry _real _check _covered + for _dir in "$@"; do + [[ -d "${_dir}" ]] || continue + + _covered=0 + _check="${_dir}" + while :; do + if [[ -n "${_MOUNTED_PREFIXES["${_check}"]:-}" ]]; then + _covered=1 + break + fi + [[ "${_check}" == "/" ]] && break + _check="${_check%/*}" + [[ -n "${_check}" ]] || _check="/" + done + [[ "${_covered}" -eq 0 ]] || continue + + _emit_home_intermediate_dirs "${_dir}" inclusive + for _entry in "${_dir}"/*; do + [[ -e "${_entry}" ]] || continue + _real="$(readlink -f -- "${_entry}" 2> /dev/null || true)" + [[ -n "${_real}" ]] || continue + # _check_dir_contains_blocked exits on a hit, so it runs in a + # subshell here to skip the entry instead of aborting the launch. + if _bwrap_path_blocked "${_real}" || + { [[ -d "${_real}" ]] && ! (_check_dir_contains_blocked "${_real}" "skill") 2> /dev/null; }; then + echo "Warning: skill '${_entry}' resolves into or contains a blocked location (${_real}); not mounted." >&2 + continue + fi + BWRAP_ARGS+=(--bind "${_real}" "${_entry}") + done + _MOUNTED_PREFIXES["${_dir}"]=1 + done +} + # ── NSLS-II software (read-only) ──────────────────────────────── # Only bind if the directory exists on the host system. build_nsls2_mount() { From 1bd5843dca3cc46e7ba7e9a2844bc14f46c15624 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 20 Oct 2026 14:44:33 -0400 Subject: [PATCH 28/40] FIX: deal with changes to codex and its desire to launch a daemon Assisted-by: codex:gpt-6-sol --- bin/bwcodex | 42 ++++++++++++++++++++++++++++++------------ docs/bw-wrappers.md | 6 ++++++ 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/bin/bwcodex b/bin/bwcodex index a16e3e7..1a5b098 100755 --- a/bin/bwcodex +++ b/bin/bwcodex @@ -33,7 +33,8 @@ # weaker isolation. Either the user file or the system file # (/etc/codex/config.toml) is sufficient for fail-fast. # - AGENTS.md: bind-mounted read-only (if it exists) -# - packages/: bind-mounted read-only (if it exists, installer cache) +# - packages/: tmpfs; managed daemon package copied from the CLI on each run +# - app-server-daemon/settings.json: ephemeral, disables daemon auto-update # - auth.json: bind-mounted read-write only if it already exists on host; # --init-auth creates a {} stub for first-time ChatGPT-login setup # - Everything else (tmp/, state.toml, features/, ...): tmpfs, discarded on exit @@ -45,7 +46,7 @@ # top of this file. # - /etc/codex/requirements.toml: admin-enforced restrictions (if # present); bind-mounted read-only. Users cannot loosen these. -# - Codex CLI binary + npm prefix: read-only (handled by build_dynamic_tool_mounts) +# - Codex CLI package (including metadata and resources): read-only # - Git config + includes: read-only # - Pixi cache: read-write # - Pixi config: read-only @@ -267,7 +268,7 @@ resolve_codex_paths() { CODEX_LOG_DIR="${CODEX_HOME_DIR}/log" CODEX_SKILLS_DIR="${CODEX_HOME_DIR}/skills" CODEX_HISTORY_FILE="${CODEX_HOME_DIR}/history.jsonl" - CODEX_PACKAGES_DIR="${CODEX_HOME_DIR}/packages" + CODEX_DAEMON_SETTINGS_FILE="${CODEX_HOME_DIR}/app-server-daemon/settings.json" # Validate the user-controllable root directory once here so that all # sub-path mounts derived from it are implicitly covered. @@ -387,8 +388,19 @@ build_codex_home_mount() { BWRAP_ARGS+=(--ro-bind "${CODEX_AGENTS_FILE}" "${CODEX_AGENTS_FILE}") fi - if [[ -d "${CODEX_PACKAGES_DIR}" ]]; then - BWRAP_ARGS+=(--ro-bind "${CODEX_PACKAGES_DIR}" "${CODEX_PACKAGES_DIR}") + # Keep the managed daemon package in tmpfs. The complete CLI package + # mounted by build_codex_bin_mount seeds it on each launch. Disable the + # daemon's updater before Codex starts so it does not fetch a newer copy. + BWRAP_ARGS+=(--perms 0700 --dir "${CODEX_HOME_DIR}/app-server-daemon") + if [[ "${DRY_RUN}" -eq 1 ]]; then + BWRAP_ARGS+=(--perms 0600 --file "" "${CODEX_DAEMON_SETTINGS_FILE}") + else + local _settings_fd _staged_settings + _staged_settings="$(mktemp --tmpdir bwcodex-daemon-settings.XXXXXX.json)" + printf '{"updater":{"autoUpdateEnabled":false}}\n' > "${_staged_settings}" + exec {_settings_fd}< "${_staged_settings}" + rm -f "${_staged_settings}" + BWRAP_ARGS+=(--perms 0600 --file "${_settings_fd}" "${CODEX_DAEMON_SETTINGS_FILE}") fi # auth.json: only persist if it already exists on the host. @@ -471,14 +483,20 @@ build_codex_env() { fi } -# ── Codex install dir (stub) ───────────────────────────────────── -# Codex CLI is installed via npm at /usr/local/lib/node_modules/@openai/codex, -# which build_dynamic_tool_mounts already covers via the npm-prefix mount. -# This stub is kept for symmetry with bwclaude/bwopencode and as a future -# hook for non-npm Codex installs (e.g. the standalone installer's -# /opt/codex layout). +# ── Complete Codex CLI package ────────────────────────────────── +# A packaged CLI needs codex-package.json and its sibling resource dirs to +# seed a managed app-server daemon. The dynamic tool mount only sees the +# binary directory for a symlink such as /usr/bin/codex -> +# /opt/openai-codex/bin/codex. +# Mount the entire package when its metadata is present; npm installations +# are already covered by the dynamic npm-prefix mount. build_codex_bin_mount() { - : + local _real_bin _package_dir + _real_bin="$(readlink -f -- "${_TOOL_BIN}")" + _package_dir="$(dirname "$(dirname "${_real_bin}")")" + if [[ -f "${_package_dir}/codex-package.json" ]]; then + _mount_ro_dir_if_needed "${_package_dir}" + fi } # ═══════════════════════════════════════════════════════════════════ diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index b7ded84..3f0f544 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -487,6 +487,12 @@ Two approved auth paths (configured in `~/.codex/config.toml`): tokens to `~/.codex/auth.json`. Run once with `--init-auth` to make that file persist across sandbox invocations. +The wrapper keeps `~/.codex/packages` in its ephemeral Codex home. When the +daemon starts, Codex copies its package from the system CLI, which the wrapper +mounts read-only with its metadata and resources. An ephemeral +`app-server-daemon/settings.json` disables automatic daemon package updates. +The package copy is discarded when the sandbox exits. + Additional options: | Option | Effect | From 1dd4b28a3553ee2ddb7abfe20a8c6cc4a38b07a0 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 20 Oct 2026 15:07:09 -0400 Subject: [PATCH 29/40] STY: do not confuse shellcheck Assisted-by: opencode:claude-sonnet-5.5 --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8bff403..08358e1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -82,7 +82,7 @@ jobs: ACTIONLINT_VER=$(grep -A1 'repo: https://github.com/rhysd/actionlint' .pre-commit-config.yaml | grep 'rev:' | awk '{print $2}') # shfmt: pre-commit-shfmt adds -N build numbers (v3.12.0-2 -> v3.12.0) SHFMT_VER=$(grep -A1 'repo: https://github.com/scop/pre-commit-shfmt' .pre-commit-config.yaml | grep 'rev:' | awk '{print $2}' | sed 's/-[0-9]\+$//') - # shellcheck-py: Python wrapper adds .N to upstream shellcheck version (v0.11.0.1 -> v0.11.0) + # eratta: shellcheck-py: Python wrapper adds .N to upstream shellcheck version (v0.11.0.1 -> v0.11.0) SHELLCHECK_VER=$(grep -A1 'repo: https://github.com/shellcheck-py/shellcheck-py' .pre-commit-config.yaml | grep 'rev:' | awk '{print $2}' | sed 's/\.[0-9]\+$//') GITLEAKS_VER=$(grep -A1 'repo: https://github.com/gitleaks/gitleaks' .pre-commit-config.yaml | grep 'rev:' | awk '{print $2}') From 76af27af9ef4f7739d5a2454aca064b0bce006e7 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 20 Oct 2026 15:07:36 -0400 Subject: [PATCH 30/40] MNT: move from pre-commit to prek pre-commit was failing in obscure ways locally, moving to better tool was easier than debugging a tool we should move off of anyway. Assisted-by: opencode:claude-sonnet-5.5 --- .github/workflows/lint.yml | 22 +-- .pre-commit-config.yaml | 2 +- pixi.lock | 382 +++++-------------------------------- pixi.toml | 13 +- 4 files changed, 70 insertions(+), 349 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 08358e1..3abb591 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -51,7 +51,7 @@ jobs: echo "to_ref=${SHA}" >> "$GITHUB_OUTPUT" elif [[ "$EVENT_NAME" == "push" ]]; then # On branch creation, $BEFORE is 40 zeros; after force-push, the old SHA - # may be unfetchable. Both break pre-commit --from-ref, so fall back to --all-files. + # may be unfetchable. Both break prek --from-ref, so fall back to --all-files. if [[ "$BEFORE" =~ ^0+$ ]] || ! git cat-file -e "$BEFORE" 2>/dev/null; then echo "from_ref=" >> "$GITHUB_OUTPUT" echo "to_ref=" >> "$GITHUB_OUTPUT" @@ -101,32 +101,32 @@ jobs: echo "gitleaks=$GITLEAKS_VER" } >> "$GITHUB_OUTPUT" - - name: Run pre-commit (changed files) - id: pre_commit + - name: Run prek (changed files) + id: prek env: FROM_REF: ${{ steps.refs.outputs.from_ref }} TO_REF: ${{ steps.refs.outputs.to_ref }} run: | set -o pipefail if [[ -n "$FROM_REF" ]]; then - pixi run -e dev pre-commit run \ + pixi run -e dev prek run \ --from-ref "$FROM_REF" \ --to-ref "$TO_REF" \ --show-diff-on-failure \ --color always \ - 2>&1 | tee /tmp/pre-commit-output.txt + 2>&1 | tee /tmp/prek-output.txt else - pixi run -e dev pre-commit run \ + pixi run -e dev prek run \ --all-files \ --show-diff-on-failure \ --color always \ - 2>&1 | tee /tmp/pre-commit-output.txt + 2>&1 | tee /tmp/prek-output.txt fi - name: Write job summary if: always() env: - PRE_COMMIT_OUTCOME: ${{ steps.pre_commit.outcome }} + PREK_OUTCOME: ${{ steps.prek.outcome }} FROM_REF: ${{ steps.refs.outputs.from_ref }} TO_REF: ${{ steps.refs.outputs.to_ref }} RUFF_VER: ${{ steps.versions.outputs.ruff }} @@ -141,7 +141,7 @@ jobs: SHELLCHECK_VER: ${{ steps.versions.outputs.shellcheck }} GITLEAKS_VER: ${{ steps.versions.outputs.gitleaks }} run: | - if [[ "$PRE_COMMIT_OUTCOME" == "success" ]]; then + if [[ "$PREK_OUTCOME" == "success" ]]; then BADGE="All linting hooks passed" else BADGE="One or more linting hooks failed" @@ -176,11 +176,11 @@ jobs: echo "| shellcheck | shellcheck | ${SHELLCHECK_VER} |" echo "| gitleaks | gitleaks | ${GITLEAKS_VER} |" echo "" - if [[ -s /tmp/pre-commit-output.txt ]]; then + if [[ -s /tmp/prek-output.txt ]]; then echo "### Output" echo "" echo '```' - sed 's/\x1b\[[0-9;]*m//g' /tmp/pre-commit-output.txt + sed 's/\x1b\[[0-9;]*m//g' /tmp/prek-output.txt echo '```' fi } >> "$GITHUB_STEP_SUMMARY" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b8f0c24..24abc86 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ # commit and in CI via the Lint workflow # (.github/workflows/lint.yml). Tool versions are pinned in # pixi.toml [feature.dev.dependencies] for the pixi-dispatched hooks -# and via `rev:` for the upstream pre-commit-managed hooks. +# and via `rev:` for the upstream prek-managed hooks. # # Linter configs live at the repository root and are auto-discovered # by each tool by its conventional dotfile name. The only exception is diff --git a/pixi.lock b/pixi.lock index 548624b..74fb48b 100644 --- a/pixi.lock +++ b/pixi.lock @@ -108,7 +108,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ast-serialize-0.5.0-py310hd8a072f_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py313hf46b229_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda @@ -124,71 +123,43 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/mypy-2.1.0-py313hc25a8b5_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/prek-0.5.6-hf19af3b_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.2.2-py313h54dd161_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.13.14-h6add32d_100_cp313.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/python-librt-0.11.0-py313h54dd161_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ruff-0.15.8-h7805a7d_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/ukkonen-1.1.0-py313h7037e92_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zizmor-1.23.1-hb17b654_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/codespell-2.4.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.14-py313hd8ed1ab_100.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.29.4-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-1.1.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.0-pyha770c72_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.4.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.14-h4df99d1_100.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.5.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/yamllint-1.38.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda osx-64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/codespell-2.4.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.14-py313hd8ed1ab_100.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.29.4-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-1.1.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.0-pyha770c72_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.4.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.14-h4df99d1_100.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.5.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/yamllint-1.38.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/ast-serialize-0.5.0-py310hb9b2626_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h500dc9f_9.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/cffi-2.0.0-py313hf57695f_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-22.1.8-h19cb2f5_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.8.1-hcc62823_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libffi-3.5.2-hd1f9c09_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/liblzma-5.8.3-hbb4bfdb_0.conda @@ -198,46 +169,31 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/mypy-2.1.0-py313h0b0ee5e_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.6-hcc0dc9a_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.3-hc881268_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/prek-0.5.6-he704061_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/psutil-7.2.2-py313h16366db_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/python-3.13.14-h3d5d122_100_cp313.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/python-librt-0.11.0-py313h22ab4a2_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/readline-8.3-h68b038d_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/ruff-0.15.8-h16586dd_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-h7142dee_3.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/ukkonen-1.1.0-py313h252b9d7_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/yaml-0.2.5-h4132b18_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zizmor-1.23.1-h19f9e61_0.conda osx-arm64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/codespell-2.4.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.14-py313hd8ed1ab_100.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.29.4-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-1.1.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.0-pyha770c72_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.4.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.14-h4df99d1_100.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.5.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/yamllint-1.38.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ast-serialize-0.5.0-py310h3b8a9b8_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_9.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.0.0-py313h224173a_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hef89b57_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.8-h55c6f16_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda @@ -247,43 +203,29 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/mypy-2.1.0-py313h3e91af1_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/prek-0.5.6-hc2d82ec_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/psutil-7.2.2-py313h6688731_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.13.14-h448ec07_100_cp313.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-librt-0.11.0-py313h6688731_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ruff-0.15.8-hc5c3a1d_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ukkonen-1.1.0-py313h5c29297_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/yaml-0.2.5-h925e9cb_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zizmor-1.23.1-h6fdd925_0.conda win-64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-h4c7d964_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/codespell-2.4.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.14-py313hd8ed1ab_100.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.29.4-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-1.1.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.0-pyha770c72_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.4.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.14-h4df99d1_100.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.5.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/yamllint-1.38.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/ast-serialize-0.5.0-py310ha413424_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_9.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.0.0-py313h5ea7bf4_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.1-hac47afa_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h3d046cb_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_0.conda @@ -292,6 +234,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/mypy-2.1.0-py313h4ac8b90_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.3-hf411b9b_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/prek-0.5.6-h18a1a76_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.2.2-py313h5fd188c_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.13.14-h09917c8_100_cp313.conda - conda: https://conda.anaconda.org/conda-forge/win-64/python-librt-0.11.0-py313h5fd188c_0.conda @@ -299,7 +242,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/ruff-0.15.8-h02f8532_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h6ed50ae_3.conda - conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/ukkonen-1.1.0-py313hf069bd2_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_39.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_39.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda @@ -345,20 +287,6 @@ packages: license_family: BSD size: 260182 timestamp: 1771350215188 -- conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py313hf46b229_1.conda - sha256: 2162a91819945c826c6ef5efe379e88b1df0fe9a387eeba23ddcf7ebeacd5bd6 - md5: d0616e7935acab407d1543b28c446f6f - depends: - - __glibc >=2.17,<3.0.a0 - - libffi >=3.5.2,<3.6.0a0 - - libgcc >=14 - - pycparser - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 - license: MIT - license_family: MIT - size: 298357 - timestamp: 1761202966461 - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda sha256: fbf86c4a59c2ed05bbffb2ba25c7ed94f6185ec30ecb691615d42342baa1a16a md5: c80d8a3b84358cb967fa81e7075fbc8a @@ -529,6 +457,19 @@ packages: license_family: Apache size: 3159683 timestamp: 1781069855778 +- conda: https://conda.anaconda.org/conda-forge/linux-64/prek-0.5.6-hf19af3b_0.conda + sha256: 66d983181dd8dfc9729d629f5288c53ba4086fadc7b2fe3f8008e2a9399ce2ce + md5: 4fed9cd7d24477b074c35c9c5e4dbbc9 + depends: + - libgcc >=15 + - __glibc >=2.17,<3.0.a0 + constrains: + - __glibc >=2.17 + license: MIT + license_family: MIT + run_exports: {} + size: 5051533 + timestamp: 1792492186945 - conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.2.2-py313h54dd161_0.conda sha256: f19fd682d874689dfde20bf46d7ec1a28084af34583e0405685981363af47c91 md5: 25fe6e02c2083497b3239e21b49d8093 @@ -617,20 +558,6 @@ packages: license_family: BSD size: 3301196 timestamp: 1769460227866 -- conda: https://conda.anaconda.org/conda-forge/linux-64/ukkonen-1.1.0-py313h7037e92_0.conda - sha256: 7f2e4f38e57c17858c644259a1be868d6e98780239fd93bfa057cb5cfc24a928 - md5: cb423e0853b3dde2b3738db4dedf5ba2 - depends: - - __glibc >=2.17,<3.0.a0 - - cffi - - libgcc >=14 - - libstdcxx >=14 - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 - license: MIT - license_family: MIT - size: 14910 - timestamp: 1769438729201 - conda: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda sha256: 6d9ea2f731e284e9316d95fa61869fe7bbba33df7929f82693c121022810f4ad md5: a77f85f77be52ff59391544bfe73390a @@ -689,15 +616,6 @@ packages: license: ISC size: 128866 timestamp: 1781708962055 -- conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda - sha256: aa589352e61bb221351a79e5946d56916e3c595783994884accdb3b97fe9d449 - md5: 381bd45fb7aa032691f3063aff47e3a1 - depends: - - python >=3.10 - license: MIT - license_family: MIT - size: 13589 - timestamp: 1763607964133 - conda: https://conda.anaconda.org/conda-forge/noarch/codespell-2.4.2-pyhd8ed1ab_0.conda sha256: d09a16087e14faaa92af87a8dcb30e7f85eed2ad1e1f994bb228a482d104d94c md5: dc82cad3ba612ecccbabeb988ca49eae @@ -717,45 +635,6 @@ packages: license: Python-2.0 size: 48337 timestamp: 1781257766256 -- conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda - sha256: e2753997b8bd34205f42be01b8bab8037423dc30c02a1ec12de23e5b4c0b0a2e - md5: 58638f77697c4f6726753eb8be34818b - depends: - - python >=3.10 - - python - license: Apache-2.0 - license_family: APACHE - size: 303705 - timestamp: 1781320269259 -- conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.29.4-pyhd8ed1ab_0.conda - sha256: feb5c13cc8f256212a979783a7645abd7e27925c51ee5431babbc0efc661cdfd - md5: 66f138d7a6dffb5c959cc4bf6dc2b797 - depends: - - python >=3.10 - license: Unlicense - size: 36989 - timestamp: 1781381078337 -- conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda - sha256: 381cedccf0866babfc135d65ee40b778bd20e927d2a5ec810f750c5860a7c5b8 - md5: 84a3233b709a289a4ddd7a2fd27dd988 - depends: - - python >=3.10 - - ukkonen - license: MIT - license_family: MIT - size: 79757 - timestamp: 1776455344188 -- conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - sha256: 43e2a5497cad1598ff88a3e69f69bc88b7b8f141fa63c60eab5db296317318b8 - md5: ffc17e785d64e12fc311af9184221839 - depends: - - python >=3.10 - - zipp >=3.20 - - python - license: Apache-2.0 - license_family: APACHE - size: 34766 - timestamp: 1779714582554 - conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda sha256: 6ed158e4e5dd8f6a10ad9e525631e35cee8557718f83de7a4e3966b1f772c4b1 md5: e9c622e0d00fa24a6292279af3ab6d06 @@ -765,16 +644,6 @@ packages: license_family: MIT size: 11766 timestamp: 1745776666688 -- conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda - sha256: 4fa40e3e13fc6ea0a93f67dfc76c96190afd7ea4ffc1bac2612d954b42cdc3ee - md5: eb52d14a901e23c39e9e7b4a1a5c015f - depends: - - python >=3.10 - - setuptools - license: BSD-3-Clause - license_family: BSD - size: 40866 - timestamp: 1766261270149 - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-1.1.1-pyhd8ed1ab_0.conda sha256: 6eaee417d33f298db79bc7185ab1208604c0e6cf51dade34cd513c6f9db9c6f3 md5: 11adc78451c998c0fd162584abfa3559 @@ -784,52 +653,6 @@ packages: license_family: MOZILLA size: 56559 timestamp: 1777271601895 -- conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda - sha256: 9e5e1fd3506ccfc4d444fc4d2d39b0ed097d5d0e3bd3d4bdf6bcc81aaf66860d - md5: 2c5ef45db85d34799771629bd5860fd7 - depends: - - python >=3.10 - - python - license: MIT - license_family: MIT - size: 26308 - timestamp: 1779972894916 -- conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.0-pyha770c72_0.conda - sha256: 716960bf0a9eb334458a26b3bdcb17b8d0786062138a4f48c7f335c8418c5d0b - md5: 7859736b4f8ebe6c8481bf48d91c9a1e - depends: - - cfgv >=2.0.0 - - identify >=1.0.0 - - nodeenv >=0.11.1 - - python >=3.10 - - pyyaml >=5.1 - - virtualenv >=20.10.0 - license: MIT - license_family: MIT - size: 201606 - timestamp: 1776858157327 -- conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda - sha256: e27e0473fc6723311a0bd48b89b616fa1b996a2f7a2b555338cbbcfb9c640568 - md5: 9c5491066224083c41b6d5635ed7107b - depends: - - python >=3.10 - - python - license: BSD-3-Clause - license_family: BSD - size: 55886 - timestamp: 1779293633166 -- conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.4.2-pyhcf101f3_0.conda - sha256: 6914da740f6e3ec44ffb2f687dbc9c33abf084e42f34e3a8bb8235e475850619 - md5: 7a9095c9300d1b50b1785ca9bc4cadae - depends: - - python >=3.10 - - filelock >=3.15.4 - - platformdirs <5,>=4.3.6 - - python - license: MIT - license_family: MIT - size: 35514 - timestamp: 1781257630962 - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.14-h4df99d1_100.conda sha256: c7a8f98ea1cda5a84377c236ccd4bf1b6e2212c5a258d60bba295fb9f0260235 md5: 200323d73f85b9c5c411db8c8c4942db @@ -861,15 +684,6 @@ packages: license_family: MIT size: 45525 timestamp: 1770223374054 -- conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda - sha256: 82088a6e4daa33329a30bc26dc19a98c7c1d3f05c0f73ce9845d4eab4924e9e1 - md5: 8e194e7b992f99a5015edbd4ebd38efd - depends: - - python >=3.10 - license: MIT - license_family: MIT - size: 639697 - timestamp: 1773074868565 - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda sha256: 032271135bca55aeb156cee361c81350c6f3fb203f57d024d7e5a1fc9ef18731 md5: 0caa1af407ecff61170c9437a808404d @@ -886,22 +700,6 @@ packages: license: LicenseRef-Public-Domain size: 119135 timestamp: 1767016325805 -- conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.5.1-pyhcf101f3_0.conda - sha256: 0a7b0a2ada7ad719f9d4f8874eb10911e1fcfdecefc86456105eb806ebd60ac4 - md5: e449fb99b714be1e13fa5564dacd1af5 - depends: - - python >=3.10 - - distlib >=0.3.7,<1 - - filelock <4,>=3.24.2 - - importlib-metadata >=6.6 - - platformdirs >=3.9.1,<5 - - python-discovery >=1.4.2 - - typing_extensions >=4.13.2 - - python - license: MIT - license_family: MIT - size: 3111990 - timestamp: 1781651033074 - conda: https://conda.anaconda.org/conda-forge/noarch/yamllint-1.38.0-pyhcf101f3_1.conda sha256: 7f5bcc0f059c607ccd65fa1b82d8b369c2598a6e86c31f7a4995bc2f2753e2eb md5: db30cb6c0910cc35e35d3955cf373df2 @@ -914,16 +712,6 @@ packages: license_family: GPL size: 111901 timestamp: 1770937575481 -- conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - sha256: 210bd31c22bb88f5e2a167df24c95bb5f152b2ada7502f9b8c49d1f5366db423 - md5: ba3dcdc8584155c97c648ae9c044b7a3 - depends: - - python >=3.10 - - python - license: MIT - license_family: MIT - size: 24190 - timestamp: 1779159948016 - conda: https://conda.anaconda.org/conda-forge/osx-64/ast-serialize-0.5.0-py310hb9b2626_1.conda noarch: python sha256: 9d38da0aa9f1034eecf97151a26686f8a6261cbfe27eb486e80e0d86e0169d33 @@ -948,28 +736,6 @@ packages: license_family: BSD size: 133427 timestamp: 1771350680709 -- conda: https://conda.anaconda.org/conda-forge/osx-64/cffi-2.0.0-py313hf57695f_1.conda - sha256: 16c8c80bebe1c3d671382a64beaa16996e632f5b75963379e2b084eb6bc02053 - md5: b10f64f2e725afc9bf2d9b30eff6d0ea - depends: - - __osx >=10.13 - - libffi >=3.5.2,<3.6.0a0 - - pycparser - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 - license: MIT - license_family: MIT - size: 290946 - timestamp: 1761203173891 -- conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-22.1.8-h19cb2f5_0.conda - sha256: 57ee997f1f800cf38abc743c0f0a9ddfe6a101c697c35510452ce6f4ddf96361 - md5: 0f600157f28fc7bc9549ecafdfa5bc12 - depends: - - __osx >=11.0 - license: Apache-2.0 WITH LLVM-exception - license_family: Apache - size: 566717 - timestamp: 1781672189697 - conda: https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.8.1-hcc62823_1.conda sha256: 9c96cc05e056e1bba5b545cbbd57b6e01db622dc2c82934caaaa25cfb22fe666 md5: dcfdea7b7013beef0a4d744d776ea38f @@ -1064,6 +830,18 @@ packages: license_family: Apache size: 2775300 timestamp: 1781071391999 +- conda: https://conda.anaconda.org/conda-forge/osx-64/prek-0.5.6-he704061_0.conda + sha256: a8327d33e53849a94f423cacece0821691bd1b17b918677c539346602fa7c161 + md5: 958b54425cf8f6c0edbcd98c8914f263 + depends: + - __osx >=11.0 + constrains: + - __osx >=11.0 + license: MIT + license_family: MIT + run_exports: {} + size: 4962127 + timestamp: 1792492271696 - conda: https://conda.anaconda.org/conda-forge/osx-64/psutil-7.2.2-py313h16366db_0.conda sha256: b50a9d64aabd30c05e405cc1166f21fd7dee8d1b42ef38116701883d3bd4d5fa md5: c8185e1891ace76e565b4c28dd50ed5d @@ -1142,19 +920,6 @@ packages: license_family: BSD size: 3282953 timestamp: 1769460532442 -- conda: https://conda.anaconda.org/conda-forge/osx-64/ukkonen-1.1.0-py313h252b9d7_0.conda - sha256: 201d026c60bbbdd7c9bf9b3c61f807711ba24a9899a1b7f8a978b507d44d7efa - md5: e6ab56e180655e23353afea13caebc44 - depends: - - __osx >=10.13 - - cffi - - libcxx >=19 - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 - license: MIT - license_family: MIT - size: 14202 - timestamp: 1769439075795 - conda: https://conda.anaconda.org/conda-forge/osx-64/yaml-0.2.5-h4132b18_3.conda sha256: a335161bfa57b64e6794c3c354e7d49449b28b8d8a7c4ed02bf04c3f009953f9 md5: a645bb90997d3fc2aea0adf6517059bd @@ -1199,20 +964,6 @@ packages: license_family: BSD size: 124834 timestamp: 1771350416561 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.0.0-py313h224173a_1.conda - sha256: 1fa69651f5e81c25d48ac42064db825ed1a3e53039629db69f86b952f5ce603c - md5: 050374657d1c7a4f2ea443c0d0cbd9a0 - depends: - - __osx >=11.0 - - libffi >=3.5.2,<3.6.0a0 - - pycparser - - python >=3.13,<3.14.0a0 - - python >=3.13,<3.14.0a0 *_cp313 - - python_abi 3.13.* *_cp313 - license: MIT - license_family: MIT - size: 291376 - timestamp: 1761203583358 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hef89b57_0.conda sha256: 3a7907a17e9937d3a46dfd41cffaf815abad59a569440d1e25177c15fd0684e5 md5: f1182c91c0de31a7abd40cedf6a5ebef @@ -1222,15 +973,6 @@ packages: license_family: MIT size: 12361647 timestamp: 1773822915649 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.8-h55c6f16_0.conda - sha256: a2e7abab5add9750fab064c024394de48e49f97631c605ad5db5c8ac3fc769ef - md5: 89f76a2a21a3ec3ec983b5eb237c4113 - depends: - - __osx >=11.0 - license: Apache-2.0 WITH LLVM-exception - license_family: Apache - size: 569349 - timestamp: 1781670209146 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda sha256: 5af74261101e3c777399c6294b2b5d290e508153268eb2e9ff99c4d69834612f md5: a915151d5d3c5bf039f5ccc8402a436f @@ -1326,6 +1068,18 @@ packages: license_family: Apache size: 3102584 timestamp: 1781069820667 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/prek-0.5.6-hc2d82ec_0.conda + sha256: cdc5a03f831fe73380821344e78e5ae16c21fab30e3b03b5086cf4dc5080f72f + md5: fc244dae46d27b91ca8503910edca686 + depends: + - __osx >=11.0 + constrains: + - __osx >=11.0 + license: MIT + license_family: MIT + run_exports: {} + size: 4518194 + timestamp: 1792492197044 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/psutil-7.2.2-py313h6688731_0.conda sha256: 1d2a6039fb71d61134b1d6816202529f2f6286c83b59bc1491fd288f5c08046e md5: ba2d89e51a855963c767648f44c03871 @@ -1406,20 +1160,6 @@ packages: license_family: BSD size: 3127137 timestamp: 1769460817696 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ukkonen-1.1.0-py313h5c29297_0.conda - sha256: d28d0242d3fa23784630c775d5b628ce25e2d45f5d3f1cfcdc3815bc954073fa - md5: 43b1eb729bd1cd9ea595548eb8100b65 - depends: - - __osx >=11.0 - - cffi - - libcxx >=19 - - python >=3.13,<3.14.0a0 - - python >=3.13,<3.14.0a0 *_cp313 - - python_abi 3.13.* *_cp313 - license: MIT - license_family: MIT - size: 14773 - timestamp: 1769439197815 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/yaml-0.2.5-h925e9cb_3.conda sha256: b03433b13d89f5567e828ea9f1a7d5c5d697bf374c28a4168d71e9464f5dafac md5: 78a0fe9e9c50d2c381e8ee47e3ea437d @@ -1466,20 +1206,6 @@ packages: license_family: BSD size: 56115 timestamp: 1771350256444 -- conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.0.0-py313h5ea7bf4_1.conda - sha256: f867a11f42bb64a09b232e3decf10f8a8fe5194d7e3a216c6bac9f40483bd1c6 - md5: 55b44664f66a2caf584d72196aa98af9 - depends: - - pycparser - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: MIT - license_family: MIT - size: 292681 - timestamp: 1761203203673 - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.1-hac47afa_1.conda sha256: 1a54d874addda73b6f7164d5f3905821277a1831bcc05edd74b3085391688571 md5: ccc490c81ffe14181861beac0e8f3169 @@ -1581,6 +1307,18 @@ packages: license_family: Apache size: 9414790 timestamp: 1781071745579 +- conda: https://conda.anaconda.org/conda-forge/win-64/prek-0.5.6-h18a1a76_0.conda + sha256: 6c4e466cfb5f9865af8a50f33736bdeb54502c692afcb986db69c0dd6de09dbe + md5: 80a9bb7c0906abd8f89880322934c2ba + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + license: MIT + license_family: MIT + run_exports: {} + size: 4856185 + timestamp: 1792492182435 - conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.2.2-py313h5fd188c_0.conda sha256: 3ec3373748f83069bef93b540de416e637ee30231b222d5df8f712e93f2f9195 md5: 761b299a6289c77459defea3563f8fc0 @@ -1677,20 +1415,6 @@ packages: license: LicenseRef-MicrosoftWindowsSDK10 size: 694692 timestamp: 1756385147981 -- conda: https://conda.anaconda.org/conda-forge/win-64/ukkonen-1.1.0-py313hf069bd2_0.conda - sha256: 09f3bb587199361774612f4e70226d8688eda264b452ec401e1ce904633dde43 - md5: bfa075d1cd7bf341b8189af9616ce537 - depends: - - cffi - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: MIT - license_family: MIT - size: 18441 - timestamp: 1769438882754 - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_39.conda sha256: 17693b60cb54f80c60275f003f3bfc1b128af56dbfd65c4fae37c64eeb755ce1 md5: 2eacea63f545b97342da520df6854276 diff --git a/pixi.toml b/pixi.toml index 59e9a30..70b8faf 100644 --- a/pixi.toml +++ b/pixi.toml @@ -8,15 +8,15 @@ version = "0.1.0" [dependencies] # Runtime interpreter for azoidcapp (stdlib only; no extra packages needed). -# Capped at <3.14 to avoid conda-forge pre-commit packaging issues with the -# Python 3.14 free-threaded (3.14t) variant. +# Capped at <3.14 to avoid packaging issues with the Python 3.14 +# free-threaded (3.14t) variant. python = ">=3.9,<3.14" [environments] dev = { features = ["dev"] } [feature.dev.dependencies] -pre-commit = "*" +prek = "*" # Tool versions are pinned so developer environments are deterministic # and stay in lockstep with the .pre-commit-config.yaml hook set # (both locally and in CI via the Lint workflow). @@ -27,10 +27,7 @@ codespell = "==2.4.2" yamllint = "==1.38.0" zizmor = "==1.23.1" mypy = "*" -# identify >= 2.x is required by pre-commit-shfmt v3.12+ which uses the -# 'csh' type tag; conda-forge's pre-commit pulls an older identify. -identify = ">=2.5" [feature.dev.tasks] -lint = "pre-commit run --all-files" -lint-files = "pre-commit run --files" +lint = "prek run --all-files" +lint-files = "prek run --files" From ed3b816c4f968f740fb16f6d7da669dc571a8081 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 20 Oct 2026 15:17:16 -0400 Subject: [PATCH 31/40] feat(bwrap): mount tool authoring directories read-write Personal skills were mounted one entry at a time over a tmpfs, so a new top-level skill created inside the sandbox was discarded on exit, and the agents/, commands/ and prompts/ directories the tools also read were not writable at all (except Copilot's skills/ and agents/). Tools could therefore not author their own extensions across sessions. Replace build_personal_skills_mounts with two helpers in the library, built on _mount_authoring_dir: - build_authoring_dir_mounts KIND DIR...: create DIR on the host (not under --dry-run) and bind it read-write as a whole at its logical path, so anything created inside the sandbox persists. - build_personal_authoring_mounts KIND DIR...: the same, but only for directories that already exist (alternate search locations). Skills symlinked in from a separate repository would dangle in a whole-directory bind, and bwrap refuses to bind over a symlink inside a bound directory. So the resolved target of each symlink entry is also bound read-write at its own path; only that entry is exposed, not the repository around it. Targets, or directories, that resolve into or contain a blocked location are skipped with a warning. If the authoring directory is itself reached through a symlink, fall back to the previous per-entry binds (new top-level entries are then not persisted). - bwopencode, bwopencode2: {skills,agents,commands} under the config dir (now skipped by resolve_config_symlinks so the read-only bind does not shadow them); ~/.claude/{skills,agents,commands} and ~/.agents/{skills,agents} when present - bwclaude: {skills,agents,commands} under the Claude home - bwcodex: ~/.codex/{skills,prompts}; ~/.agents/skills when present - bwcopilot: ~/.copilot/{skills,agents} via the shared helper (replaces the inline bind and mkdir); ~/.agents/skills when present The deployment-managed read-only mounts are unchanged. Documented in the launcher headers and a reworked "Authoring directories" section of docs/bw-wrappers.md. Based on the approach in NSLS2/n2snscripts#4. Items from that PR already on this branch (haiku model override, dropped gateway hostname in bwclaude messages) are not repeated. Assisted-by: OpenCode:claude-sonnet-5-5 --- bin/bwclaude | 20 ++++- bin/bwcodex | 20 ++++- bin/bwcopilot | 40 +++++----- bin/bwopencode | 37 ++++++++-- bin/bwopencode2 | 34 +++++++-- docs/bw-wrappers.md | 53 +++++++------ lib/bwrap_sandbox_lib.sh | 155 +++++++++++++++++++++++++++++---------- 7 files changed, 256 insertions(+), 103 deletions(-) diff --git a/bin/bwclaude b/bin/bwclaude index e8fa36c..7eae624 100755 --- a/bin/bwclaude +++ b/bin/bwclaude @@ -28,9 +28,10 @@ # the OAuth session (if any), per-project trust decisions and personal MCP # server definitions. KNOWN GAP: a session can add an MCP server or a trust # decision here that unsandboxed claude will honour later. -# - Personal skills (~/.claude/skills, or $CLAUDE_CONFIG_DIR/skills): -# read-write (if present); each skill is resolved individually, so skills -# symlinked in from elsewhere (e.g. a shared skills repository) work too +# - Authoring directories (skills/, agents/, commands/ under the Claude home): +# read-write, always created on the host, so definitions authored in the +# sandbox persist; skills symlinked in from elsewhere (e.g. a shared skills +# repository) work too # - Claude cache ($XDG_CACHE_HOME/claude, default ~/.cache/claude): read-write # - Git config + includes: read-only # - Pixi cache: read-write @@ -367,6 +368,17 @@ build_claude_home_mount() { fi } +# ── Claude authoring directories (read-write, persistent) ──────── +# skills/, agents/ and commands/ hold user-authored markdown definitions. +# They are created on the host when missing and mounted read-write so the +# agent can create and edit them from inside the sandbox. Must run AFTER +# build_claude_home_mount (the home is a tmpfs that would hide them). +build_claude_authoring_mounts() { + build_authoring_dir_mounts skill "${CLAUDE_HOME_DIR}/skills" + build_authoring_dir_mounts agent "${CLAUDE_HOME_DIR}/agents" + build_authoring_dir_mounts command "${CLAUDE_HOME_DIR}/commands" +} + # ── Claude cache (read-write) ──────────────────────────────────── build_claude_cache_mount() { BWRAP_ARGS+=(--bind "${CLAUDE_CACHE_DIR}" "${CLAUDE_CACHE_DIR}") @@ -534,7 +546,7 @@ main() { build_home_tmpfs build_workdir_mount build_claude_home_mount - build_personal_skills_mounts "${CLAUDE_HOME_DIR}/skills" + build_claude_authoring_mounts build_claude_cache_mount build_git_mounts build_pixi_mounts diff --git a/bin/bwcodex b/bin/bwcodex index 1a5b098..ed7e7fe 100755 --- a/bin/bwcodex +++ b/bin/bwcodex @@ -38,8 +38,9 @@ # - auth.json: bind-mounted read-write only if it already exists on host; # --init-auth creates a {} stub for first-time ChatGPT-login setup # - Everything else (tmp/, state.toml, features/, ...): tmpfs, discarded on exit -# - Personal skills (~/.codex/skills, ~/.agents/skills): read-write (if -# present); each skill is resolved individually, so skills symlinked in +# - Authoring directories (~/.codex/skills, ~/.codex/prompts): read-write, +# always created on the host, so definitions authored in the sandbox +# persist; ~/.agents/skills: read-write (if present). Skills symlinked in # from elsewhere (e.g. a shared skills repository) work too # - /etc/codex/config.toml: system-wide provider/defaults config (if # present); bind-mounted read-only. Codex layers user config on @@ -267,6 +268,7 @@ resolve_codex_paths() { CODEX_SESSIONS_DIR="${CODEX_HOME_DIR}/sessions" CODEX_LOG_DIR="${CODEX_HOME_DIR}/log" CODEX_SKILLS_DIR="${CODEX_HOME_DIR}/skills" + CODEX_PROMPTS_DIR="${CODEX_HOME_DIR}/prompts" CODEX_HISTORY_FILE="${CODEX_HOME_DIR}/history.jsonl" CODEX_DAEMON_SETTINGS_FILE="${CODEX_HOME_DIR}/app-server-daemon/settings.json" @@ -302,6 +304,18 @@ check_codex_config() { fi } +# ── Codex authoring directories (read-write, persistent) ────────── +# skills/ and prompts/ hold user-authored markdown definitions. They are +# created on the host when missing and mounted read-write so the agent can +# create and edit them from inside the sandbox. ~/.agents/skills is an +# additional skills location, mounted only if it already exists. Must run +# AFTER build_codex_home_mount (the home is a tmpfs that would hide them). +build_codex_authoring_mounts() { + build_authoring_dir_mounts skill "${CODEX_SKILLS_DIR}" + build_authoring_dir_mounts prompt "${CODEX_PROMPTS_DIR}" + build_personal_authoring_mounts skill "${_HOME}/.agents/skills" +} + # ── Managed Codex skills (read-only) ────────────────────────────── # Resolve the fixed administrator-managed alias before binding it at the # location Codex discovers. This is deliberately not a --ro-path mount. @@ -537,7 +551,7 @@ main() { build_home_tmpfs build_workdir_mount build_codex_home_mount - build_personal_skills_mounts "${CODEX_SKILLS_DIR}" "${_HOME}/.agents/skills" + build_codex_authoring_mounts build_git_mounts build_pixi_mounts build_ccache_mounts diff --git a/bin/bwcopilot b/bin/bwcopilot index 9929896..2b6606b 100755 --- a/bin/bwcopilot +++ b/bin/bwcopilot @@ -10,15 +10,13 @@ # - Copilot home (~/.copilot or $COPILOT_HOME): tmpfs (ephemeral by default) # - session-state/: bind-mounted read-write (persistent) # - logs/: bind-mounted read-write (persistent) -# - skills/: bind-mounted read-write (user-created skill definitions) -# - agents/: bind-mounted read-write (user-created custom agents) +# - skills/, agents/: bind-mounted read-write, always created on the host +# (user-authored definitions; persist across sessions) # - installed-plugins/: bind-mounted read-only (prevents agent from installing plugins) # - config.json: COPIED into tmpfs (host auth available, changes ephemeral) # - Use --init-auth to make config.json persistent (for personal machines) -# - Personal skills (~/.agents/skills): read-write (if present); each skill -# is resolved individually, so skills symlinked in from elsewhere (e.g. a -# shared skills repository) work too (~/.copilot/skills is the skills/ -# bind above) +# - Personal skills (~/.agents/skills): read-write (if present); skills +# symlinked in from elsewhere (e.g. a shared skills repository) work too # - Copilot cache (~/.cache/copilot or $COPILOT_CACHE_HOME): read-write # - GitHub CLI auth (~/.config/gh/hosts.yml): read-only (if it exists) # - Git config + includes: read-only @@ -247,8 +245,6 @@ ensure_host_dirs() { mkdir -p "${COPILOT_HOME_DIR}" "${COPILOT_CACHE_DIR}" # Create persistent subdirectories for bind-mounts mkdir -p "${COPILOT_SESSION_DIR}" "${COPILOT_LOGS_DIR}" - # Create user-writable customization directories - mkdir -p "${COPILOT_HOME_DIR}/skills" "${COPILOT_HOME_DIR}/agents" } # ── Copilot home (ephemeral tmpfs + selective persistence) ────── @@ -264,8 +260,7 @@ ensure_host_dirs() { # Persistent read-write: # - session-state/: per-session checkpoints, events, database # - logs/: process logs -# - skills/: user-created skill definitions (SKILL.md files) -# - agents/: user-created custom agent profiles (*.agent.md files) +# - skills/, agents/: mounted by build_copilot_authoring_mounts # # Persistent read-only: # - mcp-config.json, lsp-config.json: user preferences (if they exist) @@ -298,15 +293,8 @@ build_copilot_home_mount() { fi done - # Bind-mount user-writable customization directories (read-write). - # Skills and agents are user-created files, not installed packages. - # ensure_host_dirs always creates both directories, so no existence - # check is needed (on --dry-run the directories may not exist yet, - # but the bind args are still emitted for accurate dry-run output). - local _cust_dir - for _cust_dir in skills agents; do - BWRAP_ARGS+=(--bind "${COPILOT_HOME_DIR}/${_cust_dir}" "${COPILOT_HOME_DIR}/${_cust_dir}") - done + # skills/ and agents/ (user-authored, read-write) are mounted by + # build_copilot_authoring_mounts, which runs after this function. # Handle config.json: copy or bind depending on --init-auth. # File creation is skipped on --dry-run to keep that mode side-effect-free. @@ -359,6 +347,18 @@ build_copilot_settings_mount() { BWRAP_ARGS+=(--ro-bind "${_settings_source}" "${_settings_file}") } +# ── Copilot authoring directories (read-write, persistent) ───────── +# skills/ and agents/ hold user-authored markdown definitions. They are +# created on the host when missing and mounted read-write so the agent can +# create and edit them from inside the sandbox. ~/.agents/skills is an +# additional skills location, mounted only if it already exists. Must run +# AFTER build_copilot_home_mount (the home is a tmpfs that would hide them). +build_copilot_authoring_mounts() { + build_authoring_dir_mounts skill "${COPILOT_HOME_DIR}/skills" + build_authoring_dir_mounts agent "${COPILOT_HOME_DIR}/agents" + build_personal_authoring_mounts skill "${_HOME}/.agents/skills" +} + # ── Copilot cache (read-write) ────────────────────────────────── build_copilot_cache_mount() { BWRAP_ARGS+=(--bind "${COPILOT_CACHE_DIR}" "${COPILOT_CACHE_DIR}") @@ -444,7 +444,7 @@ main() { build_home_tmpfs build_workdir_mount build_copilot_home_mount - build_personal_skills_mounts "${_HOME}/.agents/skills" + build_copilot_authoring_mounts build_copilot_cache_mount build_gh_auth_mount build_git_mounts diff --git a/bin/bwopencode b/bin/bwopencode index 2ff53ab..6e12acb 100755 --- a/bin/bwopencode +++ b/bin/bwopencode @@ -7,11 +7,15 @@ # # The sandbox provides filesystem isolation: # - Current directory: read-write -# - OpenCode config: read-only (symlinks resolved) -# - Personal skills (~/.config/opencode/skills, ~/.claude/skills, -# ~/.agents/skills): read-write (if present); each skill is resolved -# individually, so skills symlinked in from elsewhere (e.g. a shared +# - OpenCode config: read-only (symlinks resolved), except the authoring +# directories below +# - Authoring directories (~/.config/opencode/{skills,agents,commands}): +# read-write, always created on the host, so definitions authored in the +# sandbox persist; skills symlinked in from elsewhere (e.g. a shared # skills repository) work too +# - Additional skills/agents/commands locations OpenCode reads +# (~/.claude/{skills,agents,commands}, ~/.agents/{skills,agents}): +# read-write, only if they already exist # - OpenCode data dir: ephemeral tmpfs with selective persistence # - auth.json: persisted only if it already exists on host; # otherwise created in tmpfs (discarded on exit) @@ -253,15 +257,32 @@ resolve_config_symlinks() { local entry local_resolved for entry in "${OC_CONFIG_DIR}"/*; do [[ -e "${entry}" ]] || continue - # Personal skills are mounted by build_personal_skills_mounts, one - # skill at a time, so symlinked skills resolve in the sandbox. - [[ "${entry##*/}" == skills ]] && continue + # Authoring directories are mounted read-write by + # build_opencode_authoring_mounts; a read-only bind here would + # shadow that. + case "${entry##*/}" in skills | agents | commands) continue ;; esac _bwrap_canonicalize_safe "${entry}" "opencode config entry '${entry}'" local_resolved="${_BWRAP_CANON}" OC_CONFIG_BINDS+=("--ro-bind" "${local_resolved}" "${entry}") done } +# ── OpenCode authoring directories (read-write, persistent) ────── +# skills/, agents/ and commands/ under the OpenCode config directory hold +# user-authored markdown definitions. They are created on the host when +# missing and mounted read-write so the agent can create and edit them from +# inside the sandbox. The Claude- and agent-compatible locations OpenCode +# also reads are mounted read-write only if they already exist. Must run +# AFTER build_opencode_config_mount (which creates the config directory). +build_opencode_authoring_mounts() { + build_authoring_dir_mounts skill "${OC_CONFIG_DIR}/skills" + build_authoring_dir_mounts agent "${OC_CONFIG_DIR}/agents" + build_authoring_dir_mounts command "${OC_CONFIG_DIR}/commands" + build_personal_authoring_mounts skill "${_HOME}/.claude/skills" "${_HOME}/.agents/skills" + build_personal_authoring_mounts agent "${_HOME}/.claude/agents" "${_HOME}/.agents/agents" + build_personal_authoring_mounts command "${_HOME}/.claude/commands" +} + # ── Managed OpenCode config and skills (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. This is the Linux @@ -533,7 +554,7 @@ main() { build_workdir_mount build_persistent_dotdir build_opencode_config_mount - build_personal_skills_mounts "${OC_CONFIG_DIR}/skills" "${_HOME}/.claude/skills" "${_HOME}/.agents/skills" + build_opencode_authoring_mounts build_opencode_data_mounts build_opencode_cache_mount build_git_mounts diff --git a/bin/bwopencode2 b/bin/bwopencode2 index e2b7bb6..0154d47 100755 --- a/bin/bwopencode2 +++ b/bin/bwopencode2 @@ -13,10 +13,13 @@ # - Current directory: read-write # - OpenCode 2 config (~/.config/opencode2/opencode): read-only (symlinks resolved); # service.json / service-*.json (background-service settings) are not mounted -# - Personal skills (~/.config/opencode2/opencode/skills, ~/.claude/skills, -# ~/.agents/skills): read-write (if present); each skill is resolved -# individually, so skills symlinked in from elsewhere (e.g. a shared +# - Authoring directories (~/.config/opencode2/opencode/{skills,agents,commands}): +# read-write, always created on the host, so definitions authored in the +# sandbox persist; skills symlinked in from elsewhere (e.g. a shared # skills repository) work too +# - Additional skills/agents/commands locations OpenCode reads +# (~/.claude/{skills,agents,commands}, ~/.agents/{skills,agents}): +# read-write, only if they already exist # - 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 @@ -395,9 +398,10 @@ resolve_config_symlinks() { # file is not mounted. case "${entry##*/}" in service*.json) continue ;; - # Personal skills are mounted by build_personal_skills_mounts, one - # skill at a time, so symlinked skills resolve in the sandbox. - skills) continue ;; + # Authoring directories are mounted read-write by + # build_opencode2_authoring_mounts; a read-only bind here would + # shadow that. + skills | agents | commands) continue ;; esac _bwrap_canonicalize_safe "${entry}" "opencode2 config entry '${entry}'" local_resolved="${_BWRAP_CANON}" @@ -405,6 +409,22 @@ resolve_config_symlinks() { done } +# ── OpenCode 2 authoring directories (read-write, persistent) ──── +# skills/, agents/ and commands/ under the OpenCode 2 config directory hold +# user-authored markdown definitions. They are created on the host when +# missing and mounted read-write so the agent can create and edit them from +# inside the sandbox. The Claude- and agent-compatible locations OpenCode +# also reads are mounted read-write only if they already exist. Must run +# AFTER build_opencode2_config_mount (which creates the config directory). +build_opencode2_authoring_mounts() { + build_authoring_dir_mounts skill "${OC2_CONFIG_DIR}/skills" + build_authoring_dir_mounts agent "${OC2_CONFIG_DIR}/agents" + build_authoring_dir_mounts command "${OC2_CONFIG_DIR}/commands" + build_personal_authoring_mounts skill "${_HOME}/.claude/skills" "${_HOME}/.agents/skills" + build_personal_authoring_mounts agent "${_HOME}/.claude/agents" "${_HOME}/.agents/agents" + build_personal_authoring_mounts command "${_HOME}/.claude/commands" +} + # ── 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 @@ -721,7 +741,7 @@ main() { build_workdir_mount build_persistent_dotdir build_opencode2_config_mount - build_personal_skills_mounts "${OC2_CONFIG_DIR}/skills" "${_HOME}/.claude/skills" "${_HOME}/.agents/skills" + build_opencode2_authoring_mounts build_opencode2_data_mount build_opencode2_cache_mount build_opencode2_state_dir diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index 3f0f544..42d0cb8 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -45,29 +45,36 @@ read-only. Contribute or update skills through the deployment's documented contribution workflow; do not edit its deployed checkout or managed aliases directly. -## Personal skills directories - -Each wrapper also mounts, read-write and only when they exist, the personal -skills directories its tool reads: - -| Wrapper | Personal skills directories | -| --- | --- | -| `bwopencode` | `~/.config/opencode/skills`, `~/.claude/skills`, `~/.agents/skills` | -| `bwopencode2` | `~/.config/opencode2/opencode/skills`, `~/.claude/skills`, `~/.agents/skills` | -| `bwclaude` | `~/.claude/skills` (`$CLAUDE_CONFIG_DIR/skills` when that is set) | -| `bwcopilot` | `~/.agents/skills`; `~/.copilot/skills` is mounted read-write with the Copilot home | -| `bwcodex` | `~/.codex/skills`, `~/.agents/skills` | - -Each directory is scanned one level deep and every entry (one skill) is -resolved with `readlink -f` and mounted at its usual path. A common setup keeps -skills in a separate repository and symlinks each one in (for example -`~/.agents/skills/some-skill -> ~/src/.../skills/some-skill`); that repository -does not have to be mounted for the skill to work in the sandbox. An entry that -resolves into a blocked location (such as `~/.ssh`), or is a directory that -contains one, is skipped with a warning. Existing skills can be edited from -inside the sandbox (for a symlinked skill, that edits its source repository), -but a new top-level skill created there is discarded on exit, and skills added -on the host while a sandbox is running do not appear in it. +## Authoring directories + +Each wrapper mounts, read-write, the directories where its tool reads +user-authored markdown definitions, so skills, agents, commands and prompts +can be created and edited from inside the sandbox and persist on the host. +The primary directories are created on the host when missing (not under +`--dry-run`); the additional locations are mounted only when they already +exist. + +| Wrapper | Primary (always created) | Additional (if present) | +| --- | --- | --- | +| `bwopencode` | `~/.config/opencode/{skills,agents,commands}` | `~/.claude/{skills,agents,commands}`, `~/.agents/{skills,agents}` | +| `bwopencode2` | `~/.config/opencode2/opencode/{skills,agents,commands}` | `~/.claude/{skills,agents,commands}`, `~/.agents/{skills,agents}` | +| `bwclaude` | `~/.claude/{skills,agents,commands}` (under `$CLAUDE_CONFIG_DIR` when that is set) | | +| `bwcopilot` | `~/.copilot/{skills,agents}` | `~/.agents/skills` | +| `bwcodex` | `~/.codex/{skills,prompts}` | `~/.agents/skills` | + +A common setup keeps skills in a separate repository and symlinks each one in +(for example `~/.agents/skills/some-skill -> ~/src/.../skills/some-skill`). +Each such symlink's resolved target is mounted read-write too, so the skill +works in the sandbox and editing it edits its source repository; the rest of +that repository is not exposed. A symlink that resolves into a blocked +location (such as `~/.ssh`), or to a directory that contains one, is skipped +with a warning, as is an authoring directory that does. + +If the authoring directory itself is a symlink (for example +`~/.config/opencode/skills -> ~/dotfiles/skills`), its resolved target is +mounted at the usual path, but symlinks inside it are not followed this way; +each entry is mounted individually instead. Existing entries work, but a new +top-level entry created in the sandbox is discarded on exit. ## Common options diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index c2dfadd..d2e7ba0 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -2293,59 +2293,138 @@ build_user_bin_mount() { fi } -# ── Personal skills directories (read-write) ──────────────────── -# build_personal_skills_mounts DIR... -# Make the user's personal skills directories (the ones the tool reads, -# e.g. ~/.claude/skills or ~/.agents/skills) available read-write, so the -# agent can update existing skills. Missing directories are skipped. +# ── Authoring directories (read-write, persistent) ────────────── +# The tools let users author reusable definitions (skills, agents, commands, +# prompts) as plain markdown under their config/home directory, and the agent +# is expected to be able to create and edit them from inside the sandbox. The +# tool's home/config directory is a tmpfs, so each such directory must be +# bind-mounted read-write from its real host location to survive the session. # -# Each DIR is scanned one level deep and every entry (one skill) is -# resolved with readlink -f and bound at its logical path, instead of -# binding DIR as one unit. A common layout keeps the skills in a separate -# repository and symlinks each one in (~/.agents/skills/x -> ~/src/.../x); -# that repository is not otherwise mounted, so binding DIR whole would -# leave every such skill dangling. An entry that resolves into a blocked -# location, or is a directory containing one, is skipped with a warning. -# A DIR already covered by an earlier bind (e.g. inside the working -# directory) is left alone. Only the existing entries are host-backed: a -# new top-level skill created inside the sandbox lands on the tmpfs and is +# _path_already_mounted PATH +# Return 0 when PATH, or one of its ancestors, is already registered in +# _MOUNTED_PREFIXES. +_path_already_mounted() { + local _check="$1" + while :; do + [[ -n "${_MOUNTED_PREFIXES["${_check}"]:-}" ]] && return 0 + [[ "${_check}" == "/" ]] && return 1 + _check="${_check%/*}" + [[ -n "${_check}" ]] || _check="/" + done +} + +# _mount_authoring_dir DIR CREATE KIND +# Mount DIR read-write. CREATE=1 creates it on the host first (skipped +# under --dry-run, which must not mutate host state) so a first-time user +# can author into it; CREATE=0 mounts it only when it already exists. KIND +# is the word used in warnings (e.g. "skill"). +# +# The directory is resolved with readlink -f and bound whole at its logical +# path, so entries created inside the sandbox persist on the host. An +# entry that is a symlink (a common layout symlinks each skill in from a +# separate repository) would dangle, since that repository is not +# otherwise mounted, so each symlink's resolved target is also bound +# read-write at its own path. Only that target is exposed, not the +# repository around it. A target that resolves into a blocked location, or +# is a directory containing one, is skipped with a warning. +# +# bwrap cannot bind over a symlink inside a bound directory, so when DIR +# itself is reached through a symlink (its resolved path differs from DIR) +# the symlink targets are not bound this way. That case falls back to +# binding each entry individually, at its logical path, over a tmpfs +# directory: existing entries work, but one created inside the sandbox is # discarded on exit. # -# Must be called AFTER the tool's own home mount: that mount may be a tmpfs -# that would hide a skills directory bound underneath it. -build_personal_skills_mounts() { - local _dir _entry _real _check _covered - for _dir in "$@"; do - [[ -d "${_dir}" ]] || continue +# A DIR that resolves into a blocked location, contains one, or is already +# covered by an earlier bind (e.g. inside the working directory) is left +# alone. Must be called AFTER the tool's own home mount: that mount may be +# a tmpfs that would hide a directory bound underneath it. +_mount_authoring_dir() { + local _dir="$1" _create="$2" _kind="$3" + local _canon _entry _real + + if [[ ! -e "${_dir}" ]]; then + [[ "${_create}" -eq 1 ]] || return 0 + elif [[ ! -d "${_dir}" ]]; then + return 0 + fi - _covered=0 - _check="${_dir}" - while :; do - if [[ -n "${_MOUNTED_PREFIXES["${_check}"]:-}" ]]; then - _covered=1 - break - fi - [[ "${_check}" == "/" ]] && break - _check="${_check%/*}" - [[ -n "${_check}" ]] || _check="/" - done - [[ "${_covered}" -eq 0 ]] || continue + _canon="$(readlink -f -- "${_dir}" 2> /dev/null || true)" + [[ -n "${_canon}" ]] || return 0 + # _check_dir_contains_blocked exits on a hit, so it runs in a subshell + # here to skip the directory instead of aborting the launch. + if _bwrap_path_blocked "${_canon}" || + ! (_check_dir_contains_blocked "${_canon}" "${_kind} directory") 2> /dev/null; then + echo "Warning: ${_kind} directory '${_dir}' resolves into or contains a blocked location (${_canon}); not mounted." >&2 + return 0 + fi + _path_already_mounted "${_dir}" && return 0 + + if [[ "${_create}" -eq 1 && "${DRY_RUN}" -eq 0 ]]; then + mkdir -p -- "${_canon}" + fi - _emit_home_intermediate_dirs "${_dir}" inclusive + _emit_home_intermediate_dirs "${_dir}" inclusive + + if [[ "${_canon}" != "${_dir}" ]]; then + [[ -d "${_canon}" ]] || return 0 for _entry in "${_dir}"/*; do [[ -e "${_entry}" ]] || continue _real="$(readlink -f -- "${_entry}" 2> /dev/null || true)" [[ -n "${_real}" ]] || continue - # _check_dir_contains_blocked exits on a hit, so it runs in a - # subshell here to skip the entry instead of aborting the launch. if _bwrap_path_blocked "${_real}" || - { [[ -d "${_real}" ]] && ! (_check_dir_contains_blocked "${_real}" "skill") 2> /dev/null; }; then - echo "Warning: skill '${_entry}' resolves into or contains a blocked location (${_real}); not mounted." >&2 + { [[ -d "${_real}" ]] && ! (_check_dir_contains_blocked "${_real}" "${_kind}") 2> /dev/null; }; then + echo "Warning: ${_kind} '${_entry}' resolves into or contains a blocked location (${_real}); not mounted." >&2 continue fi BWRAP_ARGS+=(--bind "${_real}" "${_entry}") done _MOUNTED_PREFIXES["${_dir}"]=1 + return 0 + fi + + BWRAP_ARGS+=(--bind "${_canon}" "${_dir}") + _MOUNTED_PREFIXES["${_dir}"]=1 + + [[ -d "${_dir}" ]] || return 0 + for _entry in "${_dir}"/*; do + [[ -L "${_entry}" ]] || continue + _real="$(readlink -f -- "${_entry}" 2> /dev/null || true)" + [[ -n "${_real}" && -e "${_real}" ]] || continue + _path_already_mounted "${_real}" && continue + if _bwrap_path_blocked "${_real}" || + { [[ -d "${_real}" ]] && ! (_check_dir_contains_blocked "${_real}" "${_kind}") 2> /dev/null; }; then + echo "Warning: ${_kind} '${_entry}' resolves into or contains a blocked location (${_real}); not mounted." >&2 + continue + fi + _emit_home_intermediate_dirs "${_real}" + BWRAP_ARGS+=(--bind "${_real}" "${_real}") + _MOUNTED_PREFIXES["${_real}"]=1 + done +} + +# build_authoring_dir_mounts KIND DIR... +# Mount each DIR read-write, creating it on the host when missing so +# authoring works out of the box. Use for the tool's own (primary) +# location of each kind of definition. KIND is "skill", "agent", +# "command" or "prompt". +build_authoring_dir_mounts() { + local _kind="$1" _dir + shift + for _dir in "$@"; do + _mount_authoring_dir "${_dir}" 1 "${_kind}" + done +} + +# build_personal_authoring_mounts KIND DIR... +# Mount additional locations the tool also reads (e.g. ~/.claude/skills or +# ~/.agents/skills) read-write. Missing directories are skipped, not +# created. +build_personal_authoring_mounts() { + local _kind="$1" _dir + shift + for _dir in "$@"; do + _mount_authoring_dir "${_dir}" 0 "${_kind}" done } From e70a583bf56bf480da032f63ebb26621deabbd9d Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 20 Oct 2026 15:29:16 -0400 Subject: [PATCH 32/40] fix(bwrap): refuse bound directories that contain blocked paths _mount_ro_dir_if_needed bound a tool's on-PATH directory whole after only _check_path_safe, which rejects a path that IS or sits inside a blocked location but not a directory that CONTAINS one. An npm CLI symlinked from a directory such as ~/.config (now mounted too, so the invoked path exists in the sandbox) exposed that entire directory, including an existing ~/.config/gh/hosts.yml, to the agent. The root cause was that the two checks had to be paired by hand at every bind site, and one site forgot. Make the safe thing the default: - _check_bind_safe CANON CONTEXT: the check for any path about to be bind-mounted. It runs _check_path_safe and then refuses a path that contains a blocked location. Used by the working directory, --ro-path/--rw-path, _mount_ro_dir_if_needed (so the npm prefix and every on-PATH tool directory) and _bwrap_canonicalize_safe. - _bwrap_bind_blocked CANON: the non-exiting form, for the authoring directory mounts that skip a candidate with a warning. It replaces their `! (_check_dir_contains_blocked ...) 2> /dev/null` subshell construction. - _bwrap_contains_blocked CANON: the shared predicate, non-exiting, which reports the offending entry in _BWRAP_CONTAINED_BLOCKED. - _check_dir_contains_blocked is removed so it cannot be called without its companion again. _check_path_safe now says to use _check_bind_safe when binding. _check_path_safe stays for paths that are validated but not bound whole: tool-home roots (_check_dir_safe) and, via the new "root" mode of _bwrap_canonicalize_safe, CLAUDE_CONFIG_DIR and the opencode/opencode2 config directories. Those only have selected subpaths mounted, and the opencode2 config directory is in EXTRA_BLOCKED_DIRS, so a contains-check there would refuse every launch. _bwrap_canonicalize_safe now applies the full check by default, which newly refuses an opencode/opencode2 config entry that is a symlink to a directory containing a blocked location (e.g. plugins -> ~/dotfiles where ~/.ssh -> ~/dotfiles/ssh); such an entry is bound whole. Addresses review comments on NSLS2/n2snscripts#25 (lib/bwrap_sandbox_lib.sh, _mount_ro_dir_if_needed; reported twice). Assisted-by: OpenCode:claude-sonnet-5-5 --- bin/bwclaude | 2 +- bin/bwopencode | 2 +- bin/bwopencode2 | 4 +- lib/bwrap_sandbox_lib.sh | 112 +++++++++++++++++++++++++-------------- 4 files changed, 76 insertions(+), 44 deletions(-) diff --git a/bin/bwclaude b/bin/bwclaude index 7eae624..a3f4fa0 100755 --- a/bin/bwclaude +++ b/bin/bwclaude @@ -235,7 +235,7 @@ resolve_claude_paths() { # ~/.claude.json by default and $CLAUDE_CONFIG_DIR/.claude.json when # CLAUDE_CONFIG_DIR is set. It is shared with unsandboxed claude. if [[ -n "${CLAUDE_CONFIG_DIR:-}" ]]; then - _bwrap_canonicalize_safe "${CLAUDE_CONFIG_DIR}" "CLAUDE_CONFIG_DIR" + _bwrap_canonicalize_safe "${CLAUDE_CONFIG_DIR}" "CLAUDE_CONFIG_DIR" root CLAUDE_HOME_DIR="${_BWRAP_CANON}" CLAUDE_STATE_FILE="${CLAUDE_HOME_DIR}/.claude.json" else diff --git a/bin/bwopencode b/bin/bwopencode index 6e12acb..5bcf31b 100755 --- a/bin/bwopencode +++ b/bin/bwopencode @@ -252,7 +252,7 @@ resolve_config_symlinks() { [[ -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" + _bwrap_canonicalize_safe "${OC_CONFIG_DIR}" "opencode config dir" root local entry local_resolved for entry in "${OC_CONFIG_DIR}"/*; do diff --git a/bin/bwopencode2 b/bin/bwopencode2 index 0154d47..3918255 100755 --- a/bin/bwopencode2 +++ b/bin/bwopencode2 @@ -350,7 +350,7 @@ resolve_opencode2_paths() { 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 + # shellcheck disable=SC2034 # read by _bwrap_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" @@ -387,7 +387,7 @@ resolve_config_symlinks() { [[ -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" + _bwrap_canonicalize_safe "${OC2_CONFIG_DIR}" "opencode2 config dir" root local entry local_resolved for entry in "${OC2_CONFIG_DIR}"/*; do diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index d2e7ba0..1eb36a0 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -710,9 +710,10 @@ _emit_home_intermediate_dirs() { # actually emitted, DIR is added to _MOUNTED_PREFIXES so future calls # with the same path or a sub-path are no-ops. # Handles intermediate --dir entries for paths under $HOME. -# Calls _check_path_safe before emitting any mount — aborts if DIR +# Calls _check_bind_safe before emitting any mount — aborts if DIR # resolves into a blocked path (e.g. a tool binary that symlinks into -# ~/.ssh or a misconfigured npm prefix pointing at $HOME). +# ~/.ssh or a misconfigured npm prefix pointing at $HOME) or contains one +# (e.g. an on-PATH ~/.config holding ~/.config/gh). _mount_ro_dir_if_needed() { local dir="$1" [[ -d "${dir}" ]] || return 0 @@ -737,7 +738,7 @@ _mount_ro_dir_if_needed() { local _dir_canon _dir_canon="$(readlink -f -- "${dir}" 2> /dev/null || true)" [[ -n "${_dir_canon}" ]] || _dir_canon="${dir}" - _check_path_safe "${_dir_canon}" "dynamic mount" + _check_bind_safe "${_dir_canon}" "dynamic mount" # Create intermediate --dir entries for paths under $HOME (the # tmpfs $HOME has no subdirectories yet). DIR is the bind mount point, @@ -809,7 +810,7 @@ _mount_npm_global_prefix() { # are resolved (npm itself may return a non-canonical path on some systems). local npm_prefix_canon npm_prefix_canon="$(readlink -f "${npm_prefix}" 2> /dev/null || echo "${npm_prefix}")" - _check_path_safe "${npm_prefix_canon}" "npm global prefix" + _check_bind_safe "${npm_prefix_canon}" "npm global prefix" _mount_ro_dir_if_needed "${npm_prefix_canon}" _EXTRA_PATH_DIRS+=("${npm_prefix_canon}/bin") @@ -1116,6 +1117,10 @@ _bwrap_path_blocked() { # 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. # +# This only asks whether CANON is itself blocked. Callers that bind a path +# into the sandbox should use _check_bind_safe, which also refuses a +# directory that CONTAINS a blocked location. +# # CANON : absolute, symlink-free path (output of readlink -f). # CONTEXT : short label used in error messages, e.g. "--ro-path", # "--rw-path", or "working directory". @@ -1193,28 +1198,57 @@ _check_path_safe() { # 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_contains_blocked CANON +# Return 0 (true) when the canonical path 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); the offending +# entry is left in _BWRAP_CONTAINED_BLOCKED. Blocked locations that do not +# exist are ignored. Binding such a directory would expose the blocked +# location through the parent mount (e.g. running from ~/.config would bind +# ~/.config/gh read-write). Does NOT check whether CANON itself is inside a +# blocked tree: use _bwrap_bind_blocked or _check_bind_safe for that. +_bwrap_contains_blocked() { + local canon="$1" _b _t + _BWRAP_CONTAINED_BLOCKED="" _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 + _BWRAP_CONTAINED_BLOCKED="${_b}" + return 0 fi done + return 1 +} + +# _bwrap_bind_blocked CANON +# Non-exiting safety check for a path about to be bind-mounted: return 0 +# (true) when CANON is blocked (_bwrap_path_blocked) OR contains a blocked +# location (_bwrap_contains_blocked). Use where an unsafe candidate is +# skipped with a warning instead of aborting the launcher. +_bwrap_bind_blocked() { + _bwrap_path_blocked "$1" || _bwrap_contains_blocked "$1" +} + +# _check_bind_safe CANON CONTEXT +# The safety check for ANY path about to be bind-mounted: exit 1 with an +# error when CANON is blocked (see _check_path_safe) or, for a directory +# bound whole, contains a blocked location. Use this at every bind-mount +# site rather than _check_path_safe alone, which does not notice a +# directory that contains a blocked path. +# +# _check_path_safe on its own is for paths that are validated but not bound +# whole: a tool-home directory that only has selected subpaths mounted +# (see _check_dir_safe), or a symlink target about to be resolved. +_check_bind_safe() { + local canon="$1" context="$2" + _check_path_safe "${canon}" "${context}" + if _bwrap_contains_blocked "${canon}"; then + echo "Error: ${context} '${canon}' contains the blocked path '${_BWRAP_CONTAINED_BLOCKED}'. Refusing to mount." >&2 + exit 1 + fi } # _check_dir_safe PATH CONTEXT @@ -1243,20 +1277,29 @@ _check_dir_safe() { # _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 +# readlink fails or PATH does not exist) and run _check_bind_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). # +# Pass "root" as MODE for a directory that is validated but NOT bound whole +# (a tool-home directory that only has selected subpaths mounted): then only +# _check_path_safe runs, as a contained blocked location is not exposed. +# # 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. +# the checks call 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" + local mode="${3:-bind}" _BWRAP_CANON="$(readlink -f "${path}" 2> /dev/null || true)" [[ -n "${_BWRAP_CANON}" ]] || _BWRAP_CANON="${path}" - _check_path_safe "${_BWRAP_CANON}" "${context}" + if [[ "${mode}" == "root" ]]; then + _check_path_safe "${_BWRAP_CANON}" "${context}" + else + _check_bind_safe "${_BWRAP_CANON}" "${context}" + fi } # _bwrap_include_visible_path RAW @@ -1383,10 +1426,7 @@ validate_extra_path() { fi # ── Safety check ───────────────────────────────────────────── - _check_path_safe "${canon}" "--${mode}-path" - if [[ -d "${canon}" ]]; then - _check_dir_contains_blocked "${canon}" "--${mode}-path" - fi + _check_bind_safe "${canon}" "--${mode}-path" # All checks passed — return the canonical path. echo "${canon}" @@ -1891,12 +1931,9 @@ build_workdir_mount() { # Guard: reject dangerous working / git-root directories via the same # rules applied to --ro-path / --rw-path. This catches $HOME itself, # ancestors of $HOME (/home, /), /root, ~/.ssh, and all other blocked - # prefixes. - _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. - _check_dir_contains_blocked "${_BIND_DIR}" "working directory" + # prefixes, and a directory that CONTAINS a blocked location (e.g. + # ~/.config with ~/.config/gh): the whole tree is bound read-write. + _check_bind_safe "${_BIND_DIR}" "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 @@ -2351,10 +2388,7 @@ _mount_authoring_dir() { _canon="$(readlink -f -- "${_dir}" 2> /dev/null || true)" [[ -n "${_canon}" ]] || return 0 - # _check_dir_contains_blocked exits on a hit, so it runs in a subshell - # here to skip the directory instead of aborting the launch. - if _bwrap_path_blocked "${_canon}" || - ! (_check_dir_contains_blocked "${_canon}" "${_kind} directory") 2> /dev/null; then + if _bwrap_bind_blocked "${_canon}"; then echo "Warning: ${_kind} directory '${_dir}' resolves into or contains a blocked location (${_canon}); not mounted." >&2 return 0 fi @@ -2372,8 +2406,7 @@ _mount_authoring_dir() { [[ -e "${_entry}" ]] || continue _real="$(readlink -f -- "${_entry}" 2> /dev/null || true)" [[ -n "${_real}" ]] || continue - if _bwrap_path_blocked "${_real}" || - { [[ -d "${_real}" ]] && ! (_check_dir_contains_blocked "${_real}" "${_kind}") 2> /dev/null; }; then + if _bwrap_bind_blocked "${_real}"; then echo "Warning: ${_kind} '${_entry}' resolves into or contains a blocked location (${_real}); not mounted." >&2 continue fi @@ -2392,8 +2425,7 @@ _mount_authoring_dir() { _real="$(readlink -f -- "${_entry}" 2> /dev/null || true)" [[ -n "${_real}" && -e "${_real}" ]] || continue _path_already_mounted "${_real}" && continue - if _bwrap_path_blocked "${_real}" || - { [[ -d "${_real}" ]] && ! (_check_dir_contains_blocked "${_real}" "${_kind}") 2> /dev/null; }; then + if _bwrap_bind_blocked "${_real}"; then echo "Warning: ${_kind} '${_entry}' resolves into or contains a blocked location (${_real}); not mounted." >&2 continue fi From 970726afe0f70c01b3a9e4dbf8a970d8e706b809 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 20 Oct 2026 15:33:58 -0400 Subject: [PATCH 33/40] fix(bwrap): guard the empty extra-path check against set -u on old bash build_extra_path_mounts opened with `[[ ${#EXTRA_RO_PATHS[@]} -eq 0 && ${#EXTRA_RW_PATHS[@]} -eq 0 ]] && return 0`. Both arrays are empty in an ordinary launch, and the project documents bash 4.x support, where expanding empty arrays under `set -u` is fragile before bash 4.4. Test for "neither array has elements" with the guarded expansion `[[ -z "${A[*]+x}${B[*]+x}" ]]`, the same style the loops below already use for their element expansions. Addresses a review comment on NSLS2/n2snscripts#25 (lib/bwrap_sandbox_lib.sh, build_extra_path_mounts). Assisted-by: OpenCode:claude-sonnet-5-5 --- lib/bwrap_sandbox_lib.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index 1eb36a0..830f657 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -1457,7 +1457,9 @@ validate_extra_path() { # build_home_tmpfs (which creates the $HOME tmpfs that intermediate --dir # entries populate). build_extra_path_mounts() { - [[ ${#EXTRA_RO_PATHS[@]} -eq 0 && ${#EXTRA_RW_PATHS[@]} -eq 0 ]] && return 0 + # Guarded expansion (not ${#arr[@]}): empty-array expansions are fragile + # under `set -u` on bash < 4.4. + [[ -z "${EXTRA_RO_PATHS[*]+x}${EXTRA_RW_PATHS[*]+x}" ]] && return 0 local _raw_path _canon _mode local -A _mode_of=() From 223b82c782d7516b0a9850eb496f45b5c0d9e843 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 20 Oct 2026 15:56:17 -0400 Subject: [PATCH 34/40] fix(bwrap): apply binary masks after user mounts build_binary_masks ran right after the base sandbox, before every other mount. bwrap applies binds in order and a later bind that covers a path hides mounts beneath it, so an explicit `--ro-path /usr` (or /usr/bin, or any ancestor of a masked binary) re-bound the host tree over the /dev/null masks and made ssh, sudo, nc and the rest executable again. Confirmed with bwrap directly: mask-then-rebind leaves /usr/bin/true executable; rebind-then-mask leaves it a character device. Move build_binary_masks to the end of the mount sequence in all five launchers, after build_extra_path_mounts (and, in bwcopilot, after the settings and managed-skills binds that follow it). The masks only need the base /usr bind beneath them, so nothing else depends on the old position. Document the ordering requirement on build_binary_masks and in docs/bw-wrappers.md. Not changed: the /dev/tty mask in build_proc_dev_tmp has the same weakness (`--ro-path /dev` is accepted and rebinds /dev), as does the rest of the base mount set. That is a separate decision about which paths user mounts may cover, left for a follow-up. Addresses a review comment on NSLS2/n2snscripts#25 (lib/bwrap_sandbox_lib.sh, build_extra_path_mounts). Assisted-by: OpenCode:claude-sonnet-5-5 --- bin/bwclaude | 5 ++++- bin/bwcodex | 5 ++++- bin/bwcopilot | 5 ++++- bin/bwopencode | 5 ++++- bin/bwopencode2 | 5 ++++- docs/bw-wrappers.md | 4 +++- lib/bwrap_sandbox_lib.sh | 4 ++++ 7 files changed, 27 insertions(+), 6 deletions(-) diff --git a/bin/bwclaude b/bin/bwclaude index a3f4fa0..a135c48 100755 --- a/bin/bwclaude +++ b/bin/bwclaude @@ -538,7 +538,6 @@ main() { # Build bwrap argument list build_base_sandbox - build_binary_masks build_proc_dev_tmp build_sys_mounts build_etc_mounts @@ -558,6 +557,10 @@ main() { build_dynamic_tool_mounts build_extra_path_mounts + # Mask dangerous binaries last: an earlier mask is hidden by any later + # bind that covers it (e.g. --ro-path /usr or /usr/bin). + build_binary_masks + # Environment variables build_env_vars build_claude_env diff --git a/bin/bwcodex b/bin/bwcodex index ed7e7fe..64b3733 100755 --- a/bin/bwcodex +++ b/bin/bwcodex @@ -543,7 +543,6 @@ main() { # Build bwrap argument list build_base_sandbox - build_binary_masks build_proc_dev_tmp build_sys_mounts build_etc_mounts @@ -562,6 +561,10 @@ main() { build_dynamic_tool_mounts build_extra_path_mounts + # Mask dangerous binaries last: an earlier mask is hidden by any later + # bind that covers it (e.g. --ro-path /usr or /usr/bin). + build_binary_masks + # Environment variables build_env_vars build_codex_env diff --git a/bin/bwcopilot b/bin/bwcopilot index 2b6606b..9a2022a 100755 --- a/bin/bwcopilot +++ b/bin/bwcopilot @@ -437,7 +437,6 @@ main() { # Build bwrap argument list build_base_sandbox - build_binary_masks build_proc_dev_tmp build_sys_mounts build_etc_mounts @@ -465,6 +464,10 @@ main() { build_managed_ro_bind /opt/nsls2/skills/skills /opt/nsls2/skills/skills fi + # Mask dangerous binaries last: an earlier mask is hidden by any later + # bind that covers it (e.g. --ro-path /usr or /usr/bin). + build_binary_masks + # Environment variables build_env_vars build_copilot_env diff --git a/bin/bwopencode b/bin/bwopencode index 5bcf31b..c8a20ce 100755 --- a/bin/bwopencode +++ b/bin/bwopencode @@ -544,7 +544,6 @@ main() { # Build bwrap argument list build_base_sandbox - build_binary_masks build_proc_dev_tmp build_sys_mounts build_etc_mounts @@ -566,6 +565,10 @@ main() { build_dynamic_tool_mounts build_extra_path_mounts + # Mask dangerous binaries last: an earlier mask is hidden by any later + # bind that covers it (e.g. --ro-path /usr or /usr/bin). + build_binary_masks + # Environment variables build_env_vars build_opencode_env diff --git a/bin/bwopencode2 b/bin/bwopencode2 index 3918255..82ee195 100755 --- a/bin/bwopencode2 +++ b/bin/bwopencode2 @@ -730,7 +730,6 @@ main() { # Build bwrap argument list build_base_sandbox - build_binary_masks build_proc_dev_tmp build_sys_mounts build_etc_mounts @@ -756,6 +755,10 @@ main() { build_dynamic_tool_mounts build_extra_path_mounts + # Mask dangerous binaries last: an earlier mask is hidden by any later + # bind that covers it (e.g. --ro-path /usr or /usr/bin). + build_binary_masks + # Environment variables build_env_vars build_opencode2_env diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index 42d0cb8..742c855 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -95,7 +95,9 @@ Every `bw*` wrapper accepts these options: overrides it, so `--rw-path DIR --ro-path DIR/sub` mounts `DIR` read-write but keeps `DIR/sub` read-only (the reverse, a read-only tree with one writable subdirectory, works too). A path given as both `--ro-path` and `--rw-path` is -rejected. +rejected. The masks on privileged binaries are applied after all other mounts, +so a path that covers their directory (for example `--ro-path /usr`) does not +undo them. Tool-specific options are listed in each wrapper's section below. diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index 830f657..bfea95d 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -1722,6 +1722,10 @@ build_base_sandbox() { # an existing read-only bind mount. On older versions, we skip masking # entirely (the binaries remain accessible inside the sandbox). # +# Must be called AFTER every other mount step, including user --ro-path / +# --rw-path: a bind that covers a masked binary's directory (e.g. --ro-path +# /usr or /usr/bin) emitted afterwards would hide the mask. +# # Categories: # SSH – lateral movement via remote shell / file transfer # Network – arbitrary TCP/UDP connections, reverse shells From 7e853976218163475476c74f1249e74b9d0d72f0 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 20 Oct 2026 15:57:39 -0400 Subject: [PATCH 35/40] docs(gpg-passwd): drop the stale "bash only" warning docs/gpg-passwd.md still said the library requires bash >= 4.3 and is not zsh-safe because of a `local -n` nameref. That nameref was replaced by validated `eval` indirection (portable to zsh), so the warning now contradicts both the code and the library's own header, and wrongly tells zsh users not to source it. Replace it with a statement that bash and zsh are both supported and why the eval is safe, and bring the rest of the page in line with what the function enforces: required-variable names must be plain shell identifiers (not only free of the reserved __gpgpw_ prefix), and return code 2 also covers an invalid or reserved name. Checked by sourcing the library under bash and zsh with a stubbed gpg: a valid required variable returns 0, an empty one 2, an injection-shaped name 2 and a reserved name 2 in both shells. Addresses a review comment on NSLS2/n2snscripts#25 (docs/gpg-passwd.md). Assisted-by: OpenCode:claude-sonnet-5-5 --- docs/gpg-passwd.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/gpg-passwd.md b/docs/gpg-passwd.md index 8d604b1..5a55af3 100644 --- a/docs/gpg-passwd.md +++ b/docs/gpg-passwd.md @@ -4,9 +4,11 @@ Shell library providing `decrypt_env_file`, a helper for sourcing GPG-encrypted environment files into the current shell. Sourced, not executed. -**Requires bash >= 4.3. Not zsh-safe.** The required-variable check uses a -`local -n` nameref, which does not exist in zsh and needs bash 4.3 or newer. -Source this only from bash callers. +**Works in both bash and zsh**, so it can be sourced from a `~/.bashrc` or a +`~/.zshrc`. The required-variable check reads each variable by `eval` +indirection rather than a bash-only `local -n` nameref; the name is validated +first (see [Required-variable names](#required-variable-names)), so the `eval` +cannot inject code. ## Usage @@ -35,9 +37,10 @@ The trailing `|| rc=$?` both captures the code and suppresses `set -e`. ### Required-variable names -Required-variable names passed to `decrypt_env_file` must not begin with -`__gpgpw_`; that prefix is reserved for the function's own locals and such a -name is rejected with return code `2`. +Required-variable names passed to `decrypt_env_file` must be plain shell +identifiers (letters, digits and underscores, not starting with a digit) and +must not begin with `__gpgpw_`; that prefix is reserved for the function's own +locals. Any other name is rejected with return code `2`. ## Behaviour @@ -52,7 +55,7 @@ name is rejected with return code `2`. | --- | --- | | `0` | Success | | `0` (with warning) | File is missing — no decrypt attempted | -| `2` | A required variable is still empty after decrypt | +| `2` | A required variable is still empty after decrypt, or a required-variable name is invalid or reserved | | `3` | `gpg` failed (bad passphrase, malformed file, etc.) | | `4` | `eval` of decrypted content failed (syntax error or failing command) | From 2011721859643a2b19ecab073a6ee1eb30eb45c7 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 20 Oct 2026 16:00:19 -0400 Subject: [PATCH 36/40] fix(bwrap): keep extra paths containing a newline intact build_extra_path_mounts ordered the validated --ro-path/--rw-path set by piping the keys through a newline-delimited `sort` into `mapfile -t`. A path containing an embedded newline passes validation (it is a legal file name) but was split into two entries by that pipeline, and the following `_mode_of[...]` lookup on a half-path then failed under `set -u`, aborting the launcher with "unbound variable": bwopencode --ro-path "$(printf '/tmp/a\nb')" --dry-run Pass the keys NUL-delimited end to end (`printf '%s\0'`, `sort -z`) and collect them with a `read -r -d ''` loop. mapfile -d would be shorter but needs bash 4.4, and the wrappers still support older 4.x. The ancestors-first ordering is unchanged: an ancestor is still a byte prefix of its descendants, so it sorts before them. Checked with an embedded-newline directory: it is mounted once at its real name, and a --rw-path nested under it plus a sibling --ro-path come out in the expected ancestors-first order. Addresses a review comment on NSLS2/n2snscripts#25 (lib/bwrap_sandbox_lib.sh, build_extra_path_mounts). Assisted-by: OpenCode:claude-sonnet-5-5 --- lib/bwrap_sandbox_lib.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index bfea95d..2b49383 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -1481,9 +1481,14 @@ build_extra_path_mounts() { done # Ancestors first: an ancestor is a byte prefix of its descendants, so a - # plain LC_ALL=C sort orders it before them. + # plain LC_ALL=C sort orders it before them. NUL-delimited end to end so a + # path containing a newline stays one entry (mapfile -d needs bash 4.4, so + # read in a loop). local -a _sorted=() - mapfile -t _sorted < <(printf '%s\n' "${!_mode_of[@]}" | LC_ALL=C sort) + local _sorted_path + while IFS= read -r -d '' _sorted_path; do + _sorted+=("${_sorted_path}") + done < <(printf '%s\0' "${!_mode_of[@]}" | LC_ALL=C sort -z) # Destinations of every bind emitted so far. A path with a bound # ancestor needs no intermediate --dir entries (the ancestor's host tree From a1f52f69059fe125abe8aad75fd52bff39848148 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 20 Oct 2026 16:05:11 -0400 Subject: [PATCH 37/40] fix(bwrap): authoring dirs: handle hidden mounts, dot entries, new homes Three problems in _mount_authoring_dir, found while checking the review comments on NSLS2/n2snscripts#25 against it: - "Already covered" was decided from _MOUNTED_PREFIXES, which records that an ancestor was bound but not that it is still visible. With the tool home inside the working directory (CLAUDE_CONFIG_DIR=$PWD/ .claude, CODEX_HOME=$PWD/.codex) the working-directory bind registers the repository, then the tool-home tmpfs hides the subdirectory; the authoring directory was treated as covered, never mounted, and its skills vanished from the sandbox. Decide it from the effective mount order instead: _path_visible_from_host walks BWRAP_ARGS and the path is covered only if the LAST mount over it is an identity bind (a later --tmpfs or a bind from another source hides it). This replaces _path_already_mounted and is used for the directory and for symlink targets. - The entry loops used a bare `*` glob, which skips dot-prefixed entries: a symlinked `.system` skill was left dangling (whole- directory mode) or missing (symlinked-directory fallback). Iterate `*`, `.[!.]*` and `..?*`. - A primary directory to be created was resolved with `readlink -f`, which prints nothing when a parent is missing, so on a fresh install (no ~/.config/opencode yet) the function returned silently and the directory was neither created nor mounted. Use `readlink -m`. Fresh homes now get working, persistent skills/agents/commands directories for bwopencode, bwclaude and bwcopilot. Checked: a skills directory under a tmpfs-hidden working directory is mounted and readable; dot-prefixed entries (real and symlinked) appear in both modes; fresh homes persist files written in the sandbox; a working directory containing the authoring directory is still not bound twice; --dry-run creates nothing. Addresses two review comments on NSLS2/n2snscripts#25 (lib/bwrap_sandbox_lib.sh, build_personal_skills_mounts coverage check and dot-prefixed entries), which were written against the code this branch's authoring-directory work replaced. Assisted-by: OpenCode:claude-sonnet-5-5 --- lib/bwrap_sandbox_lib.sh | 53 ++++++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index 2b49383..6188050 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -2348,17 +2348,38 @@ build_user_bin_mount() { # tool's home/config directory is a tmpfs, so each such directory must be # bind-mounted read-write from its real host location to survive the session. # -# _path_already_mounted PATH -# Return 0 when PATH, or one of its ancestors, is already registered in -# _MOUNTED_PREFIXES. -_path_already_mounted() { - local _check="$1" - while :; do - [[ -n "${_MOUNTED_PREFIXES["${_check}"]:-}" ]] && return 0 - [[ "${_check}" == "/" ]] && return 1 - _check="${_check%/*}" - [[ -n "${_check}" ]] || _check="/" +# _path_visible_from_host PATH +# Return 0 when PATH already shows its host contents in the sandbox given +# the mounts emitted so far, i.e. the LAST mount in BWRAP_ARGS covering PATH +# (at PATH or an ancestor) is an identity bind (source == destination). A +# registered ancestor in _MOUNTED_PREFIXES is not enough: a later tmpfs (a +# tool home such as CODEX_HOME=$PWD/.codex inside the working directory) or +# a bind from a different source hides what an earlier bind exposed. +_path_visible_from_host() { + local _path="$1" _i=0 _flag _src _dest _visible=0 + while [[ ${_i} -lt ${#BWRAP_ARGS[@]} ]]; do + _flag="${BWRAP_ARGS[${_i}]}" + _bwrap_flag_arity "${_flag}" + case "${_flag}" in + --tmpfs) + _dest="${BWRAP_ARGS[$((_i + 1))]}" + _is_prefix_of "${_dest}" "${_path}" && _visible=0 + ;; + --bind | --ro-bind | --bind-try | --ro-bind-try | --dev-bind | --dev-bind-try) + _src="${BWRAP_ARGS[$((_i + 1))]}" + _dest="${BWRAP_ARGS[$((_i + 2))]}" + if _is_prefix_of "${_dest}" "${_path}"; then + if [[ "${_src}" == "${_dest}" ]]; then + _visible=1 + else + _visible=0 + fi + fi + ;; + esac + _i=$((_i + 1 + _BWRAP_FLAG_ARITY)) done + [[ ${_visible} -eq 1 ]] } # _mount_authoring_dir DIR CREATE KIND @@ -2397,13 +2418,15 @@ _mount_authoring_dir() { return 0 fi - _canon="$(readlink -f -- "${_dir}" 2> /dev/null || true)" + # -m, not -f: a directory about to be created may have missing parents + # too (a fresh ~/.config/opencode), and -f yields nothing for those. + _canon="$(readlink -m -- "${_dir}" 2> /dev/null || true)" [[ -n "${_canon}" ]] || return 0 if _bwrap_bind_blocked "${_canon}"; then echo "Warning: ${_kind} directory '${_dir}' resolves into or contains a blocked location (${_canon}); not mounted." >&2 return 0 fi - _path_already_mounted "${_dir}" && return 0 + _path_visible_from_host "${_dir}" && return 0 if [[ "${_create}" -eq 1 && "${DRY_RUN}" -eq 0 ]]; then mkdir -p -- "${_canon}" @@ -2413,7 +2436,7 @@ _mount_authoring_dir() { if [[ "${_canon}" != "${_dir}" ]]; then [[ -d "${_canon}" ]] || return 0 - for _entry in "${_dir}"/*; do + for _entry in "${_dir}"/* "${_dir}"/.[!.]* "${_dir}"/..?*; do [[ -e "${_entry}" ]] || continue _real="$(readlink -f -- "${_entry}" 2> /dev/null || true)" [[ -n "${_real}" ]] || continue @@ -2431,11 +2454,11 @@ _mount_authoring_dir() { _MOUNTED_PREFIXES["${_dir}"]=1 [[ -d "${_dir}" ]] || return 0 - for _entry in "${_dir}"/*; do + for _entry in "${_dir}"/* "${_dir}"/.[!.]* "${_dir}"/..?*; do [[ -L "${_entry}" ]] || continue _real="$(readlink -f -- "${_entry}" 2> /dev/null || true)" [[ -n "${_real}" && -e "${_real}" ]] || continue - _path_already_mounted "${_real}" && continue + _path_visible_from_host "${_real}" && continue if _bwrap_bind_blocked "${_real}"; then echo "Warning: ${_kind} '${_entry}' resolves into or contains a blocked location (${_real}); not mounted." >&2 continue From b05cfd33fa0e55154e2f61b611ffef3f259d26c1 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 20 Oct 2026 16:43:13 -0400 Subject: [PATCH 38/40] MNT: error if opencode(2) is not configured at all Need at least an empty file to not be surprised to hit their providers. Assisted-by: opencode:claude-sonnet-5.5 --- bin/bwopencode | 6 ++++++ bin/bwopencode2 | 6 ++++++ lib/bwrap_sandbox_lib.sh | 26 ++++++++++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/bin/bwopencode b/bin/bwopencode index c8a20ce..b0312ba 100755 --- a/bin/bwopencode +++ b/bin/bwopencode @@ -5,6 +5,10 @@ # - 0.4.0: base functionality # - 0.6.3+: bind over ro-bind (fallback: skip binary masking) # +# Requires at least a minimal OpenCode config: /etc/opencode/opencode.json{,c} +# or a user opencode.json, opencode.jsonc or config.json in ~/.config/opencode. +# Exits 2 if neither exists. +# # The sandbox provides filesystem isolation: # - Current directory: read-write # - OpenCode config: read-only (symlinks resolved), except the authoring @@ -533,6 +537,8 @@ main() { detect_kernel_capabilities resolve_common_paths resolve_opencode_paths + # Fail before any host-state change (ensure_host_dirs) if nothing is configured. + require_opencode_config bwopencode "${OC_CONFIG_DIR}" detect_shell # Skip host-state mutations on dry-run so --dry-run remains side-effect-free. if [[ "${DRY_RUN}" -eq 0 ]]; then diff --git a/bin/bwopencode2 b/bin/bwopencode2 index 82ee195..fd71128 100755 --- a/bin/bwopencode2 +++ b/bin/bwopencode2 @@ -9,6 +9,10 @@ # role installs. OpenCode 2 is a separate product from # OpenCode 1 (see bwopencode) and never shares its directories. # +# Requires at least a minimal OpenCode config: /etc/opencode/opencode.json{,c} +# or a user opencode.json, opencode.jsonc or config.json in +# ~/.config/opencode2/opencode. Exits 2 if neither exists. +# # The sandbox provides filesystem isolation: # - Current directory: read-write # - OpenCode 2 config (~/.config/opencode2/opencode): read-only (symlinks resolved); @@ -719,6 +723,8 @@ main() { detect_kernel_capabilities resolve_common_paths resolve_opencode2_paths + # Fail before any host-state change (ensure_host_dirs) if nothing is configured. + require_opencode_config bwopencode2 "${OC2_CONFIG_DIR}" detect_shell # Skip host-state mutations on dry-run so --dry-run remains side-effect-free. if [[ "${DRY_RUN}" -eq 0 ]]; then diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index 6188050..2ec4660 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -1888,6 +1888,32 @@ build_etc_mounts() { ) } +# ── OpenCode config requirement ─────────────────────────────────── +# require_opencode_config WRAPPER USER_CONFIG_DIR +# Exit 2 unless at least a minimal OpenCode config exists: a system-wide +# /etc/opencode/opencode.json{,c} (deployed by ansible on managed hosts) or +# a user opencode.json, opencode.jsonc or config.json in USER_CONFIG_DIR. +# Either is sufficient; OpenCode layers the user config under the managed +# one. Without any, OpenCode starts with no provider or model and fails +# with a confusing error of its own. Only the files' presence is checked, +# not their content. OPENCODE_CONFIG* is not forwarded into the sandbox, +# so it is no substitute. +require_opencode_config() { + local _wrapper="$1" _user_dir="$2" _f + for _f in /etc/opencode/opencode.json /etc/opencode/opencode.jsonc; do + [[ -f "${_f}" && -r "${_f}" ]] && return 0 + done + for _f in opencode.json opencode.jsonc config.json; do + [[ -f "${_user_dir}/${_f}" && -r "${_user_dir}/${_f}" ]] && return 0 + done + printf 'Error: %s needs at least a minimal OpenCode config to run.\n' "${_wrapper}" >&2 + printf ' No config was found in either place:\n' >&2 + printf ' /etc/opencode/opencode.json or opencode.jsonc (system-wide; deployed by ansible)\n' >&2 + printf ' %s/opencode.json, opencode.jsonc or config.json (user)\n' "${_user_dir}" >&2 + printf ' Create at least one.\n' >&2 + exit 2 +} + # ── Managed system paths (read-only) ────────────────────────────── # Bind a fixed, administrator-provided path only when it exists. Resolve # aliases before binding: the source can be an /etc symlink into the managed From af7bc7fc81bf51c256356d5b75dfa9bbf6b71bba Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 20 Oct 2026 16:50:32 -0400 Subject: [PATCH 39/40] feat(bwopencode2): mount the opencode2 directories at the standard locations bwopencode2 kept OpenCode 2 apart from OpenCode 1 by pointing all four XDG_* variables at ~/.config|.local/share|.local/state|.cache/opencode2 inside the sandbox. That forced a workaround for every other tool that reads $XDG_CONFIG_HOME or $XDG_CACHE_HOME (git, pixi, ccache), and made the sandbox differ from every other wrapper. Keep the opencode2 directories as the HOST convention (they match the ansible `opencode2` wrapper and keep OpenCode 2 config, data and cache apart from OpenCode 1 on the host), but mount them at the standard locations in the sandbox: host ~/.config/opencode2/opencode -> ~/.config/opencode host ~/.local/share/opencode2/opencode -> ~/.local/share/opencode host ~/.cache/opencode2/opencode -> ~/.cache/opencode ephemeral ~/.local/state/opencode (under $XDG_CONFIG_HOME etc. as the shared library resolves them, as bwopencode does). No XDG_* variable is set any more; OpenCode 2 finds its directories at the defaults. - The variables are split into host sources (OC2_CONFIG_SRC, OC2_DATA_SRC, OC2_CACHE_SRC) and sandbox destinations (OC2_CONFIG_DIR, OC2_DATA_DIR, OC2_CACHE_DIR, OC2_STATE_DIR). Config entries are bound read-only at OC2_CONFIG_DIR/; the data and cache directories are bound from their host sources; project-dotfiles stay under the host data directory. The minimal-config check now looks at the host config directory (OC2_CONFIG_SRC), since OC2_CONFIG_DIR is the sandbox path. - service.json is still never mounted, and the host opencode2 config directory stays in EXTRA_BLOCKED_DIRS. - build_xdg_mirror_mounts is removed. It existed only to symlink git, pixi and ccache config into the opencode2 XDG homes; with the standard locations they find the library's mounts directly. The library flag _BWRAP_XDG_GIT_HAS_FILES, which only it read, goes too. - The read-write skills/agents/commands directories live under the host config directory and are mounted under the sandbox one. _mount_authoring_dir gains an optional SRC (the host directory to mount at DIR) behind a new build_authoring_dir_mounts_from. Because host and sandbox paths differ, a relative symlink inside such a directory would resolve against the new location and dangle, so it is skipped with a warning; absolute symlinks work as before. OpenCode 1's config, data and cache are never mounted into bwopencode2: OpenCode 2 migrates the OpenCode 1 session database one way and cannot load its plugins, so a fallback to the plain ~/.config/opencode was considered and dropped. Data and cache likewise always come from the opencode2 host directories. Documented in the launcher header, --help and docs/bw-wrappers.md (the config/data/cache table, the isolation notes, and the removed git, pixi and ccache mirror paragraph). Assisted-by: OpenCode:claude-sonnet-5-5 --- bin/bwopencode2 | 197 +++++++++++++++------------------------ docs/bw-wrappers.md | 83 ++++++++++------- lib/bwrap_sandbox_lib.sh | 54 +++++++---- 3 files changed, 156 insertions(+), 178 deletions(-) diff --git a/bin/bwopencode2 b/bin/bwopencode2 index fd71128..6ef4213 100755 --- a/bin/bwopencode2 +++ b/bin/bwopencode2 @@ -7,7 +7,13 @@ # # 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. +# OpenCode 1 (see bwopencode) and keeps its config, data and cache apart from +# it on the host. +# +# On the host the opencode2 convention separates the directories +# (~/.config|.local/share|.cache/opencode2/opencode); inside the sandbox +# everything is mounted at the standard locations (~/.config/opencode, ...), so +# no XDG_* override is set. # # Requires at least a minimal OpenCode config: /etc/opencode/opencode.json{,c} # or a user opencode.json, opencode.jsonc or config.json in @@ -15,30 +21,30 @@ # # The sandbox provides filesystem isolation: # - Current directory: read-write -# - OpenCode 2 config (~/.config/opencode2/opencode): read-only (symlinks resolved); -# service.json / service-*.json (background-service settings) are not mounted -# - Authoring directories (~/.config/opencode2/opencode/{skills,agents,commands}): -# read-write, always created on the host, so definitions authored in the -# sandbox persist; skills symlinked in from elsewhere (e.g. a shared -# skills repository) work too +# - OpenCode 2 config (host ~/.config/opencode2/opencode, mounted at +# ~/.config/opencode): read-only (symlinks resolved); service.json / service-*.json +# (background-service settings) are not mounted +# - Authoring directories ({skills,agents,commands} under the host config +# directory, mounted under ~/.config/opencode): read-write, always created on the host, so +# definitions authored in the sandbox persist; skills symlinked in from +# elsewhere (e.g. a shared skills repository) work too # - Additional skills/agents/commands locations OpenCode reads # (~/.claude/{skills,agents,commands}, ~/.agents/{skills,agents}): # read-write, only if they already exist -# - 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 +# - OpenCode 2 data (host ~/.local/share/opencode2/opencode, mounted at +# ~/.local/share/opencode): read-write, the whole directory persists +# (database incl. saved sign-in, logs, ...) +# - OpenCode 2 cache (host ~/.cache/opencode2/opencode, mounted at +# ~/.cache/opencode): read-write +# - OpenCode 2 state (~/.local/state/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; only the -# allowed_signers, config, ignore and attributes files of git/ are -# mounted, never credentials) +# - OpenCode 1's config, database and other data are never mounted +# (OpenCode 2 would migrate the database one way) +# - Git config + includes: read-only (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 @@ -206,8 +212,10 @@ Wrapper options (consumed before opencode2 sees the command line): 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 +On the host OpenCode 2 data lives under ~/.local/share|.cache/opencode2/opencode +(never the OpenCode 1 directories); inside the sandbox it is mounted at the +standard locations (~/.local/share/opencode, ...), and the config is +~/.config/opencode2/opencode mounted at ~/.config/opencode. 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 @@ -343,41 +351,48 @@ guard_tool_args() { } 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" - # The config directory holds service.json, which is never mounted: refuse - # a working directory / --ro-path / --rw-path that would bind it whole. + # Host side: the opencode2-specific directories, derived from the resolved + # $HOME (never from XDG_*), matching the ansible `opencode2` wrapper, which + # exports XDG_*_HOME=$HOME/.{config,local/share,local/state,cache}/opencode2 + # (OpenCode 2 appends "opencode" to each). They keep OpenCode 2 data apart + # from OpenCode 1 on the host. + OC2_CONFIG_SRC="${_HOME}/.config/opencode2/opencode" + OC2_DATA_SRC="${_HOME}/.local/share/opencode2/opencode" + OC2_CACHE_SRC="${_HOME}/.cache/opencode2/opencode" + + # Sandbox side: always the standard locations, which is where OpenCode 2 + # looks by default, so no XDG_* override is needed. The XDG_* values are + # the ones the shared library resolved (the host's, or ~/.config etc.), the + # same way bwopencode does. State is ephemeral at the default location. + OC2_CONFIG_DIR="${XDG_CONFIG_HOME}/opencode" + OC2_DATA_DIR="${XDG_DATA_HOME}/opencode" + OC2_CACHE_DIR="${XDG_CACHE_HOME}/opencode" + OC2_STATE_DIR="${_HOME}/.local/state/opencode" + + # The opencode2 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 _bwrap_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" + EXTRA_BLOCKED_DIRS=("${OC2_CONFIG_SRC}") # 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" + _check_dir_safe "${OC2_DATA_SRC}" "opencode2 data dir" + _check_dir_safe "${OC2_CACHE_SRC}" "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}" + mkdir -p "${OC2_DATA_SRC}" "${OC2_CACHE_SRC}" } # 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. +# We resolve each entry in the host config directory (OC2_CONFIG_SRC, +# ~/.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 +# under OC2_CONFIG_DIR, the standard location inside the sandbox. This avoids exposing the dotfiles tree inside the sandbox. # # Every path is canonicalized with realpath (readlink -f) and the canonical # result is passed through _check_path_safe before being added to @@ -388,13 +403,13 @@ ensure_host_dirs() { # the unresolved name alone would hide the real target. resolve_config_symlinks() { OC2_CONFIG_BINDS=() - [[ -d "${OC2_CONFIG_DIR}" ]] || return 0 + [[ -d "${OC2_CONFIG_SRC}" ]] || return 0 # The config directory itself may be (or sit under) a symlink. - _bwrap_canonicalize_safe "${OC2_CONFIG_DIR}" "opencode2 config dir" root + _bwrap_canonicalize_safe "${OC2_CONFIG_SRC}" "opencode2 config dir" root local entry local_resolved - for entry in "${OC2_CONFIG_DIR}"/*; do + for entry in "${OC2_CONFIG_SRC}"/*; do [[ -e "${entry}" ]] || continue # service.json (or service-.json on other channels) holds the # background-service settings, including `service set env KEY VALUE` @@ -409,7 +424,7 @@ resolve_config_symlinks() { esac _bwrap_canonicalize_safe "${entry}" "opencode2 config entry '${entry}'" local_resolved="${_BWRAP_CANON}" - OC2_CONFIG_BINDS+=("--ro-bind" "${local_resolved}" "${entry}") + OC2_CONFIG_BINDS+=("--ro-bind" "${local_resolved}" "${OC2_CONFIG_DIR}/${entry##*/}") done } @@ -417,13 +432,15 @@ resolve_config_symlinks() { # skills/, agents/ and commands/ under the OpenCode 2 config directory hold # user-authored markdown definitions. They are created on the host when # missing and mounted read-write so the agent can create and edit them from -# inside the sandbox. The Claude- and agent-compatible locations OpenCode +# inside the sandbox. They live under the host config directory +# (OC2_CONFIG_SRC) and are mounted at the standard location (OC2_CONFIG_DIR). +# The Claude- and agent-compatible locations OpenCode # also reads are mounted read-write only if they already exist. Must run # AFTER build_opencode2_config_mount (which creates the config directory). build_opencode2_authoring_mounts() { - build_authoring_dir_mounts skill "${OC2_CONFIG_DIR}/skills" - build_authoring_dir_mounts agent "${OC2_CONFIG_DIR}/agents" - build_authoring_dir_mounts command "${OC2_CONFIG_DIR}/commands" + build_authoring_dir_mounts_from skill "${OC2_CONFIG_SRC}/skills" "${OC2_CONFIG_DIR}/skills" + build_authoring_dir_mounts_from agent "${OC2_CONFIG_SRC}/agents" "${OC2_CONFIG_DIR}/agents" + build_authoring_dir_mounts_from command "${OC2_CONFIG_SRC}/commands" "${OC2_CONFIG_DIR}/commands" build_personal_authoring_mounts skill "${_HOME}/.claude/skills" "${_HOME}/.agents/skills" build_personal_authoring_mounts agent "${_HOME}/.claude/agents" "${_HOME}/.agents/agents" build_personal_authoring_mounts command "${_HOME}/.claude/commands" @@ -459,7 +476,7 @@ build_managed_config() { 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 + printf ' Falling back to user config at %s/.\n' "${OC2_CONFIG_SRC}" >&2 fi } @@ -490,7 +507,7 @@ build_managed_extras() { # 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//. +# stored on the host 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 @@ -498,7 +515,7 @@ build_persistent_dotdir() { 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}" + local _persist_dir="${OC2_DATA_SRC}/project-dotfiles/${_repo_hash}" # The host mkdir is skipped on --dry-run (no host-state changes); the # bind is still emitted so the printed command matches a real run. if [[ "${DRY_RUN}" -eq 0 ]]; then @@ -527,7 +544,7 @@ build_opencode2_config_mount() { # undocumented. build_opencode2_data_mount() { BWRAP_ARGS+=( - --bind "${OC2_DATA_DIR}" "${OC2_DATA_DIR}" + --bind "${OC2_DATA_SRC}" "${OC2_DATA_DIR}" ) } @@ -535,7 +552,7 @@ build_opencode2_data_mount() { # Provider npm packages are installed here, so it must be writable. build_opencode2_cache_mount() { BWRAP_ARGS+=( - --bind "${OC2_CACHE_DIR}" "${OC2_CACHE_DIR}" + --bind "${OC2_CACHE_SRC}" "${OC2_CACHE_DIR}" ) } @@ -549,57 +566,6 @@ build_opencode2_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. -# 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 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_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. - if [[ -f "${XDG_CONFIG_HOME}/pixi/config.toml" ]]; then - 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+=(--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+=(--symlink "${CCACHE_CACHE_DIR}" "${OC2_XDG_CACHE_HOME}/ccache") - fi - fi -} - # ── OpenCode env vars ──────────────────────────────────────────── # Pass through ONLY the allowlisted OPENCODE_* env vars (harmless behaviour # switches). Every other OPENCODE_* variable is dropped, because the @@ -633,11 +599,8 @@ build_opencode2_install_mount() { fi } -# Must run AFTER build_env_vars: the XDG_* values set here override the -# host pass-through from build_env_vars (--setenv overrides the forwarded -# environment), -# so OpenCode 2 uses its own directories even if `opencode2` on PATH is a -# plain binary rather than the ansible wrapper. +# Must run AFTER build_env_vars. No XDG_* override is set: OpenCode 2 finds +# its directories at the standard locations they are mounted at. 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. @@ -688,15 +651,6 @@ build_opencode2_env() { if [[ "${FORWARD_GH_TOKENS}" -eq 1 ]]; then forward_env_by_prefix GH_TOKEN_ 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}" - ) } # ═══════════════════════════════════════════════════════════════════ @@ -724,7 +678,7 @@ main() { resolve_common_paths resolve_opencode2_paths # Fail before any host-state change (ensure_host_dirs) if nothing is configured. - require_opencode_config bwopencode2 "${OC2_CONFIG_DIR}" + require_opencode_config bwopencode2 "${OC2_CONFIG_SRC}" detect_shell # Skip host-state mutations on dry-run so --dry-run remains side-effect-free. if [[ "${DRY_RUN}" -eq 0 ]]; then @@ -754,7 +708,6 @@ main() { build_pixi_mounts build_ccache_mounts build_npm_mounts - build_xdg_mirror_mounts build_user_bin_mount build_nsls2_mount build_opencode2_install_mount diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index 742c855..d7c8c70 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -260,6 +260,13 @@ project-dotfiles to the host data dir; `auth.json` is persisted only if it already exists on the host (use `--init-auth` once to create it for personal accounts). +Requires at least a minimal OpenCode config: `/etc/opencode/opencode.json` or +`opencode.jsonc` (deployed by ansible on managed hosts), or an `opencode.json`, +`opencode.jsonc` or `config.json` in `~/.config/opencode`. Either is enough. +If none exists the wrapper exits with status 2 before touching anything on the +host (`--dry-run` and `--exec` included; `--help` is unaffected). Only the +presence of a readable file is checked, not its content. + ### `OPENCODE_*` environment allowlist Only an allowlist of harmless `OPENCODE_*` behaviour toggles (46 names) is @@ -296,8 +303,8 @@ variables are printed to stderr. To permit another variable, add it to 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. +because OpenCode 2 uses different directories on the host and has a background +service that must not be reachable from the sandbox. ```text bwopencode2 [bwopencode2-options] [opencode2 arguments...] @@ -306,27 +313,30 @@ bwopencode2 [bwopencode2-options] [opencode2 arguments...] Requires the `opencode2` command on `PATH`, which the ansible base role installs. +Requires at least a minimal OpenCode config, as `bwopencode` does: the +system-wide `/etc/opencode/opencode.json{,c}` (shared with `bwopencode`), or an +`opencode.json`, `opencode.jsonc` or `config.json` in +`~/.config/opencode2/opencode`. If neither exists the wrapper exits with +status 2. The OpenCode 1 config in `~/.config/opencode` is not used. + Accepts the common options above except `--init-auth`. -### Isolation from OpenCode 1 +### Host directories and sandbox locations -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 | +On the host OpenCode 2 follows the ansible-installed `opencode2` wrapper's +convention of separate `opencode2` directories. Inside the sandbox everything is +mounted at the **standard** locations (the same ones `bwopencode` uses, under +`$XDG_CONFIG_HOME` and so on), so the wrapper sets no `XDG_*` variable and git, +pixi and ccache find their config where the shared library mounts it. + +| Host | Sandbox | Mount | +| --- | --- | --- | +| `~/.config/opencode2/opencode` | `~/.config/opencode` | read-only, symlinks resolved | +| `~/.local/share/opencode2/opencode` | `~/.local/share/opencode` | read-write, the whole directory persists | +| `~/.cache/opencode2/opencode` | `~/.cache/opencode` | read-write (provider npm packages install here) | +| (none) | `~/.local/state/opencode` | not persisted (ephemeral) | +| `/.opencode` | `/.opencode` | persistent copy under the host data dir `project-dotfiles/`, so repository content cannot inject config or plugins | +| `/etc/opencode/opencode.json{,c}`, `skills`, `agents`, `commands`, `plugins` | same | 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 @@ -335,22 +345,23 @@ 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 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. -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 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. +### Config and authoring directories + +A system-wide `/etc/opencode` config is mounted when present, and the user +config in `~/.config/opencode2/opencode` is layered on it, so personal tuning +still works on managed hosts. The `skills`, `agents` and `commands` read-write +authoring directories (see above) live under the host config directory and are +mounted under `~/.config/opencode`; because the host and sandbox paths differ, a +relative symlink inside them cannot resolve and is skipped with a warning +(absolute symlinks work). + +### Isolation from OpenCode 1 + +OpenCode 2 migrates the OpenCode 1 session database one way and cannot load +OpenCode 1 plugins, so on the host it uses its own `opencode2` config, data and +cache directories, and the OpenCode 1 ones are never mounted, even when an +`opencode2` directory does not exist yet (data and cache are created on first +run). ### What is kept out of the sandbox diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index 2ec4660..a45f1d1 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -146,11 +146,6 @@ declare -A _GIT_LINK_STATUS=() # 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 -# 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="" _GIT_ROOT="" @@ -2119,11 +2114,9 @@ _bwrap_git_recreate_link() { # 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 @@ -2158,7 +2151,6 @@ _bwrap_mount_xdg_git_files() { for _k in "${!names[@]}"; do BWRAP_ARGS+=(--ro-bind "${reals[${_k}]}" "${dest_dir}/${names[${_k}]}") done - _BWRAP_XDG_GIT_HAS_FILES=1 } # _bwrap_git_lexical_mount RAW INC_PATH @@ -2408,12 +2400,16 @@ _path_visible_from_host() { [[ ${_visible} -eq 1 ]] } -# _mount_authoring_dir DIR CREATE KIND +# _mount_authoring_dir DIR CREATE KIND [SRC] # Mount DIR read-write. CREATE=1 creates it on the host first (skipped # under --dry-run, which must not mutate host state) so a first-time user # can author into it; CREATE=0 mounts it only when it already exists. KIND # is the word used in warnings (e.g. "skill"). # +# SRC is the host directory to mount at DIR, for when the tool looks in one +# place and the files live in another (default: DIR itself). CREATE and the +# existence test apply to SRC. +# # The directory is resolved with readlink -f and bound whole at its logical # path, so entries created inside the sandbox persist on the host. An # entry that is a symlink (a common layout symlinks each skill in from a @@ -2421,7 +2417,9 @@ _path_visible_from_host() { # otherwise mounted, so each symlink's resolved target is also bound # read-write at its own path. Only that target is exposed, not the # repository around it. A target that resolves into a blocked location, or -# is a directory containing one, is skipped with a warning. +# is a directory containing one, is skipped with a warning. With a +# distinct SRC a RELATIVE symlink would resolve against DIR inside the +# sandbox rather than against SRC, so it is skipped with a warning. # # bwrap cannot bind over a symlink inside a bound directory, so when DIR # itself is reached through a symlink (its resolved path differs from DIR) @@ -2435,24 +2433,26 @@ _path_visible_from_host() { # alone. Must be called AFTER the tool's own home mount: that mount may be # a tmpfs that would hide a directory bound underneath it. _mount_authoring_dir() { - local _dir="$1" _create="$2" _kind="$3" - local _canon _entry _real + local _dir="$1" _create="$2" _kind="$3" _src="${4:-$1}" + local _canon _entry _real _link - if [[ ! -e "${_dir}" ]]; then + if [[ ! -e "${_src}" ]]; then [[ "${_create}" -eq 1 ]] || return 0 - elif [[ ! -d "${_dir}" ]]; then + elif [[ ! -d "${_src}" ]]; then return 0 fi # -m, not -f: a directory about to be created may have missing parents # too (a fresh ~/.config/opencode), and -f yields nothing for those. - _canon="$(readlink -m -- "${_dir}" 2> /dev/null || true)" + _canon="$(readlink -m -- "${_src}" 2> /dev/null || true)" [[ -n "${_canon}" ]] || return 0 if _bwrap_bind_blocked "${_canon}"; then - echo "Warning: ${_kind} directory '${_dir}' resolves into or contains a blocked location (${_canon}); not mounted." >&2 + echo "Warning: ${_kind} directory '${_src}' resolves into or contains a blocked location (${_canon}); not mounted." >&2 return 0 fi - _path_visible_from_host "${_dir}" && return 0 + if [[ "${_src}" == "${_dir}" ]]; then + _path_visible_from_host "${_dir}" && return 0 + fi if [[ "${_create}" -eq 1 && "${DRY_RUN}" -eq 0 ]]; then mkdir -p -- "${_canon}" @@ -2460,7 +2460,7 @@ _mount_authoring_dir() { _emit_home_intermediate_dirs "${_dir}" inclusive - if [[ "${_canon}" != "${_dir}" ]]; then + if [[ "${_src}" == "${_dir}" && "${_canon}" != "${_dir}" ]]; then [[ -d "${_canon}" ]] || return 0 for _entry in "${_dir}"/* "${_dir}"/.[!.]* "${_dir}"/..?*; do [[ -e "${_entry}" ]] || continue @@ -2479,11 +2479,18 @@ _mount_authoring_dir() { BWRAP_ARGS+=(--bind "${_canon}" "${_dir}") _MOUNTED_PREFIXES["${_dir}"]=1 - [[ -d "${_dir}" ]] || return 0 - for _entry in "${_dir}"/* "${_dir}"/.[!.]* "${_dir}"/..?*; do + [[ -d "${_canon}" ]] || return 0 + for _entry in "${_canon}"/* "${_canon}"/.[!.]* "${_canon}"/..?*; do [[ -L "${_entry}" ]] || continue _real="$(readlink -f -- "${_entry}" 2> /dev/null || true)" [[ -n "${_real}" && -e "${_real}" ]] || continue + if [[ "${_src}" != "${_dir}" ]]; then + _link="$(readlink -- "${_entry}" 2> /dev/null || true)" + if [[ "${_link}" != /* ]]; then + echo "Warning: ${_kind} '${_entry}' is a relative symlink and would not resolve at '${_dir}'; not mounted." >&2 + continue + fi + fi _path_visible_from_host "${_real}" && continue if _bwrap_bind_blocked "${_real}"; then echo "Warning: ${_kind} '${_entry}' resolves into or contains a blocked location (${_real}); not mounted." >&2 @@ -2508,6 +2515,13 @@ build_authoring_dir_mounts() { done } +# build_authoring_dir_mounts_from KIND SRC DEST +# Like build_authoring_dir_mounts for one directory, but mount the host +# directory SRC (created when missing) at DEST, where the tool looks. +build_authoring_dir_mounts_from() { + _mount_authoring_dir "$3" 1 "$1" "$2" +} + # build_personal_authoring_mounts KIND DIR... # Mount additional locations the tool also reads (e.g. ~/.claude/skills or # ~/.agents/skills) read-write. Missing directories are skipped, not From 1cd82c8bfeb5c4e3b4decd9f401f30202bf42bfa Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 20 Oct 2026 17:03:47 -0400 Subject: [PATCH 40/40] fix(bwrap): apply binary masks as the very last step in every launcher 223b82c moved build_binary_masks after the user --ro-path/--rw-path mounts, but left it ahead of build_env_vars and the per-launcher env builder. Those can still emit binds: bwcodex's build_codex_env calls build_ssl_cert_file_mount, which --ro-binds the canonical SSL_CERT_FILE. A bind placed after a mask hides it, so SSL_CERT_FILE pointing at a masked binary (or a file whose canonical path is one) re-exposed that binary. Run build_binary_masks last in bwclaude, bwcodex, bwcopilot, bwopencode and bwopencode2, after the env builders, immediately before the dry-run / launch step. Env builders only add --setenv otherwise, so nothing else changes; the argument vector differs only in order. Update the ordering requirement on build_binary_masks and in docs/bw-wrappers.md accordingly. Checked with --dry-run on all five launchers (with --ro-path /usr): the last mount flag is the /dev/null mask in each. For bwcodex with SSL_CERT_FILE set, the cert bind used to come after the masks (line 250 vs masks ending at 223) and now comes before them (181 vs masks starting at 187). The masked-binary override itself could not be reproduced here because this session runs inside a sandbox in which the masked binaries are already /dev/null; ordering was confirmed with bwrap directly (rebind-then-mask leaves a character device). Addresses the remaining review comment on NSLS2#25 (lib/bwrap_sandbox_lib.sh, build_binary_masks). Assisted-by: OpenCode:claude-sonnet-5-5 --- bin/bwclaude | 8 ++++---- bin/bwcodex | 8 ++++---- bin/bwcopilot | 8 ++++---- bin/bwopencode | 8 ++++---- bin/bwopencode2 | 8 ++++---- docs/bw-wrappers.md | 6 +++--- lib/bwrap_sandbox_lib.sh | 8 +++++--- 7 files changed, 28 insertions(+), 26 deletions(-) diff --git a/bin/bwclaude b/bin/bwclaude index a135c48..226e0bf 100755 --- a/bin/bwclaude +++ b/bin/bwclaude @@ -557,14 +557,14 @@ main() { build_dynamic_tool_mounts build_extra_path_mounts - # Mask dangerous binaries last: an earlier mask is hidden by any later - # bind that covers it (e.g. --ro-path /usr or /usr/bin). - build_binary_masks - # Environment variables build_env_vars build_claude_env + # Mask dangerous binaries as the very last step: a mask is hidden by any + # later bind that covers it (e.g. --ro-path /usr or /usr/bin). + build_binary_masks + # Launch if [[ "${DRY_RUN}" -eq 1 ]]; then print_dry_run diff --git a/bin/bwcodex b/bin/bwcodex index 64b3733..c217ca8 100755 --- a/bin/bwcodex +++ b/bin/bwcodex @@ -561,14 +561,14 @@ main() { build_dynamic_tool_mounts build_extra_path_mounts - # Mask dangerous binaries last: an earlier mask is hidden by any later - # bind that covers it (e.g. --ro-path /usr or /usr/bin). - build_binary_masks - # Environment variables build_env_vars build_codex_env + # Mask dangerous binaries as the very last step: a mask is hidden by any + # later bind that covers it (e.g. --ro-path /usr or /usr/bin). + build_binary_masks + # Launch if [[ "${DRY_RUN}" -eq 1 ]]; then print_dry_run diff --git a/bin/bwcopilot b/bin/bwcopilot index 9a2022a..164e18c 100755 --- a/bin/bwcopilot +++ b/bin/bwcopilot @@ -464,14 +464,14 @@ main() { build_managed_ro_bind /opt/nsls2/skills/skills /opt/nsls2/skills/skills fi - # Mask dangerous binaries last: an earlier mask is hidden by any later - # bind that covers it (e.g. --ro-path /usr or /usr/bin). - build_binary_masks - # Environment variables build_env_vars build_copilot_env + # Mask dangerous binaries as the very last step: a mask is hidden by any + # later bind that covers it (e.g. --ro-path /usr or /usr/bin). + build_binary_masks + # Launch if [[ "${DRY_RUN}" -eq 1 ]]; then print_dry_run diff --git a/bin/bwopencode b/bin/bwopencode index b0312ba..bc02a45 100755 --- a/bin/bwopencode +++ b/bin/bwopencode @@ -571,14 +571,14 @@ main() { build_dynamic_tool_mounts build_extra_path_mounts - # Mask dangerous binaries last: an earlier mask is hidden by any later - # bind that covers it (e.g. --ro-path /usr or /usr/bin). - build_binary_masks - # Environment variables build_env_vars build_opencode_env + # Mask dangerous binaries as the very last step: a mask is hidden by any + # later bind that covers it (e.g. --ro-path /usr or /usr/bin). + build_binary_masks + # Launch if [[ "${DRY_RUN}" -eq 1 ]]; then print_dry_run diff --git a/bin/bwopencode2 b/bin/bwopencode2 index 6ef4213..e8e206c 100755 --- a/bin/bwopencode2 +++ b/bin/bwopencode2 @@ -714,14 +714,14 @@ main() { build_dynamic_tool_mounts build_extra_path_mounts - # Mask dangerous binaries last: an earlier mask is hidden by any later - # bind that covers it (e.g. --ro-path /usr or /usr/bin). - build_binary_masks - # Environment variables build_env_vars build_opencode2_env + # Mask dangerous binaries as the very last step: a mask is hidden by any + # later bind that covers it (e.g. --ro-path /usr or /usr/bin). + build_binary_masks + # Launch if [[ "${DRY_RUN}" -eq 1 ]]; then print_dry_run diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index d7c8c70..184e806 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -95,9 +95,9 @@ Every `bw*` wrapper accepts these options: overrides it, so `--rw-path DIR --ro-path DIR/sub` mounts `DIR` read-write but keeps `DIR/sub` read-only (the reverse, a read-only tree with one writable subdirectory, works too). A path given as both `--ro-path` and `--rw-path` is -rejected. The masks on privileged binaries are applied after all other mounts, -so a path that covers their directory (for example `--ro-path /usr`) does not -undo them. +rejected. The masks on privileged binaries are the very last step, applied after all +other mounts and the environment setup, so a path that covers their directory +(for example `--ro-path /usr`) does not undo them. Tool-specific options are listed in each wrapper's section below. diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index a45f1d1..6366971 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -1722,9 +1722,11 @@ build_base_sandbox() { # an existing read-only bind mount. On older versions, we skip masking # entirely (the binaries remain accessible inside the sandbox). # -# Must be called AFTER every other mount step, including user --ro-path / -# --rw-path: a bind that covers a masked binary's directory (e.g. --ro-path -# /usr or /usr/bin) emitted afterwards would hide the mask. +# Must be the very last step of every launcher, after every other mount +# (user --ro-path / --rw-path included) and after the environment builders, +# some of which add binds (bwcodex binds SSL_CERT_FILE): a bind that covers a +# masked binary or its directory (e.g. --ro-path /usr or /usr/bin) emitted +# afterwards would hide the mask. # # Categories: # SSH – lateral movement via remote shell / file transfer