From 4a28c3ab6b397dc711963cc794e4b6ba43327d75 Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Sat, 18 Jul 2026 09:48:37 -0400 Subject: [PATCH] FIX: resolve real tool binary to prevent sandbox PATH shadowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add resolve_real_tool_binary() to lib/bwrap_sandbox_lib.sh. On managed hosts the dotfiles wrapper (~/.local/bin/opencode) shadows the real npm binary on PATH. command -v opencode then resolves to the wrapper; its directory gets added to SANDBOX_PATH and bind-mounted into the --clearenv sandbox, where AZURE_*/N2SNSCRIPTS_LIB are stripped and the wrapper fails with 'cannot locate gpg-passwd.sh' (the double-invocation bug). Fix: detect dotfiles wrappers by checking whether readlink -f resolves to a path containing /config/scripts/. If so, substitute the npm-prefix binary directly. Hard-error if the npm binary is also absent (returning the wrapper would silently recreate the bug). Non-npm tools (system packages) are unaffected — the candidate is returned unchanged. Wire resolve_real_tool_binary into all four wrappers: bwopencode, bwclaude, bwcodex, bwcopilot. Assisted-by: opencode:claude-opus-4-8 Assisted-by: opencode:claude-sonnet-4-6 Assisted-by: opencode:gpt-5.6-sol --- bin/bwclaude | 2 +- bin/bwcodex | 2 +- bin/bwcopilot | 2 +- bin/bwopencode | 2 +- lib/bwrap_sandbox_lib.sh | 56 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 60 insertions(+), 4 deletions(-) diff --git a/bin/bwclaude b/bin/bwclaude index a22a085..2656efe 100755 --- a/bin/bwclaude +++ b/bin/bwclaude @@ -149,7 +149,7 @@ parse_wrapper_args() { # not depend on PATH lookup inside the sandbox. # Sets globals: _TOOL_BIN, _TOOL_CMD resolve_tool_binary() { - _TOOL_BIN="$(command -v claude 2> /dev/null || true)" + _TOOL_BIN="$(resolve_real_tool_binary claude)" if [[ -z "${_TOOL_BIN}" ]]; then echo "Error: 'claude' not found in PATH." >&2 exit 1 diff --git a/bin/bwcodex b/bin/bwcodex index 7f3b346..8695d9b 100755 --- a/bin/bwcodex +++ b/bin/bwcodex @@ -177,7 +177,7 @@ parse_wrapper_args() { # not depend on PATH lookup inside the sandbox. # Sets globals: _TOOL_BIN, _TOOL_CMD resolve_tool_binary() { - _TOOL_BIN="$(command -v codex 2> /dev/null || true)" + _TOOL_BIN="$(resolve_real_tool_binary codex)" if [[ -z "${_TOOL_BIN}" ]]; then echo "Error: 'codex' not found in PATH." >&2 exit 1 diff --git a/bin/bwcopilot b/bin/bwcopilot index 437b65d..4d2cb8e 100755 --- a/bin/bwcopilot +++ b/bin/bwcopilot @@ -141,7 +141,7 @@ parse_wrapper_args() { # archived/deprecated as of Oct 2025. # Sets globals: _TOOL_BIN, _TOOL_CMD resolve_tool_binary() { - _TOOL_BIN="$(command -v copilot 2> /dev/null || true)" + _TOOL_BIN="$(resolve_real_tool_binary copilot)" if [[ -z "${_TOOL_BIN}" ]]; then echo "Error: 'copilot' not found in PATH." >&2 echo "Install the standalone GitHub Copilot CLI from https://github.com/github/copilot-cli" >&2 diff --git a/bin/bwopencode b/bin/bwopencode index fbd76f4..1d6321d 100755 --- a/bin/bwopencode +++ b/bin/bwopencode @@ -135,7 +135,7 @@ parse_wrapper_args() { # not depend on PATH lookup inside the sandbox. # Sets globals: _TOOL_BIN, _TOOL_CMD resolve_tool_binary() { - _TOOL_BIN="$(command -v opencode 2> /dev/null || true)" + _TOOL_BIN="$(resolve_real_tool_binary opencode)" if [[ -z "${_TOOL_BIN}" ]]; then echo "Error: 'opencode' not found in PATH." >&2 exit 1 diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index 024c510..842d172 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -180,6 +180,62 @@ _get_npm_prefix() { printf '%s' "${_NPM_PREFIX}" } +# resolve_real_tool_binary TOOL_NAME +# Return the path to the real (non-wrapper) binary for TOOL_NAME. +# +# Problem: on managed hosts the dotfiles wrapper (e.g. ~/.local/bin/opencode) +# precedes /usr/local/bin on PATH. `command -v opencode` therefore resolves +# to the wrapper, not the npm-installed binary. resolve_and_mount_tool then +# adds the wrapper's directory to SANDBOX_PATH and bind-mounts it into the +# --clearenv sandbox. Inside the sandbox the wrapper is visible on PATH, but +# the env vars it needs (N2SNSCRIPTS_LIB, AZURE_*) are stripped, so it fails +# with "cannot locate gpg-passwd.sh" — the double-invocation bug. +# +# Fix: if `command -v TOOL_NAME` resolves to a dotfiles wrapper symlink +# (detected by checking whether `readlink -f candidate` contains +# "/config/scripts/"), skip it and return the npm-installed binary from +# /bin/TOOL_NAME instead. If the npm binary is also absent, +# that means the real tool is not installed — exit with an error rather +# than silently returning the wrapper (which would recreate the bug). +# +# If the candidate is NOT a dotfiles wrapper (system package, already the +# correct npm binary, etc.) it is returned as-is so non-npm tools are +# unaffected. +# +# Outputs the resolved path on stdout. Callers assign with $(...). +resolve_real_tool_binary() { + local tool_name="$1" + local candidate real_candidate npm_prefix npm_bin + + candidate="$(command -v "${tool_name}" 2> /dev/null || true)" + if [[ -z "${candidate}" ]]; then + # Tool not on PATH at all — callers handle the empty-string case. + printf '%s' "${candidate}" + return 0 + fi + + # Detect dotfiles wrapper: the symlink target contains /config/scripts/ + # (the dotfiles convention for per-tool wrapper scripts). + real_candidate="$(readlink -f "${candidate}" 2> /dev/null || printf '%s' "${candidate}")" + if [[ "${real_candidate}" == */config/scripts/* ]]; then + # candidate is a dotfiles wrapper — resolve to the real npm binary. + npm_prefix="$(_get_npm_prefix)" + npm_bin="${npm_prefix:+${npm_prefix}/bin/${tool_name}}" + if [[ -n "${npm_bin}" && -x "${npm_bin}" ]]; then + printf '%s' "${npm_bin}" + return 0 + fi + # The real binary is not installed. Returning the wrapper would + # recreate the double-invocation bug; error out instead. + printf 'resolve_real_tool_binary: %s resolves to a dotfiles wrapper but %s is not installed or not executable.\n' \ + "${candidate}" "${tool_name}" >&2 + return 1 + fi + + # Not a dotfiles wrapper — return the candidate unchanged. + printf '%s' "${candidate}" +} + # _emit_home_intermediate_dirs DIR [MODE] # Emit `--dir` BWRAP_ARGS for the ancestor components of DIR that live # under $HOME, so the eventual bind mount point exists inside the tmpfs