{"slug": "lightweight-sandboxing-bwrap-wrapper-for-the-pi-coding-agent", "title": "Lightweight sandboxing(bwrap) wrapper for the pi coding agent.", "summary": "A developer released pi-box, a Bash wrapper that runs the pi coding agent inside a bubblewrap (bwrap) sandbox. The project mounts the working directory read-write at /work, keeps host home directories hidden unless explicitly mounted read-only via -H, and stores the agent's profile and sessions in a project-local .pi-box directory. It also supports attaching to running sandboxes, one-shot prompts, and optional X11/GPU passthrough.", "body_md": "|  | #!/usr/bin/env bash | \n|  | # pi-box — запуск pi в изолированной bwrap-песочнице. | \n|  | # | \n|  | # Проект монтируется в /work целиком; всё, что нужно инструменту, лежит в | \n|  | # <проект>/.pi-box. Наружу ничего не пишется, домашние каталоги хоста | \n|  | # (и ключи в них) внутри не видны — кроме явного -H, и то только для чтения. | \n|  | # | \n|  | # Внутри: /work (проект, rw), /box/profile, /box/sessions, /pi (ro), /home пусто. | \n|  | # | \n|  | # Полная справка: pi-box --help | \n|  | set -uo pipefail | \n|  | # ── цвета ──────────────────────────────────────────────────────────────────── | \n|  | # Только для терминала: в конвейере лишние escape-последовательности мешают. | \n|  | if [ -t 1 ]; then | \n|  | C_RST=$'\\033[0m'; C_B=$'\\033[1m'; C_DIM=$'\\033[2m' | \n|  | C_CYAN=$'\\033[36m'; C_GREEN=$'\\033[32m'; C_YEL=$'\\033[33m'; C_RED=$'\\033[31m' | \n|  | else | \n|  | C_RST=\"\"; C_B=\"\"; C_DIM=\"\"; C_CYAN=\"\"; C_GREEN=\"\"; C_YEL=\"\"; C_RED=\"\" | \n|  | fi | \n|  | # ── справка ──────────────────────────────────────────────────────────────── | \n|  | # Текст целиком внутри функции, а не вырезан из шапки по номерам строк: | \n|  | # правка шапки однажды уже обрезала help посреди предложения. | \n|  | usage() { | \n|  | cat <<EOF | \n|  | ${C_B}pi-box${C_RST} — ${C_CYAN}pi${C_RST} в изолированной песочнице (bwrap). | \n|  | Основной режим — запуск pi в текущем каталоге. Проект монтируется в /work и | \n|  | доступен на запись; домашние каталоги хоста внутри не видны. | \n|  | Повторный вызов подключается к уже запущенной песочнице. | \n|  | ${C_B}ЗАПУСК${C_RST} | \n|  | ${C_GREEN}pi-box${C_RST} [PATH] запустить (по умолчанию текущий каталог) | \n|  | ${C_GREEN}pi-box${C_RST} -p \"промпт\" [PATH] один запрос, без TUI | \n|  | ${C_GREEN}pi-box${C_RST} -a [ИМЯ\\|PATH] подключиться к запущенной | \n|  | ${C_GREEN}pi-box${C_RST} -m меню песочниц (даже если есть своя) | \n|  | ${C_GREEN}pi-box${C_RST} -x + X11 и GPU: рендер 3D, окна | \n|  | ${C_GREEN}pi-box${C_RST} -H + домашний каталог, только чтение | \n|  | ${C_DIM}Без аргументов открывается меню песочниц.${C_RST} | \n|  | ${C_B}ФАЙЛЫ${C_RST} | \n|  | ${C_GREEN}pi-box${C_RST} -l список песочниц | \n|  | ${C_GREEN}pi-box${C_RST} --rm [ИМЯ\\|PATH] удалить .pi-box проекта | \n|  | ${C_GREEN}pi-box${C_RST} -f не спрашивать подтверждения при удалении | \n|  | ${C_GREEN}pi-box${C_RST} --new пересоздать (сносит профиль и сессии) | \n|  | ${C_GREEN}pi-box${C_RST} -v SRC:DST[:ro\\|rw] смонтировать каталог (можно несколько) | \n|  | ${C_B}ВНУТРИ ПЕСОЧНИЦЫ${C_RST} | \n|  | ${C_CYAN}/work${C_RST} проект, чтение и запись | \n|  | ${C_CYAN}\\$HOME${C_RST} весь дом, только чтение (с -H) | \n|  | ${C_CYAN}/box/profile${C_RST} профиль pi: auth, settings, модели, плагины | \n|  | ${C_CYAN}/box/sessions${C_RST} сессии | \n|  | ${C_CYAN}/pi${C_RST} установленный pi (только чтение) | \n|  | ${C_DIM}Состояние инструмента — <проект>/.pi-box (профиль, сессии); сам каталог | \n|  | агенту не виден. /home внутри пуст, корень, /etc и /var — только чтение.${C_RST} | \n|  | ${C_B}-H: ДОМАШНИЙ КАТАЛОГ${C_RST} | \n|  | Дом монтируется рядом с /work по своему пути, ${C_B}только для чтения${C_RST}. | \n|  | Агент видит конфиги и может искать файлы, но ничего там не меняет. | \n|  | От чтения скрыты известные хранилища секретов: | \n|  | ${C_DIM}.ssh .gnupg .aws .kube .docker .mozilla .netrc .git-credentials | \n|  | .my.cnf .pgpass .password-store .config/gh .config/gcloud .local/share/keyrings${C_RST} | \n|  | ${C_B}ПЕРЕМЕННЫЕ${C_RST} | \n|  | ${C_CYAN}PI_BOX_HOME${C_RST} реестр песочниц (по умолчанию ~/.local/share/pi-box) | \n|  | ${C_CYAN}PI_BOX_PI${C_RST} каталог @earendil-works с установленным pi | \n|  | ${C_CYAN}PI_BOX_RUNTIME${C_RST} откуда брать auth, модели и плагины (по умолчанию ~/.pi/agent) | \n|  | ${C_DIM}Нужны bwrap, tmux и установленный pi.${C_RST} | \n|  | EOF | \n|  | } | \n|  | # ── настройки ──────────────────────────────────────────────────────────────── | \n|  | PI_BOX_HOME=\"${PI_BOX_HOME:-${XDG_DATA_HOME:-$HOME/.local/share}/pi-box}\" | \n|  | PI_SRC=\"${PI_BOX_PI:-$HOME/.local/lib/node_modules/@earendil-works}\" | \n|  | PI_BASENAME=\"pi-coding-agent\" | \n|  | RUNTIME=\"${PI_BOX_RUNTIME:-$HOME/.pi/agent}\" # источник auth/models/плагинов | \n|  | SESSION_ID=\"${PI_BOX_SESSION_ID:-main}\" | \n|  | BOX_DIRNAME=\".pi-box\" | \n|  | TMUX_NAME=\"${PI_BOX_TMUX:-pi-box}\" | \n|  | # XAUTHORITY нужен для -x/--x11: без него X11 отказывает в доступе. Если | \n|  | # переменная не задана, ищем файл в обычных местах. | \n|  | if [ -z \"${XAUTHORITY:-}\" ]; then | \n|  | for c in \"$HOME/.Xauthority\" \"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/xauth\" \\ | \n|  | \"$HOME/.local/share/sx/xauthority\"; do | \n|  | [ -f \"$c\" ] && { XAUTHORITY=\"$c\"; break; } | \n|  | done | \n|  | fi | \n|  | require_pi() { | \n|  | if [ ! -f \"$PI_SRC/$PI_BASENAME/dist/bundle/cli.js\" ]; then | \n|  | echo \"${C_RED}Не найден pi${C_RST}: $PI_SRC/$PI_BASENAME\" >&2 | \n|  | echo \"Укажи установку через ${C_CYAN}PI_BOX_PI${C_RST} или поставь pi обычным способом.\" >&2 | \n|  | exit 1 | \n|  | fi | \n|  | } | \n|  | # Проверяем зависимости до работы: иначе падение будет невнятным (или, хуже, | \n|  | # скрипт напишет «запущена», не запустив ничего). | \n|  | require_tools() { | \n|  | local miss=() t | \n|  | for t in bwrap tmux; do | \n|  | command -v \"$t\" >/dev/null 2>&1 \\|\\| miss+=(\"$t\") | \n|  | done | \n|  | # node ищем один раз и запоминаем путь: запускать будем именно его, а не | \n|  | # жёсткий /usr/bin/node — иначе с nvm проверка проходит, а запуск падает. | \n|  | NODE_BIN=$(command -v node 2>/dev/null) | \n|  | [ -n \"$NODE_BIN\" ] \\|\\| miss+=(\"node\") | \n|  | [ ${#miss[@]} -eq 0 ] && return 0 | \n|  | echo \"${C_RED}Нет программ${C_RST}: ${miss[*]}\" >&2 | \n|  | echo \"Нужны: bwrap (bubblewrap), tmux и node.\" >&2 | \n|  | echo \"Пример установки (Debian/Ubuntu): sudo apt install bubblewrap tmux nodejs\" >&2 | \n|  | exit 1 | \n|  | } | \n|  | # Реестр песочниц: без общего каталога перечислять иначе нечего. | \n|  | register() { | \n|  | mkdir -p \"$PI_BOX_HOME\" | \n|  | grep -qxF \"$1\" \"$PI_BOX_HOME/boxes\" 2>/dev/null \\|\\| echo \"$1\" >> \"$PI_BOX_HOME/boxes\" | \n|  | } | \n|  | # Сокет в /tmp: путь внутри проекта может быть длинным, а sun_path — 107 байт. | \n|  | box_socket() { echo \"/tmp/pi-box-$(id -u)/$(printf %s \"$1\" \\| md5sum \\| cut -c1-12).sock\"; } | \n|  | # ── создание .pi-box ───────────────────────────────────────────────────────── | \n|  | box_init() { | \n|  | local project=\"$1\" | \n|  | local d=\"$project/$BOX_DIRNAME\" | \n|  | mkdir -p \"$d\"/{profile/extensions,sessions,tmp} | \n|  | # Ключи не должны попасть в git: внутри .pi-box игнорируем всё. | \n|  | [ -f \"$d/.gitignore\" ] \\|\\| printf '*\\n' > \"$d/.gitignore\" | \n|  | [ -f \"$RUNTIME/auth.json\" ] && install -m 600 \"$RUNTIME/auth.json\" \"$d/profile/auth.json\" | \n|  | [ -f \"$RUNTIME/models-store.json\" ] && install -m 600 \"$RUNTIME/models-store.json\" \"$d/profile/models-store.json\" | \n|  | # Плагины не копируются: они берутся из $RUNTIME/extensions при запуске. | \n|  | # Если нужны свои — положи их сюда, и они получат приоритет. | \n|  | if [ ! -f \"$d/profile/settings.json\" ]; then | \n|  | local provider model | \n|  | provider=$(sed -n 's/.*\"defaultProvider\" *: *\"$[^\"]*$\".*/\\1/p' \"$RUNTIME/settings.json\" 2>/dev/null \\| head -1) | \n|  | model=$(sed -n 's/.*\"defaultModel\" *: *\"$[^\"]*$\".*/\\1/p' \"$RUNTIME/settings.json\" 2>/dev/null \\| head -1) | \n|  | cat > \"$d/profile/settings.json\" <<EOF | \n|  | { | \n|  | \"packages\": [], | \n|  | \"defaultProvider\": \"${provider:-ez-ai}\", | \n|  | \"defaultModel\": \"${model:-deepseek/deepseek-v4.1-flash}\", | \n|  | \"theme\": \"dark\" | \n|  | } | \n|  | EOF | \n|  | fi | \n|  | cat > \"$d/tmp/tmux.conf\" <<'EOF' | \n|  | set -g default-terminal \"tmux-256color\" | \n|  | set -as terminal-features \",*:RGB\" | \n|  | set -g extended-keys on | \n|  | set -g extended-keys-format csi-u | \n|  | set -g mouse on | \n|  | set -g history-limit 50000 | \n|  | EOF | \n|  | } | \n|  | # ── argv для bwrap ───────────────────────────────────────────────────────────── | \n|  | # /work монтируется как есть, но поверх .pi-box кладётся пустой tmpfs. Профиль | \n|  | # rw (pi пишет lock-файлы), но auth.json и models-store.json — ro поверх, чтобы | \n|  | # случайная запись или rm не сломали ключ. | \n|  | box_bwrap() { | \n|  | local project=\"$1\" | \n|  | local p=\"$project/$BOX_DIRNAME/profile\" | \n|  | BW=( | \n|  | bwrap | \n|  | --ro-bind /usr /usr | \n|  | --ro-bind /etc /etc | \n|  | --ro-bind /var /var | \n|  | --proc /proc | \n|  | --dev /dev | \n|  | --tmpfs /tmp | \n|  | # /home не пробрасываем: там чужие каталоги, ключи и конфиги. Внутри | \n|  | # остаётся пустой tmpfs, а нужное (pi, проект) смонтировано точечно. | \n|  | --tmpfs /home | \n|  | --bind \"$project\" /work | \n|  | --dir /box | \n|  | --bind \"$p\" /box/profile | \n|  | --bind \"$project/$BOX_DIRNAME/sessions\" /box/sessions | \n|  | # pi берём из установки на хосте, только чтение. | \n|  | --ro-bind \"$PI_SRC\" /pi | \n|  | # Окружение и рабочий каталог — самим bwrap, без shell-обвязки: | \n|  | # так значения с кавычками и $(...) не попадут в интерпретатор. | \n|  | --setenv HOME /box | \n|  | --setenv PI_CODING_AGENT_DIR /box/profile | \n|  | --chdir /work | \n|  | --unshare-pid | \n|  | --die-with-parent | \n|  | ) | \n|  | # Ctrl-H: домашний каталог рядом с /work, только чтение. Именно ro, а не | \n|  | # «дом как проект»: агент может читать конфиги и искать файлы, но ничего | \n|  | # там не меняет. Секреты маскируем даже от чтения — они не нужны для работы, | \n|  | # а утекут в контекст модели при первом же ls. | \n|  | if [ \"$HOMERO\" = 1 ]; then | \n|  | local hp=\"$HOME\" | \n|  | BW+=(--ro-bind \"$hp\" \"$hp\") | \n|  | # Каталог — пустым tmpfs, файл — /dev/null: --tmpfs на файл bwrap | \n|  | # отвергает («Destination is not a directory», проверено). | \n|  | local sec | \n|  | for sec in .ssh .gnupg .password-store .aws .kube .docker .mozilla \\ | \n|  | .config/gh .config/gcloud .local/share/keyrings \\ | \n|  | .netrc .git-credentials .my.cnf .pgpass; do | \n|  | [ -e \"$hp/$sec\" ] \\|\\| continue | \n|  | if [ -d \"$hp/$sec\" ]; then | \n|  | BW+=(--tmpfs \"$hp/$sec\") | \n|  | else | \n|  | BW+=(--ro-bind /dev/null \"$hp/$sec\") | \n|  | fi | \n|  | done | \n|  | fi | \n|  | # Маскируем .pi-box внутри /work — свой и чужие (вложенные песочницы). | \n|  | # | \n|  | # Именно --tmpfs на существующий каталог, а не безусловный --tmpfs на путь: | \n|  | # bwrap создаёт точку монтирования в реальной файловой системе, если её нет | \n|  | # (проверено). Иначе на хосте появлялся пустой каталог-ловушка, а find_root | \n|  | # затем находил его и подменял им проект. | \n|  | # | \n|  | # Пути вложенных берём из реестра, а не find: обход всего $HOME занимал | \n|  | # десятки секунд, а известных песочниц единицы. | \n|  | local nested rel | \n|  | [ -d \"$project/$BOX_DIRNAME\" ] && BW+=(--tmpfs \"/work/$BOX_DIRNAME\") | \n|  | while IFS= read -r nested; do | \n|  | [ -n \"$nested\" ] \\|\\| continue | \n|  | [ -d \"$nested/$BOX_DIRNAME\" ] \\|\\| continue | \n|  | case \"$nested\" in | \n|  | \"$project\"/*) rel=\"${nested#$project}\" ;; | \n|  | *) continue ;; | \n|  | esac | \n|  | BW+=(--tmpfs \"/work$rel/$BOX_DIRNAME\") | \n|  | done < \"$PI_BOX_HOME/boxes\" | \n|  | # Раскладку /bin, /lib, /lib64, /sbin повторяем с хоста, а не угадываем: на | \n|  | # merged-usr это симлинки в usr, но на Fedora /lib64 -> usr/lib64, и жёсткий | \n|  | # usr/lib оставил бы /lib64 битым. Симлинк — копируем цель, настоящий | \n|  | # каталог — монтируем. | \n|  | local l tgt | \n|  | for l in /bin /lib /lib64 /sbin; do | \n|  | tgt=$(readlink \"$l\" 2>/dev/null) | \n|  | if [ -n \"$tgt\" ]; then | \n|  | BW+=(--symlink \"$tgt\" \"$l\") | \n|  | elif [ -d \"$l\" ]; then | \n|  | BW+=(--ro-bind \"$l\" \"$l\") | \n|  | fi | \n|  | done | \n|  | # node бывает вне /usr (nvm, /opt, /usr/local), а этих каталогов внутри нет: | \n|  | # запуск упал бы, хотя проверка command -v прошла. Пробрасываем тот самый | \n|  | # бинарник, которым запускаем, чтобы проверка и запуск не расходились. | \n|  | case \"$NODE_BIN\" in | \n|  | /usr/*\\|/etc/*\\|/var/*) ;; | \n|  | *) [ -n \"$NODE_BIN\" ] && BW+=(--ro-bind \"$NODE_BIN\" \"$NODE_BIN\") ;; | \n|  | esac | \n|  | # Плагины: нет своих — подключаем каталог из $RUNTIME read-only, чтобы | \n|  | # правка плагина была видна во всех песочницах сразу. Есть свои — ничего не | \n|  | # монтируем, чтобы не перекрыть намеренную настройку. | \n|  | if [ -z \"$(ls -A \"$p/extensions\" 2>/dev/null)\" ] && [ -d \"$RUNTIME/extensions\" ]; then | \n|  | BW+=(--ro-bind \"$RUNTIME/extensions\" /box/profile/extensions) | \n|  | fi | \n|  | # Ключи — только чтение, поверх rw-профиля. | \n|  | [ -f \"$p/auth.json\" ] && BW+=(--ro-bind \"$p/auth.json\" /box/profile/auth.json) | \n|  | [ -f \"$p/models-store.json\" ] && BW+=(--ro-bind \"$p/models-store.json\" /box/profile/models-store.json) | \n|  | # -v src:dst[:ro\\|rw] — как в docker. Целевую точку bwrap создаёт сам, включая | \n|  | # вложенную. | \n|  | local v src dst mode rest | \n|  | for v in ${VOLUMES[@]+\"${VOLUMES[@]}\"}; do | \n|  | [ -n \"$v\" ] \\|\\| continue | \n|  | src=\"${v%%:*}\"; rest=\"${v#*:}\" | \n|  | [ \"$rest\" = \"$v\" ] && { echo \"-v: нужен вид <источник>:<куда>[:ro\\|rw], получил '$v'\" >&2; exit 1; } | \n|  | dst=\"${rest%%:*}\" | \n|  | mode=\"${rest#*:}\"; [ \"$mode\" = \"$rest\" ] && mode=\"rw\" | \n|  | src=\"${src/#\\~/$HOME}\" | \n|  | [ -e \"$src\" ] \\|\\| { echo \"-v: нет источника '$src'\" >&2; exit 1; } | \n|  | case \"$mode\" in | \n|  | ro\\|readonly) BW+=(--ro-bind \"$src\" \"$dst\") ;; | \n|  | rw\\|readwrite) BW+=(--bind \"$src\" \"$dst\") ;; | \n|  | *) echo \"-v: режим '$mode' неизвестен (ro или rw)\" >&2; exit 1 ;; | \n|  | esac | \n|  | done | \n|  | # -x/--x11: проброс X11 и GPU. /sys нужен Mesa, иначе рендер уходит на llvmpipe. | \n|  | if [ \"$X11\" = 1 ]; then | \n|  | [ -d /dev/dri ] && BW+=(--dev-bind /dev/dri /dev/dri) | \n|  | [ -d /sys ] && BW+=(--ro-bind /sys /sys) | \n|  | [ -d /tmp/.X11-unix ] && BW+=(--ro-bind /tmp/.X11-unix /tmp/.X11-unix) | \n|  | [ -n \"${XAUTHORITY:-}\" ] && [ -f \"$XAUTHORITY\" ] && BW+=(--ro-bind \"$XAUTHORITY\" /box/.Xauthority) | \n|  | BW+=(--setenv DISPLAY \"${DISPLAY:-:0}\") | \n|  | [ -n \"${XAUTHORITY:-}\" ] && BW+=(--setenv XAUTHORITY /box/.Xauthority) | \n|  | BW+=(--setenv LIBGL_ALWAYS_SOFTWARE 0) | \n|  | # Звук и сессия — только если есть. | \n|  | [ -n \"${XDG_RUNTIME_DIR:-}\" ] && [ -d \"$XDG_RUNTIME_DIR\" ] && \\ | \n|  | BW+=(--ro-bind \"$XDG_RUNTIME_DIR\" \"$XDG_RUNTIME_DIR\" --setenv XDG_RUNTIME_DIR \"$XDG_RUNTIME_DIR\") | \n|  | fi | \n|  | } | \n|  | # ── поиск песочниц ─────────────────────────────────────────────────────────── | \n|  | # Из реестра: \"<путь>\" по имени каталога, точному пути или части имени. | \n|  | find_box() { | \n|  | local want=\"$1\" | \n|  | [ -f \"$PI_BOX_HOME/boxes\" ] \\|\\| return 1 | \n|  | local line hit=\"\" | \n|  | while IFS= read -r line; do | \n|  | [ -n \"$line\" ] \\|\\| continue | \n|  | [ -d \"$line\" ] \\|\\| continue | \n|  | if [ \"$line\" = \"$want\" ] \\|\\| [ \"$(basename \"$line\")\" = \"$want\" ]; then | \n|  | echo \"$line\"; return 0 | \n|  | fi | \n|  | case \"$(basename \"$line\")\" in *\"$want\"*) hit=\"$line\" ;; esac | \n|  | done < \"$PI_BOX_HOME/boxes\" | \n|  | [ -n \"$hit\" ] && { echo \"$hit\"; return 0; } | \n|  | return 1 | \n|  | } | \n|  | is_running() { | \n|  | local s; s=$(box_socket \"$1\") | \n|  | [ -S \"$s\" ] && tmux -S \"$s\" has-session -t \"$TMUX_NAME\" 2>/dev/null | \n|  | } | \n|  | # Режимы запущенной песочницы читаем из argv живого bwrap: скрипт их нигде не | \n|  | # хранит, а процесс о них знает. Так статус честный и после перезапуска | \n|  | # инструмента. Вывод: \" X11 ~ro\" или пусто. | \n|  | box_modes() { | \n|  | local s pid argv | \n|  | s=$(box_socket \"$1\") | \n|  | [ -S \"$s\" ] \\|\\| return 0 | \n|  | pid=$(tmux -S \"$s\" list-panes -a -F '#{pane_pid}' 2>/dev/null \\| head -1) | \n|  | [ -n \"$pid\" ] && [ -r \"/proc/$pid/cmdline\" ] \\|\\| return 0 | \n|  | argv=$(tr '\\0' '\\n' < \"/proc/$pid/cmdline\" 2>/dev/null) | \n|  | grep -qx '/dev/dri' <<< \"$argv\" && printf ' X11' | \n|  | # Дом виден как ro-bind самого $HOME — ищем именно такую пару аргументов. | \n|  | grep -A1 -x -- '--ro-bind' <<< \"$argv\" \\| grep -qx -- \"$HOME\" && printf ' ~ro' | \n|  | } | \n|  | running_boxes() { | \n|  | [ -f \"$PI_BOX_HOME/boxes\" ] \\|\\| return 0 | \n|  | local line | \n|  | while IFS= read -r line; do | \n|  | [ -n \"$line\" ] && [ -d \"$line\" ] && is_running \"$line\" && echo \"$line\" | \n|  | done < \"$PI_BOX_HOME/boxes\" | \n|  | } | \n|  | cmd_list() { | \n|  | [ -s \"$PI_BOX_HOME/boxes\" ] \\|\\| { echo \"песочниц нет\"; return; } | \n|  | local line n sz sess run | \n|  | while IFS= read -r line; do | \n|  | [ -n \"$line\" ] \\|\\| continue | \n|  | if [ ! -d \"$line/$BOX_DIRNAME\" ]; then | \n|  | printf \" %-24s (удалена)\\n\" \"$(basename \"$line\")\" | \n|  | continue | \n|  | fi | \n|  | n=$(basename \"$line\"); sz=$(du -sh \"$line/$BOX_DIRNAME\" 2>/dev/null \\| cut -f1) | \n|  | sess=$(find \"$line/$BOX_DIRNAME/sessions\" -maxdepth 1 -name '*.jsonl' 2>/dev/null \\| wc -l) | \n|  | run=\"\"; is_running \"$line\" && run=\" [запущена]\" | \n|  | printf \" %-24s %6s сессий:%s%s\\n\" \"$n\" \"$sz\" \"$sess\" \"${C_GREEN}$run${C_RST}\" | \n|  | done < \"$PI_BOX_HOME/boxes\" | \n|  | echo | \n|  | echo \"удалить: pi-box --rm <имя>\" | \n|  | } | \n|  | cmd_rm() { | \n|  | local target=\"${1:-}\" line sock pid | \n|  | [ -n \"$target\" ] \\|\\| { echo \"укажи песочницу (pi-box -l)\"; exit 1; } | \n|  | line=$(find_box \"$target\") \\|\\| { echo \"нет песочницы '$target' (pi-box -l)\" >&2; exit 1; } | \n|  | confirm_rm \"$line\" \\|\\| return 1 | \n|  | # Гасим ровно процессы песочницы: PID панелей tmux, а потом сам сервер. | \n|  | # pkill по строке argv тут опасен — под шаблон попадает и наш собственный | \n|  | # shell, если в его аргументах есть тот же путь. | \n|  | sock=$(box_socket \"$line\") | \n|  | for pid in $(tmux -S \"$sock\" list-panes -a -F '#{pane_pid}' 2>/dev/null); do | \n|  | kill \"$pid\" 2>/dev/null | \n|  | done | \n|  | tmux -S \"$sock\" kill-server 2>/dev/null | \n|  | sleep 1 | \n|  | rm -rf \"$line/$BOX_DIRNAME\" && echo \"${C_GREEN}удалено${C_RST}: $line/$BOX_DIRNAME\" | \n|  | # Убираем из реестра. | \n|  | if [ -f \"$PI_BOX_HOME/boxes\" ]; then | \n|  | grep -vxF \"$line\" \"$PI_BOX_HOME/boxes\" > \"$PI_BOX_HOME/boxes.tmp\" 2>/dev/null \\|\\| true | \n|  | mv \"$PI_BOX_HOME/boxes.tmp\" \"$PI_BOX_HOME/boxes\" | \n|  | fi | \n|  | } | \n|  | # Удаление запущенной песочницы прерывает работу и сносит профиль с историей — | \n|  | # отменить это нельзя. Спрашиваем только у активной: у остановленной терять | \n|  | # нечего, и подтверждение там было бы лишним шумом. | \n|  | confirm_rm() { | \n|  | local line=\"$1\" ans=\"\" | \n|  | is_running \"$line\" \\|\\| return 0 | \n|  | [ \"$FORCE\" = 1 ] && return 0 | \n|  | printf '%s' \"${C_YEL}«$(basename \"$line\")» запущена${C_RST} — работа прервётся, профиль и история сессий удалятся. Удалить? [y/N] \" >&2 | \n|  | # Читаем с терминала, а не со stdin: в режиме --rm stdin бывает занят | \n|  | # конвейером, а в меню его уже прочитал fzf. Нет терминала (cron, скрипт) — | \n|  | # отвечать некому, поэтому не удаляем: пусть решает --force. | \n|  | { read -r ans < /dev/tty; } 2>/dev/null \\|\\| { | \n|  | echo \"нет терминала для подтверждения — используй --force\" >&2 | \n|  | return 1 | \n|  | } | \n|  | case \"$ans\" in | \n|  | [yYдД]*) return 0 ;; | \n|  | esac | \n|  | echo \"отменено\" >&2 | \n|  | return 1 | \n|  | } | \n|  | # ── интерактивное меню ──────────────────────────────────────────────────────── | \n|  | # ── интерактивное меню ──────────────────────────────────────────────────────── | \n|  | # Живёт в текущем экране (fzf --height), история остаётся видна. Цикл: | \n|  | # после удаления или отказа показываем список снова, пока не выбрана песочница | \n|  | # или пользователь не нажал Esc. | \n|  | # | \n|  | # Enter открыть (создать, если выбран текущий каталог) | \n|  | # Ctrl-D удалить выбранную | \n|  | # Esc выйти | \n|  | # | \n|  | # Цель — одной командой сделать всё: завести, открыть, снести. | \n|  | cmd_menu() { | \n|  | command -v fzf >/dev/null 2>&1 \\|\\| { | \n|  | echo \"${C_RED}Нужен fzf${C_RST} для интерактивного выбора.\" >&2 | \n|  | echo \"Установи fzf или используй аргументы (pi-box -l, pi-box PATH).\" >&2 | \n|  | exit 1 | \n|  | } | \n|  | local here; here=$(pwd -P) | \n|  | # Из домашнего каталога песочницу не создаём (см. проверку ниже), поэтому | \n|  | # и строки «создать» для него нет. | \n|  | [ \"$here\" = \"$HOME\" ] && here=\"\" | \n|  | while :; do | \n|  | # Однострочные строки: fzf 0.74 разворачивает \\n внутри элемента | \n|  | # нестабильно — при единственной строке рисует «␊» вместо переноса, | \n|  | # поэтому многострочность не используем. Разделитель полей — \\x1f, | \n|  | # а не таб: таб может встретиться в пути. | \n|  | local -a rows=() | \n|  | # Создание — отдельная заметная строка, а не серый путь: раньше её было | \n|  | # не отличить от «открыть существующую». | \n|  | [ -n \"$here\" ] && rows+=(\"${C_CYAN}+ создать здесь${C_RST} ${C_DIM}$here${C_RST}\\x1fHERE\") | \n|  | if [ -f \"$PI_BOX_HOME/boxes\" ]; then | \n|  | local line name run st m | \n|  | while IFS= read -r line; do | \n|  | [ -n \"$line\" ] \\|\\| continue | \n|  | [ -d \"$line/$BOX_DIRNAME\" ] \\|\\| continue | \n|  | [ \"$line\" = \"$here\" ] && continue | \n|  | name=$(basename \"$line\") | \n|  | # Статус и режимы — в первой колонке: её видно. Режимы берём у живого | \n|  | # процесса, чтобы после перезапуска инструмента они не пропадали. | \n|  | if is_running \"$line\"; then | \n|  | m=$(box_modes \"$line\") | \n|  | st=\"${C_GREEN}●${C_RST}${C_YEL}${m}${C_RST}\" | \n|  | else | \n|  | st=\"${C_DIM}·${C_RST}\" | \n|  | fi | \n|  | sz=$(du -sh \"$line/$BOX_DIRNAME\" 2>/dev/null \\| cut -f1) | \n|  | rows+=(\"$(printf '%-16s %s' \"$name\" \"$st\") ${C_DIM}${sz} $line${C_RST}\\x1f$line\") | \n|  | done < \"$PI_BOX_HOME/boxes\" | \n|  | fi | \n|  | # Активные режимы — в приглашении: видно, с чем откроется выбранная. | \n|  | local modes=\"\" | \n|  | [ \"$X11\" = 1 ] && modes=\" · ${C_YEL}X11${C_RST}\" | \n|  | [ \"$HOMERO\" = 1 ] && modes=\"$modes · ${C_YEL}\\$HOME ro${C_RST}\" | \n|  | local out=\"\" | \n|  | out=$(printf '%b\\0' \"${rows[@]}\" \\| fzf --ansi --read0 \\ | \n|  | --height=\"${FZF_HEIGHT:-~45%}\" \\ | \n|  | --reverse --border --no-multi \\ | \n|  | --expect=ctrl-d,ctrl-x,ctrl-h \\ | \n|  | --prompt=\"песочница${modes}> \" \\ | \n|  | --header='Enter — открыть · Ctrl-D — удалить · Ctrl-X — X11 · Ctrl-H — $HOME ro · Esc — выход' \\ | \n|  | --delimiter='\\x1f' --with-nth=1 --accept-nth=2) \\|\\| exit 0 | \n|  | local -a kl=() | \n|  | readarray -t kl < <(printf '%s\\n' \"$out\") | \n|  | # ${kl[1]:-}: при set -u обращение к отсутствующему элементу — ошибка, | \n|  | # а при Enter fzf отдаёт одну строку (без имени клавиши). | \n|  | local pressed=\"${kl[0]:-}\" want=\"${kl[1]:-}\" | \n|  | # При Enter строки с клавишей нет — выбор оказывается первым. | \n|  | [ \"${#kl[@]}\" -lt 2 ] && { pressed=\"\"; want=\"${kl[0]:-}\"; } | \n|  | case \"$pressed\" in | \n|  | ctrl-x) X11=$((1 - X11)); continue ;; | \n|  | ctrl-h) HOMERO=$((1 - HOMERO)); continue ;; | \n|  | esac | \n|  | [ -n \"$want\" ] \\|\\| continue | \n|  | if [ \"$pressed\" = ctrl-d ]; then | \n|  | if [ \"$want\" = HERE ]; then | \n|  | echo \"${C_YEL}Здесь ещё нет песочницы — удалять нечего${C_RST}\" >&2 | \n|  | continue | \n|  | fi | \n|  | cmd_rm \"$want\" | \n|  | continue | \n|  | fi | \n|  | [ \"$want\" = HERE ] && MENU_PROJECT=\"$here\" \\|\\| MENU_PROJECT=\"$want\" | \n|  | return 0 | \n|  | done | \n|  | } | \n|  | cmd_attach() { | \n|  | local want=\"${1:-}\" line | \n|  | if [ -z \"$want\" ]; then | \n|  | local run=(); while IFS= read -r l; do run+=(\"$l\"); done < <(running_boxes) | \n|  | case \"${#run[@]}\" in | \n|  | 0) echo \"нет запущенных песочниц (pi-box -l)\" >&2; exit 1 ;; | \n|  | 1) line=\"${run[0]}\" ;; | \n|  | *) echo \"запущено несколько — укажи имя:\" >&2 | \n|  | for l in \"${run[@]}\"; do echo \" $(basename \"$l\")\" >&2; done; exit 1 ;; | \n|  | esac | \n|  | else | \n|  | line=$(find_box \"$want\") \\|\\| { echo \"нет песочницы '$want' (pi-box -l)\" >&2; exit 1; } | \n|  | fi | \n|  | is_running \"$line\" \\|\\| { echo \"песочница '$(basename \"$line\")' не запущена\" >&2; exit 1; } | \n|  | exec tmux -S \"$(box_socket \"$line\")\" attach -t \"$TMUX_NAME\" | \n|  | } | \n|  | # ── разбор аргументов ──────────────────────────────────────────────────────── | \n|  | NEW=0; PROMPT=\"\"; ATTACH=\"\"; X11=0; HOMERO=0; VOLUMES=(); PROJECT=\"\"; MENU=0; FORCE=0 | \n|  | while [ $# -gt 0 ]; do | \n|  | case \"$1\" in | \n|  | -h\\|--help) usage; exit 0 ;; | \n|  | -l\\|--list) cmd_list; exit 0 ;; | \n|  | --rm) shift; cmd_rm \"${1:-}\"; exit $? ;; | \n|  | -f\\|--force) FORCE=1; shift ;; | \n|  | --new) NEW=1; shift ;; | \n|  | -m\\|--menu) MENU=1; shift ;; | \n|  | -x\\|--x11) X11=1; shift ;; | \n|  | -H\\|--home) HOMERO=1; shift ;; | \n|  | -v\\|--volume) VOLUMES+=(\"${2:-}\"); shift 2 ;; | \n|  | -a\\|--attach) ATTACH=\"1\"; shift | \n|  | case \"${1:-}\" in ''\\|-*) ;; *) ATTACH=\"$1\"; shift ;; esac ;; | \n|  | -p\\|--print) PROMPT=\"${2:-}\"; shift 2 ;; | \n|  | -*) echo \"неизвестный флаг: $1\" >&2; exit 1 ;; | \n|  | *) PROJECT=\"$1\"; shift ;; | \n|  | esac | \n|  | done | \n|  | # Зависимости проверяем сразу после разбора аргументов: --help и -l должны | \n|  | # работать без них, а всё остальное — нет. | \n|  | require_tools | \n|  | [ -n \"$ATTACH\" ] && { [ \"$ATTACH\" = 1 ] && cmd_attach \"\" \\|\\| cmd_attach \"$ATTACH\"; } | \n|  | # Ищем .pi-box вверх по дереву — как git. Запуск из подкаталога подхватывает | \n|  | # уже существующую песочницу, а не плодит вложенную. | \n|  | find_root() { | \n|  | local dir=\"$1\" | \n|  | while :; do | \n|  | [ -d \"$dir/$BOX_DIRNAME\" ] && { echo \"$dir\"; return 0; } | \n|  | [ \"$dir\" = \"/\" ] && break | \n|  | dir=$(dirname \"$dir\") | \n|  | done | \n|  | return 1 | \n|  | } | \n|  | PROJECT=\"${PROJECT:-$PWD}\" | \n|  | [ -d \"$PROJECT\" ] \\|\\| { echo \"${C_RED}нет каталога${C_RST}: $PROJECT\" >&2; exit 1; } | \n|  | # realpath -P: без него один проект попадёт в реестр под разными путями | \n|  | # (симлинки, .. , относительный путь), и песочница «раздвоится». | \n|  | PROJECT=$(cd \"$PROJECT\" 2>/dev/null && pwd -P) | \n|  | [ -n \"$PROJECT\" ] \\|\\| { echo \"${C_RED}не удалось определить каталог${C_RST}: $PWD\" >&2; exit 1; } | \n|  | ROOT=$(find_root \"$PROJECT\") \\|\\| ROOT=\"\" | \n|  | # Меню — только когда песочницы нет ни здесь, ни выше. Если мы уже в проекте | \n|  | # с .pi-box, запуск/подключение ожидается сразу, без списка. | \n|  | if [ \"$MENU\" = 1 ] \\|\\| { [ -z \"$ROOT\" ] && [ \"$NEW\" = 0 ] && [ -z \"$PROMPT\" ] \\ | \n|  | && [ \"$X11\" = 0 ] && [ ${#VOLUMES[@]} = 0 ] && [ -z \"$ATTACH\" ]; }; then | \n|  | cmd_menu | \n|  | PROJECT=\"${MENU_PROJECT:-$PROJECT}\" | \n|  | ROOT=$(find_root \"$PROJECT\") \\|\\| ROOT=\"\" | \n|  | fi | \n|  | [ -n \"$ROOT\" ] \\|\\| ROOT=\"$PROJECT\" | \n|  | # Защита: корень и дом — это не проект. Иначе /work получил бы весь $HOME | \n|  | # (включая .ssh, .pi, чужие проекты), и агент мог бы их править. Дом для чтения | \n|  | # даёт Ctrl-H — он монтирует $HOME рядом с /work, не делая его проектом. | \n|  | case \"$ROOT\" in | \n|  | \"$HOME\") echo \"${C_RED}Нельзя${C_RST} запускать в домашнем каталоге: /work получил бы весь $HOME.\" >&2 | \n|  | echo \"Зайди в каталог проекта: ${C_CYAN}cd ~/code/myproject && pi-box${C_RST}\" >&2 | \n|  | echo \"Весь дом только для чтения — ${C_CYAN}pi-box -m${C_RST} и Ctrl-H.\" >&2 | \n|  | exit 1 ;; | \n|  | \"/\") echo \"${C_RED}Нельзя${C_RST} использовать корень / как проект.\" >&2; exit 1 ;; | \n|  | esac | \n|  | # .pi-box не должен быть симлинком. Проект монтируется в /work rw, поэтому | \n|  | # симлинк в .pi-box позволял бы писать за пределы песочницы (в ~/.ssh, /etc) | \n|  | # и увёл бы --rm на чужой каталог. Не тюрьма — страховка от случайности. | \n|  | reject_link() { | \n|  | [ ! -L \"$1\" ] \\|\\| { | \n|  | echo \"${C_RED}опасный симлинк${C_RST}: $1\" >&2 | \n|  | echo \".pi-box и всё внутри должны быть настоящими каталогами и файлами.\" >&2 | \n|  | exit 1 | \n|  | } | \n|  | } | \n|  | reject_link \"$ROOT/$BOX_DIRNAME\" | \n|  | for sub in profile profile/extensions sessions tmp profile/auth.json \\ | \n|  | profile/models-store.json profile/settings.json tmp/tmux.conf; do | \n|  | reject_link \"$ROOT/$BOX_DIRNAME/$sub\" | \n|  | done | \n|  | register \"$ROOT\" | \n|  | # --new на работающей песочнице сносит профиль из-под живой сессии: она | \n|  | # продолжит держать старые файлы (bind), а новая запустится с чистым профилем — | \n|  | # и два процесса будут писать в одно место. Отказываемся. | \n|  | if [ \"$NEW\" = 1 ] && is_running \"$ROOT\"; then | \n|  | echo \"${C_RED}Песочница запущена${C_RST} — сначала останови её:\" >&2 | \n|  | echo \" ${C_CYAN}pi-box --rm $(basename \"$ROOT\")${C_RST} (или подключись: pi-box -a)\" >&2 | \n|  | exit 1 | \n|  | fi | \n|  | require_pi | \n|  | [ \"$NEW\" = 1 ] && rm -rf \"$ROOT/$BOX_DIRNAME\" | \n|  | box_init \"$ROOT\" | \n|  | # pi монтируется в /pi целиком как каталог @earendil-works, поэтому путь к | \n|  | # cli.js повторяет его структуру. | \n|  | PI=\"/pi/$PI_BASENAME/dist/bundle/cli.js\" | \n|  | box_bwrap \"$ROOT\" | \n|  | # ── один запрос ────────────────────────────────────────────────────────────── | \n|  | # Без bash -c: переменные и cwd ставятся самим bwrap. Через bash -c значение | \n|  | # с кавычкой или $(...) попадало в shell — проверено, PI_BOX_SESSION_ID мог | \n|  | # выполнить произвольную команду. Теперь значения идут аргументами, не строкой. | \n|  | if [ -n \"$PROMPT\" ]; then | \n|  | exec \"${BW[@]}\" \"$NODE_BIN\" \"$PI\" -p \\ | \n|  | --session-dir /box/sessions --session-id \"$SESSION_ID\" \"$PROMPT\" | \n|  | fi | \n|  | # ── интерактив ─────────────────────────────────────────────────────────────── | \n|  | # Через tmux: опции терминала надо выставить до создания панели, иначе панель | \n|  | # унаследует screen-256color и потеряет truecolor, а Shift+Enter — Enter. | \n|  | # | \n|  | # -c \"$ROOT\": каталог сессии, из него открываются новые окна (prefix+c). | \n|  | # tmux-сервер живёт СНАРУЖИ bwrap, поэтому новые окна — обычные шеллы хоста, | \n|  | # и путь должен быть настоящим: /work существует только внутри песочницы. | \n|  | SOCK=$(box_socket \"$ROOT\") | \n|  | mkdir -p \"$(dirname \"$SOCK\")\" | \n|  | if is_running \"$ROOT\"; then | \n|  | [ -t 0 ] && exec tmux -S \"$SOCK\" attach -t \"$TMUX_NAME\" | \n|  | echo \"${C_GREEN}песочница${C_RST} '${C_B}$(basename \"$ROOT\")${C_RST}' уже запущена — ${C_CYAN}pi-box -a${C_RST}\" | \n|  | exit 0 | \n|  | fi | \n|  | # tmux new-session возвращает 0 даже когда панель не поднялась (проверено), | \n|  | # поэтому успех подтверждаем отдельной проверкой, а не кодом возврата. | \n|  | tmux -S \"$SOCK\" -f \"$ROOT/$BOX_DIRNAME/tmp/tmux.conf\" new-session -d -s \"$TMUX_NAME\" -c \"$ROOT\" \\ | \n|  | \"${BW[@]}\" \"$NODE_BIN\" \"$PI\" \\ | \n|  | --session-dir /box/sessions --session-id \"$SESSION_ID\" | \n|  | if ! is_running \"$ROOT\"; then | \n|  | echo \"${C_RED}не удалось запустить песочницу${C_RST}\" >&2 | \n|  | echo \"Проверь, что проект лежит в доступном каталоге; запусти без tmux:\" >&2 | \n|  | echo \" ${C_CYAN}pi-box -p \\\"echo test\\\"${C_RST}\" >&2 | \n|  | exit 1 | \n|  | fi | \n|  | [ -t 0 ] && exec tmux -S \"$SOCK\" attach -t \"$TMUX_NAME\" | \n|  | echo \"${C_GREEN}песочница${C_RST} '${C_B}$(basename \"$ROOT\")${C_RST}' запущена — ${C_CYAN}pi-box -a${C_RST}\" |", "url": "https://wpnews.pro/news/lightweight-sandboxing-bwrap-wrapper-for-the-pi-coding-agent", "canonical_source": "https://gist.github.com/Jipok/d4f7744b7e5cc1c6668ba2ed2fa74b8c", "published_at": "2026-09-25 13:59:54+00:00", "updated_at": "2026-09-25 14:32:05.813881+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "ai-safety"], "entities": ["pi-box", "bwrap", "pi", "tmux"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/lightweight-sandboxing-bwrap-wrapper-for-the-pi-coding-agent", "markdown": "https://wpnews.pro/news/lightweight-sandboxing-bwrap-wrapper-for-the-pi-coding-agent.md", "text": "https://wpnews.pro/news/lightweight-sandboxing-bwrap-wrapper-for-the-pi-coding-agent.txt", "jsonld": "https://wpnews.pro/news/lightweight-sandboxing-bwrap-wrapper-for-the-pi-coding-agent.jsonld"}}