Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AI_DISCLOSURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ human contributors.

**Source code in this repository is largely AI-generated:** AI tools
produce the initial implementation of the sandboxed AI CLI wrapper
scripts (`bin/bwopencode`, `bin/bwcodex`, `bin/bwclaude`,
scripts (`bin/bwopencode`, `bin/bwopencode2`, `bin/bwcodex`, `bin/bwclaude`,
`bin/bwcopilot`) and the shared `lib/bwrap_sandbox_lib.sh` sandbox
library, as well as `bin/gh-protect-branch`, `bin/azoidcapp`,
`bin/pemdecompose`, `lib/gpg-passwd.sh`, associated documentation, and
Expand All @@ -73,7 +73,7 @@ which model contributed to a specific change.
AI tools are used to:

- Draft and refactor the bubblewrap-sandboxed AI CLI wrapper scripts
(`bwopencode`, `bwcodex`, `bwclaude`, `bwcopilot`) and the shared
(`bwopencode`, `bwopencode2`, `bwcodex`, `bwclaude`, `bwcopilot`) and the shared
`bwrap_sandbox_lib.sh` library.
- Draft and refactor `gh-protect-branch`, `azoidcapp`, `pemdecompose`,
and `gpg-passwd.sh`.
Expand Down
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ docs/ Per-script and per-library documentation
| `bwcodex` | OpenAI Codex CLI in a bubblewrap sandbox | [docs/bw-wrappers.md](docs/bw-wrappers.md) |
| `bwcopilot` | GitHub Copilot CLI in a bubblewrap sandbox | [docs/bw-wrappers.md](docs/bw-wrappers.md) |
| `bwopencode` | OpenCode in a bubblewrap sandbox | [docs/bw-wrappers.md](docs/bw-wrappers.md) |
| `bwopencode2` | OpenCode 2 (`opencode2`) in a bubblewrap sandbox | [docs/bw-wrappers.md](docs/bw-wrappers.md) |
| `gh-protect-branch` | Apply NSLS-II standard branch protection to a GitHub repo (all branches); enables secret scanning and push protection; optionally restricts branch creation to named patterns | [docs/gh-protect-branch.md](docs/gh-protect-branch.md) |
| `pemdecompose` | List and verify certificates in a PEM file | [docs/pemdecompose.md](docs/pemdecompose.md) |

Expand Down Expand Up @@ -81,6 +82,7 @@ Add those three exports to your `~/.bashrc` or `~/.zshrc` for persistence.

```text
bwopencode [bwopencode-options] [opencode arguments...]
bwopencode2 [bwopencode2-options] [opencode2 arguments...]
bwclaude [bwclaude-options] [claude arguments...]
bwcopilot [bwcopilot-options] [copilot arguments...]
bwcodex [bwcodex-options] [codex arguments...]
Expand All @@ -96,8 +98,20 @@ Common wrapper options:
| `--init-auth` | Persist auth credentials to the host (first-time setup) |
| `--new-session` | Force `bwrap --new-session` (stricter isolation; breaks SIGWINCH) |

`bwclaude` also supports `--debug`. See each script's `--help` for the
authoritative option list.
`bwclaude` also supports `--debug`. `bwopencode2` has no `--init-auth` (sign-in
persists in its data directory), always runs OpenCode 2 with `--standalone`
(`--exec` bypasses that guard by design), passes only an allowlist of
`OPENCODE_*` environment variables, does not mount `service.json`, and never mounts
the git `credentials` file; see [docs/bw-wrappers.md](docs/bw-wrappers.md). See each script's `--help` for
the authoritative option list.

Security notes: every launcher runs `/opt/nsls2/libexec/agent-account-check`
first when installed (refuses root and shared/service accounts);
`bwopencode` forwards only an allowlist of `OPENCODE_*` behaviour toggles (all
other host `OPENCODE_*` variables are dropped); only `allowed_signers`, `config`, `ignore` and `attributes` of
`${XDG_CONFIG_HOME}/git` are mounted in every sandbox (never `credentials`); and `--dry-run` prints values of secret-looking
`--setenv` variables (names containing `KEY`, `TOKEN`, `PASSWORD`, `SECRET`, or
`CREDENTIAL`) as `REDACTED`. See `docs/bw-wrappers.md`.

### `gh-protect-branch`

Expand Down
19 changes: 3 additions & 16 deletions bin/bwclaude
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ parse_wrapper_args() {
echo "Error: --exec requires a command argument." >&2
exit 1
fi
# shellcheck disable=SC2034 # read by bwrap_resolve_tool in the library
TEST_CMD="$2"
shift 2
;;
Expand Down Expand Up @@ -153,21 +154,6 @@ parse_wrapper_args() {
# Find the absolute path to the claude binary on the host so we do
# not depend on PATH lookup inside the sandbox.
# Sets globals: _TOOL_BIN, _TOOL_CMD
resolve_tool_binary() {
_TOOL_BIN="$(bwrap_find_tool claude 2> /dev/null || true)"
if [[ -z "${_TOOL_BIN}" ]]; then
echo "Error: 'claude' not found in PATH." >&2
exit 1
fi

if [[ -n "${TEST_CMD}" ]]; then
# Split TEST_CMD into array so multi-word commands work correctly.
read -ra _TOOL_CMD <<< "${TEST_CMD}"
else
_TOOL_CMD=("${_TOOL_BIN}")
fi
}

show_help() {
cat << 'HELP_EOF'
bwclaude — Claude CLI inside a bubblewrap sandbox
Expand Down Expand Up @@ -472,8 +458,9 @@ build_claude_bin_mount() {
# ═══════════════════════════════════════════════════════════════════

main() {
bwrap_check_account
parse_wrapper_args "$@"
resolve_tool_binary
bwrap_resolve_tool claude

if [[ "${SHOW_HELP}" -eq 1 ]]; then
show_help
Expand Down
19 changes: 3 additions & 16 deletions bin/bwcodex
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ parse_wrapper_args() {
echo "Error: --exec requires a command argument." >&2
exit 1
fi
# shellcheck disable=SC2034 # read by bwrap_resolve_tool in the library
TEST_CMD="$2"
shift 2
;;
Expand Down Expand Up @@ -181,21 +182,6 @@ parse_wrapper_args() {
# Find the absolute path to the codex binary on the host so we do
# not depend on PATH lookup inside the sandbox.
# Sets globals: _TOOL_BIN, _TOOL_CMD
resolve_tool_binary() {
_TOOL_BIN="$(bwrap_find_tool codex 2> /dev/null || true)"
if [[ -z "${_TOOL_BIN}" ]]; then
echo "Error: 'codex' not found in PATH." >&2
exit 1
fi

if [[ -n "${TEST_CMD}" ]]; then
# Split TEST_CMD into array so multi-word commands work correctly.
read -ra _TOOL_CMD <<< "${TEST_CMD}"
else
_TOOL_CMD=("${_TOOL_BIN}")
fi
}

show_help() {
cat << 'HELP_EOF'
bwcodex — OpenAI Codex CLI inside a bubblewrap sandbox
Expand Down Expand Up @@ -472,8 +458,9 @@ build_codex_bin_mount() {
# ═══════════════════════════════════════════════════════════════════

main() {
bwrap_check_account
parse_wrapper_args "$@"
resolve_tool_binary
bwrap_resolve_tool codex

if [[ "${SHOW_HELP}" -eq 1 ]]; then
show_help
Expand Down
20 changes: 3 additions & 17 deletions bin/bwcopilot
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ parse_wrapper_args() {
echo "Error: --exec requires a command argument." >&2
exit 1
fi
# shellcheck disable=SC2034 # read by bwrap_resolve_tool in the library
TEST_CMD="$2"
shift 2
;;
Expand Down Expand Up @@ -145,22 +146,6 @@ parse_wrapper_args() {
# Does NOT fall back to `gh copilot` — the gh-copilot extension is
# archived/deprecated as of Oct 2025.
# Sets globals: _TOOL_BIN, _TOOL_CMD
resolve_tool_binary() {
_TOOL_BIN="$(bwrap_find_tool copilot 2> /dev/null || true)"
if [[ -z "${_TOOL_BIN}" ]]; then
echo "Error: 'copilot' not found in PATH." >&2
echo "Install the standalone GitHub Copilot CLI from https://github.com/github/copilot-cli" >&2
exit 1
fi

if [[ -n "${TEST_CMD}" ]]; then
# Split TEST_CMD into array so multi-word commands work correctly.
read -ra _TOOL_CMD <<< "${TEST_CMD}"
else
_TOOL_CMD=("${_TOOL_BIN}")
fi
}

show_help() {
cat << 'HELP_EOF'
bwcopilot — GitHub Copilot CLI inside a bubblewrap sandbox
Expand Down Expand Up @@ -424,8 +409,9 @@ build_copilot_env() {
# ═══════════════════════════════════════════════════════════════════

main() {
bwrap_check_account
parse_wrapper_args "$@"
resolve_tool_binary
bwrap_resolve_tool copilot "Install the standalone GitHub Copilot CLI from https://github.com/github/copilot-cli"

if [[ "${SHOW_HELP}" -eq 1 ]]; then
show_help
Expand Down
125 changes: 96 additions & 29 deletions bin/bwopencode
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ parse_wrapper_args() {
echo "Error: --exec requires a command argument." >&2
exit 1
fi
# shellcheck disable=SC2034 # read by bwrap_resolve_tool in the library
TEST_CMD="$2"
shift 2
;;
Expand Down Expand Up @@ -139,21 +140,6 @@ parse_wrapper_args() {
# Find the absolute path to the opencode binary on the host so we do
# not depend on PATH lookup inside the sandbox.
# Sets globals: _TOOL_BIN, _TOOL_CMD
resolve_tool_binary() {
_TOOL_BIN="$(bwrap_find_tool opencode 2> /dev/null || true)"
if [[ -z "${_TOOL_BIN}" ]]; then
echo "Error: 'opencode' not found in PATH." >&2
exit 1
fi

if [[ -n "${TEST_CMD}" ]]; then
# Split TEST_CMD into array so multi-word commands work correctly.
read -ra _TOOL_CMD <<< "${TEST_CMD}"
else
_TOOL_CMD=("${_TOOL_BIN}")
fi
}

show_help() {
cat << 'HELP_EOF'
bwopencode — opencode inside a bubblewrap sandbox
Expand Down Expand Up @@ -240,22 +226,25 @@ ensure_host_dirs() {
# bind the real file at the expected path. This avoids exposing the
# dotfiles tree inside the sandbox.
#
# Each resolved target is passed through _check_path_safe before being
# added to OC_CONFIG_BINDS. This blocks symlinks that point into
# sensitive subtrees (e.g. ~/.config/opencode/creds -> ~/.aws/credentials).
# Every path is canonicalized with realpath (readlink -f) and the canonical
# result is passed through _check_path_safe before being added to
# OC_CONFIG_BINDS. This blocks symlinks that point into sensitive subtrees
# (e.g. ~/.config/opencode/creds -> ~/.aws/credentials) and, because ALL
# entries are canonicalized (not only symlinked ones), also a symlinked
# PARENT directory (~/.config/opencode -> ~/.aws): bwrap's --ro-bind follows
# the symlink, so the unresolved name alone would hide the real target.
resolve_config_symlinks() {
OC_CONFIG_BINDS=()
[[ -d "${OC_CONFIG_DIR}" ]] || return 0

# The config directory itself may be (or sit under) a symlink.
_bwrap_canonicalize_safe "${OC_CONFIG_DIR}" "opencode config dir"

local entry local_resolved
for entry in "${OC_CONFIG_DIR}"/*; do
[[ -e "${entry}" ]] || continue
if [[ -L "${entry}" ]]; then
local_resolved="$(readlink -f "${entry}")"
else
local_resolved="${entry}"
fi
_check_path_safe "${local_resolved}" "opencode config entry '${entry}'"
_bwrap_canonicalize_safe "${entry}" "opencode config entry '${entry}'"
local_resolved="${_BWRAP_CANON}"
OC_CONFIG_BINDS+=("--ro-bind" "${local_resolved}" "${entry}")
done
}
Expand Down Expand Up @@ -383,8 +372,69 @@ build_opencode_cache_mount() {
}

# ── OpenCode env vars ────────────────────────────────────────────
# Pass through any OPENCODE_* env vars (config overrides, etc.)
# Also pass through AIFAPIM_* env vars for NSLS-II Hermes gateway.
# Pass through an ALLOWLIST of harmless OPENCODE_* behaviour toggles, and
# AIFAPIM_* env vars for the NSLS-II Hermes gateway. Other host OPENCODE_*
# variables are dropped: some are secrets (OPENCODE_SERVER_PASSWORD would
# let the agent, which shares the host network, drive an unsandboxed
# opencode server) and others redirect config/data/DB/model/server locations
# or inject config (OPENCODE_CONFIG*, OPENCODE_PERMISSION) and so would
# bypass the managed config. In --dry-run the NAMES (never values) of
# dropped variables are printed to stderr.
#
# Names were enumerated from the installed opencode v1 binary
# (strings of OPENCODE_*) and the docs env-var table. Add a name here only
# if it is a pure on/off or numeric tuning toggle: never anything with
# PASSWORD, TOKEN, SECRET, KEY, URL, PORT, SERVER, DB, HOME, CONFIG, PATH or
# a *_CONTENT payload.
_OPENCODE_ENV_ALLOWLIST=(
OPENCODE_ALWAYS_NOTIFY_UPDATE
OPENCODE_DISABLE_AUTOCOMPACT
OPENCODE_DISABLE_AUTOUPDATE
OPENCODE_DISABLE_CLAUDE_CODE
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS
OPENCODE_DISABLE_DEFAULT_PLUGINS
OPENCODE_DISABLE_EMBEDDED_WEB_UI
OPENCODE_DISABLE_EXTERNAL_SKILLS
OPENCODE_DISABLE_FFF
OPENCODE_DISABLE_LSP_DOWNLOAD
OPENCODE_DISABLE_MODELS_FETCH
OPENCODE_DISABLE_MOUSE
OPENCODE_DISABLE_PROJECT_CONFIG
OPENCODE_DISABLE_PRUNE
OPENCODE_DISABLE_TERMINAL_TITLE
OPENCODE_ENABLE_EXA
OPENCODE_ENABLE_EXPERIMENTAL_MODELS
OPENCODE_ENABLE_PARALLEL
OPENCODE_ENABLE_QUESTION_TOOL
OPENCODE_EXPERIMENTAL
OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS
OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS
OPENCODE_EXPERIMENTAL_CODE_MODE
OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER
OPENCODE_EXPERIMENTAL_EVENT_SYSTEM
OPENCODE_EXPERIMENTAL_EXA
OPENCODE_EXPERIMENTAL_FILEWATCHER
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY
OPENCODE_EXPERIMENTAL_LSP_TOOL
OPENCODE_EXPERIMENTAL_LSP_TY
OPENCODE_EXPERIMENTAL_NATIVE_LLM
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX
OPENCODE_EXPERIMENTAL_OXFMT
OPENCODE_EXPERIMENTAL_PARALLEL
OPENCODE_EXPERIMENTAL_PLAN_MODE
OPENCODE_EXPERIMENTAL_REFERENCES
OPENCODE_EXPERIMENTAL_SCOUT
OPENCODE_EXPERIMENTAL_WEBSOCKETS
OPENCODE_EXPERIMENTAL_WORKSPACES
OPENCODE_FAST_BOOT
OPENCODE_LOG_LEVEL
OPENCODE_PRINT_LOGS
OPENCODE_PURE
OPENCODE_SHOW_TTFD
)

# With --github-tokens, also forward GH_TOKEN_* vars so the agent
# can authenticate gh(1) via per-command:
# GH_TOKEN="$GH_TOKEN_NSLS2" gh <subcommand>
Expand All @@ -393,13 +443,29 @@ build_opencode_env() {
pass_through_if_set AIFAPIM_HOST
pass_through_if_set AIFAPIM_API_KEY

local _key
local _key _allowed _ok
local _dropped=()
while IFS= read -r _key; do
if [[ "${_key}" == OPENCODE_* ]]; then
BWRAP_ARGS+=(--setenv "${_key}" "${!_key}")
_ok=0
for _allowed in "${_OPENCODE_ENV_ALLOWLIST[@]}"; do
if [[ "${_key}" == "${_allowed}" ]]; then
_ok=1
break
fi
done
if [[ "${_ok}" -eq 1 ]]; then
BWRAP_ARGS+=(--setenv "${_key}" "${!_key}")
else
_dropped+=("${_key}")
fi
fi
done < <(compgen -e)

if [[ "${DRY_RUN}" -eq 1 && ${#_dropped[@]} -gt 0 ]]; then
echo "Note: dropped OPENCODE_* env vars not on the allowlist (names only): ${_dropped[*]}" >&2
fi

# GitHub CLI token passthrough (opt-in via --github-tokens)
# In --dry-run mode the value is replaced with REDACTED so that tokens
# are not printed to the terminal in the bwrap command output.
Expand All @@ -423,8 +489,9 @@ build_opencode_env() {
# ═══════════════════════════════════════════════════════════════════

main() {
bwrap_check_account
parse_wrapper_args "$@"
resolve_tool_binary
bwrap_resolve_tool opencode

if [[ "${SHOW_HELP}" -eq 1 ]]; then
show_help
Expand Down
Loading