Skip to content

dsh-better-sandbox

Verified

@rkfg/dsh-better-sandbox · v0.4.1 · MIT

Sandbox rules for DeepSeek Harness: extra writable roots, write-protected path names with a narrow escalation level for them, read-protected paths.

Install

dsh plugin add @rkfg/dsh-better-sandbox

Confirm the layer applied with dsh --profile default --dump-config — see the install guide.

Source

Tags

Creators

Readme

@rkfg/dsh-better-sandbox

Бандл DSH: расширяет песочницу дополнительными корнями записи, запрещает изменять .git и закрывает от чтения каталоги и файлы с секретами. Работает в трёх контурах: файловые инструменты (ctx.fs), команды через bash (bwrap), поиск через упакованный ripgrep. Русская копия этого файла; первичен README.md.

Состав

Модуль Роль
lib/rules.js сервис betterSandbox: эффективные списки правил, отбраковка неприменимых (rejected), маска mode 000
lib/escalation.js вписывает именованный уровень эскалации в словарь харнесса: таблицу «строже шире» и enum sandbox_permissions инструментов
lib/fs.js провайдер fs: забор на запись + запрет чтения
lib/runner.js провайдер sandbox: аргументы bwrap для команд
lib/subprocess.js провайдер subprocess: оборачивает в bwrap только ripgrep (иначе grep/glob читают закрытое)
lib/masks.js общие аргументы bwrap: их собирают и раннер, и обёртка ripgrep; расхождение означало бы дыру в защите
lib/policy.js канонизация путей, поиск защищённых каталогов с ограниченным обходом и кэшем
lib/guard-api.js Remote-сервис sandboxGuard: state и validate
lib/bundle.js группа записей бандла (cordis.group)

cordis.patch.yml — слой бандла: гасит базовые fs-sandbox, sandbox, subprocess и вставляет свои строки. Значения списков он не задаёт.

Раздел настроек

Правила правятся в веб-UI: диалог настроек → раздел Sandbox (четыре поля со списками, по строке на значение, и одно поле с именем уровня эскалации) → «Save». Раздел приносит отдельный бандл @rkfg/dsh-better-sandbox-ui: пакет с клиентской половиной обязан иметь ровно одну запись загрузчика, поэтому он не может жить внутри @rkfg/dsh-better-sandbox. Подписи раздела существуют в en и zh; по-русски они появятся вместе с русской локализацией самого харнесса.

Клиентский рукав регистрирует слот settings.section и работает через штатный слой настроек: ctx.remote.settings.describe() (без аргументов, ответ — { namespaces: [{ ns, value, revision, schema }] }) и ctx.remote.settings.update('better-sandbox', patch, revision). Хостовая запись пакета — пустой сервис: вся логика в клиенте и в слое настроек.

Собственного ctx.remote-пространства имён у клиента сознательно нет: согласно cookbook/adding-a-remote-api.md клиент обязан объявить в inject имя сервиса remote.<namespace>, а это пространство появляется из генерируемых артефактов ./typert и ./remote, которых у рукописного JS-пакета нет. Поэтому правка идёт через ctx.remote.settings — тот же путь, которым пользуется сам диалог настроек.

Установка

Из корня репозитория:

./scripts/install.sh                            # профиль web
./scripts/install.sh --home DIR --profile ИМЯ   # другой DSH_HOME и профиль
./scripts/install.sh --drop СТАРОЕ_ИМЯ          # вычистить прежнее имя пакета

Скрипт кладёт оба пакета в node_modules профиля, дописывает их в dsh.profile.bundles (с копией package.json), проверяет синтаксис и пересоздаёт заглушки маски.

Штатный путь установки плагина — dsh plugin --profile web add <package>. Скрипт существует потому, что пакеты лежат рядом с исходниками в workspace, требуют фиксации версий харнесса и пересоздания заглушек маски сразу после rm -rf. После него нужен рестарт профиля: новую строку загрузчик в запущенный процесс не добавляет.

В профильном cordis.patch.yml остаются только значения:

- id: better-sandbox
  name: '@rkfg/dsh-better-sandbox/rules'
  config:
    writableDirs: ["/opt/go", "/home/you/.cache"]
    readDenyDirs: ["/home/you/.ssh", "/home/you/.aws"]
    readDenyFiles: ["/home/you/.npmrc", "/home/you/.netrc"]

Правила

Поле Значение Правка без рестарта
writableDirs каталоги вне рабочей папки, куда разрешена запись страница «Sandbox»
denySegments имена путей, запрещённые к изменению (.git) страница «Sandbox»
readDenyDirs, readDenyFiles закрыто от чтения (маска mode 000) страница «Sandbox»
denyDepth, denySkipDirs, denyScanTtlMs обход при поиске .git для bwrap нет
maskFallback, readDenialSignature поведение раннера нет

