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