Все четыре списка помечены .volatile(): харнесс отдаёт их как ссылки { get() } (cosmokit), обновляет без перемонтирования записи, а значение сохраняется в profile patch. Единственный источник правил — config строки better-sandbox: списки приходят из слоя настроек и лежат в profile patch.

Программная правка — settings.update('better-sandbox', patch, revision) (так делает страница настроек). Вызывать её нужно вне стека активации записи: редактор конфигурации берёт лок на package.json профиля, который стартовый проход удерживает, и вызов из apply() упирается в таймаут блокировки.

Что закрыто и где границы

  • .git: чтение свободно (status, log, diff), запись → EROFS. Имя сегмента проверяется на любой глубине в ctx.fs; в bwrap — до denyDepth, глубже нужна правка denyDepth.
  • Репозиторий, созданный внутри самой команды, закрыт начиная со следующей команды.
  • rm -rf защищённого каталога требует эскалации.
  • danger-full-access снимает защиту — это и есть штатный путь, когда файл нужен.
  • Процессы вне песочницы не покрыты: MCP-серверы и git-интеграция харнесса через ctx.subprocess идут pass-through.
  • git по SSH внутри песочницы не работает: ssh не читает ~/.ssh. Варианты — эскалация, ssh-agent, вынесенный known_hosts.
  • Маска — bind пустого каталога или файла с режимом 000 → EACCES. --tmpfs не подходит: запись в него «удаётся» и исчезает.
  • Удаление каталога с маской: сначала chmod 0755 на каталог, корень и заглушку.
  • Заглушки маски лежат в папке плагина. Если их стерли во время работы (так делает переустановка пакета), confine не падает: наличие заглушек проверяется перед каждой командой, маска восстанавливается сама, а до восстановления закрытые пути прячутся запасным вариантом из maskFallback.

Проверка

Всё внутри workspace, без эскалаций и без риска для рабочего профиля:

./scripts/check.sh                                     # базовый контур: basetest и allowtest
DSH_BUNDLE=1 DSH_PROBE=driver ./scripts/webfull.sh     # полный веб-профиль с бандлом

Профили живут в .dsh-test/ (свой DSH_HOME, свой порт). Копирование идёт из исходников в тестовые профили, поэтому правки, сделанные внутри .dsh-test/, пропадают при следующем прогоне. Для проверки чтения заведите canary-файл вида ~/.ssh/secret.key; реальные секретные файлы для проверки не читаются — отказ должна выдавать сама песочница.

Model Experience

Контекст запроса: анонс правил песочницы

What the model sees

Секция sandbox:rules добавляется к контексту рантайма сразу после секции политики песочницы (SANDBOX_POLICY) и существует, только если хотя бы один список не пуст и announceInPrompt включён. Текст строится по четырём шаблонам; третий появляется только при непустом списке защищённых имён и включённом уровне.

Verbatim text for this field
Additional write roots beyond the session workspace (workspace-write mode only): <список путей в кавычках, через запятую>.
Protected path names inside every writable root (workspace-write mode): <список имён в кавычках> — writing there is refused the same way as writing outside the workspace.
Escalation level "<токен>": when a write or a command is refused only because of one of those names, retry that exact call once with sandbox_permissions set to "<токен>" and a one-sentence justification. It opens every name above for that single call, with the user's approval; every other rule stays exactly as it is. Prefer it over a wider mode.
Protected from reading (secrets and personal data): <каталоги и файлы в кавычках, через запятую> — reading their contents is refused for tools and denied inside sandboxed commands. No escalation target of this plugin opens a read, so ask the user to change the rule when the file is genuinely needed.
Sandbox guards are NOT applied here: <причины>. The rules above still bind the file tools, but commands run without that part of the protection.

Последняя строка появляется только тогда, когда какой-то из швов confinement сообщил, что настроенная защита не действует (нераспознанный раннер, раннер, который не умеет правило, отсутствующий стаб маски), и исчезает, как только защита снова применяется.

Кроме того, lib/escalation.js дописывает одно предложение к описанию параметра sandbox_permissions у bash, pwsh, write, edit: оттуда модель узнаёт, что существует цель уже, чем danger-full-access. Другого канала нет — отказ write и edit инструмент перезаписывает общим маркером с подсказкой, и текст плагина туда не попадает.

Token effect

Conditional. Строки присутствуют только при непустых списках: одна строка на каждое непустое семейство правил, длина пропорциональна числу путей. Пустые списки дают ноль токенов.

KV Cache effect

Append-only относительно политики песочницы: секция добавляется после уже существующих строк контекста и не меняет их. Правка списков через слой настроек заменяет содержимое секции на ходу, поэтому переиспользование префикса сбрасывается от места этой секции и дальше; порядок строк внутри секции фиксирован (корни → имена путей → чтение), поэтому неизменные списки дают идентичный текст.

Отказ как текст модели

What the model sees

Косвенно, через dsh-tool-fs и dsh-tool-bash: отказ fence'а и refusal-сигнатура раннера попадают в результат инструмента. Пакет поставляет текст причины отказа чтения ("<путь>" is protected from reading by the better-sandbox policy) и дополняет диалект bwrap сигнатурой permission denied, чтобы bash показал политический отказ, а не системную ошибку. Подсказку об эскалации в отказе чтения плагин намеренно не пишет: ни один инструмент не объявляет sandbox_permissions для чтения, поэтому такая подсказка отправила бы модель в ошибку схемы.

Token effect

Zero-direct: пакет не добавляет текста в контекст, кроме уже учтённого выше. Появление текста отказа зависит от действия модели и оплачивается как результат инструмента.

KV Cache effect

Independent: сообщения об отказах принадлежат истории разговора, а не системному префиксу, и не влияют на переиспользование последних.

Known Limitations and Deferred Work

  • Чтение эскалации не имеет — read, glob и grep не объявляют sandbox_permissions, а методы чтения файлового провайдера вообще не получают политику. Уровень покрывает две мутации и confined-оболочки; открыть закрытый от чтения путь можно только сменой правила.
  • Имя уровня — словарь модели — одно строчное слово, не совпадающее с read-only, workspace-write, danger-full-access; иначе плагин пишет предупреждение и выключает уровень, вместо того чтобы предлагать цель, которую некому выдать.
  • Постоянные оболочки помнят свой confinement — терминал и pwsh-сессия сохраняют правила, полученные при запуске; уровень действует на один вызов.
  • Уровень предлагается там, где он действительно расширяет права — он означает «те же права вызова, но защищённые имена открыты», поэтому добавляется в список целей только сессии workspace-write. Сессии read-only нужен workspace-write, и она просит именно его: более узкая цель была бы карточкой, обещающей меньше, чем она даёт.
  • Глубина защищённых имён в bwrap ограничена denyDepth — сегмент имени проверяется на любой глубине в ctx.fs, но в confinement только до denyDepth, и обход не спускается в denySkipDirs (node_modules, dist, кэши). Репозиторий глубже для файловых инструментов закрыт, а внутри команды — нет: место обрыва видно в отчёте скана, а нужное дерево добавляется в denyScanRoots.
  • Свежесозданное защищённое имя защищено для команд не мгновенно — denyScanTtlMs задаёт срок годности скана; 0 означает пересканировать корни перед каждой командой, платя синхронным обходом за каждую.
  • Неприменимое правило отвергается, а не приближается — корень файловой системы, правило поверх файлов самого харнесса или внутри них, а также правило на чтение, накрывающее рабочую папку, попадают в rejected: их пишет лог хоста и возвращает sandboxGuard.state().
  • Стабы маски проверяются, но не защищены ядром — стаб является источником привязки, а привязка следует за симлинком-источником: подменённый стаб смонтировал бы то, на что он указывает, поверх защищённого пути. Каждая команда проверяет тип и режим стаба и пересобирает его — режим 000 сам по себе не защита, ведь владелец всегда вернёт себе запись. Если плагин установлен внутри рабочей папки, её обитатели могут писать в папку стабов, и страница настроек об этом сообщает.
  • Грант на запись не может лежать внутри защищённого от чтения пути — маска родителя спрятала бы грант, а грант, привязанный первым, спрятал бы родителя от маски. Побеждает правило на чтение: грант отвергается по имени (inside-read-denied в rejected), потому что отсутствующий грант заметен и поправим, а молча читаемый секретный путь — нет.
  • Правило на чтение закрывает чтение, но не запись — закрытый от чтения путь, который к тому же записываем (.env в рабочей папке), можно перезаписать, не читая.
  • Диалект отказа текстовый — отказ маски опознаётся как политический, когда вывод команды называет защищённый путь. Команда, назвавшая тот же путь иначе (../file, относительный путь), по-прежнему получает отказ маски, но доходит до модели как обычная ошибка; readDenialSignature: any матчит вместо этого формулировку ядра — ценой того, что посторонние ошибки прав тоже станут «политическими отказами».
  • Landlock не выражает ни вычитание записи, ни запрет чтения подпути — при выборе этого раннера denySegments и readDeny* не применяются; причина идёт в лог, на страницу настроек и в контекст модели вместо имитации защиты.
  • Процессы вне песочницы не покрыты: MCP-серверы и git-интеграция самого харнесса через ctx.subprocess идут pass-through и не должны ломаться.
  • git по SSH внутри песочницы неработоспособен, пока ~/.ssh закрыт от чтения: нужен эскалированный запуск, ssh-agent или вынесенный known_hosts.
  • Имена закрытых каталогов остаются видны в списке родителя: маска подменяет точку mode 000, а не фильтрует каталог-родитель.
  • Установщик не пользуется dsh plugin add: нужны фиксация версий и пересоздание заглушек маски до рестарта.