Skip to content

Решать, что уходит наружу, до того как ревьюеры что-то прочитают - #2

Open
jojoprison wants to merge 1 commit into
szarkans:mainfrom
jojoprison:feat/safe-paths-preflight
Open

Решать, что уходит наружу, до того как ревьюеры что-то прочитают#2
jojoprison wants to merge 1 commit into
szarkans:mainfrom
jojoprison:feat/safe-paths-preflight

Conversation

@jojoprison

Copy link
Copy Markdown
Contributor

Привет! Разбирал плагин, чтобы понять, ставить ли его глобально — и наткнулся на одну вещь, которую починил сразу, вместо того чтобы просто завести issue. Пишу по-русски, раз мы оба, коммит оставил на английском, как и всё в репо.

Проблема

Ревьюерам сейчас передаётся инструкция найти изменения самим, включая untracked:

scripts/review-codex.sh:49     git status --porcelain --untracked-files=all ... Untracked files count as changes.
scripts/review-opencode.sh:44  git status --porcelain --untracked-files=all${PS} && ...

Для описания незакоммиченной правки это честно: свежесозданный файл и есть изменение, без него ревью неполное. Но untracked — ещё и то место, где рабочее дерево держит вещи, которые уезжать не должны: .env.local, забытый в корне deploy-ключ, service-account JSON, и — отдельная ирония — providers.env самого плагина. На публичной песочнице это ничего не стоит, на приватном репозитории это отправка секретов наружу, причём фильтр после сбора уже не помогает: чтобы решить, можно ли показывать содержимое, его придётся прочитать, а прочитанное уже в чужом промпте.

Отказ тихий вдвойне. В отчёте ничего не появляется, скоуп при этом самый естественный — «мои текущие изменения», то есть ровно то, что человек выберет по умолчанию.

Что предлагаю

Развернуть порядок: сначала имена, содержимое здесь не читается никогда.

Новый scripts/safe-paths.sh собирает пути в скоупе (git diff --name-only, git ls-files — ни одного открытия файла), прогоняет их через список deny-правил по имени и печатает машиночитаемо:

allow: src/app.py
withheld: .env.local env-file
withheld: deploy_rsa ssh-key
summary: 1 allowed, 2 withheld

Дальше разрешённые пути уходят обоим внешним ревьюерам через --paths, а в их промптах теперь прямо сказано не расширять скоуп самостоятельно. Число withheld просится в отчёт — ревьюер, видевший не всю правку, должен сказать это вслух, иначе «чисто» означает не то, что кажется.

Три свойства, на которых всё держится:

  • Fail-closed. Не разрешился скоуп (опечатка в ref, не тот репозиторий) — rc=2 и пустой allow-list. Пустой список значит «не отправлять ничего», никогда «отправить всё».
  • Path-only по построению. Скрипт не открывает файлы, не ищет высокоэнтропийные строки и не пытается быть сканером секретов: сканер, читающий содержимое ради решения, можно ли читать содержимое, уже проиграл.
  • Обычный репозиторий ничего не теряет. Прогон на этом же репо: 5 allowed, 0 withheld. .env.example и родня — в белом списке, их работа как раз в том, чтобы лежать в репозитории.

Дифф

Файл Что
scripts/safe-paths.sh новый — решение о скоупе + --self-test
scripts/review-codex.sh 1 строка: не перечислять рабочее дерево самому
scripts/review-opencode.sh 1 строка: то же
skills/code-review/SKILL.md шаг перед запуском ревьюеров
skills/check-if-done/SKILL.md тот же шаг — он отправляет наружу так же

34 добавленные строки в существующих файлах, остальное — новый скрипт. Логику ревью не трогал.

Как проверено

--self-test поднимает временный репозиторий с src/app.py, .env.local и deploy_rsa и делает три проверки, из них одна позитивная. Позитивная тут не для красоты: скрипт, который прячет вообще всё, проходит обе негативные проверки, и без «обычный файл обязан пройти» тест был бы зелёным на сломанном коде.

Проверил мутациями, что тест действительно ловит:

Мутант Результат
снять deny-правило env-file падают обе негативные проверки
заставить сбор путей возвращать пусто падает позитивная проверка
— (чистый код) self-test OK — 3 checks

Плюс живой прогон на этом репозитории (5 разрешено, 0 исключено) и на несуществующем ref (rc=2, пустой allow-list).

Чего намеренно не делал

  • README не трогал — ты его правишь чаще всего, конфликт был бы гарантирован. Скажи, если нужна строчка, добавлю в том же PR.
  • Драйвер Codex не трогал. Сперва думал, что переход с плагин-рантайма на codex exec потерял накопленные обходы, полез проверять — и нет: в коммите v2 у тебя прямо написано, почему плагинные команды не оркестрируются, а обе гочи самого CLI (кастомный промпт несовместим с флагами скоупа; effort по умолчанию none) уже учтены в комментариях review-codex.sh. Претензия снята, решение обоснованное.
  • Deny-список намеренно короткий и читаемый — литеральные globs, а не один регексп: он рассчитан на то, что человек допишет своё под свой репозиторий, а не будет расшифровывать.

Отдельно, follow-up (в этот PR не тащу)

gh pr view не встречается в плагине ни разу, то есть «отревьюй PR 42» сейчас никак не превращается в скоуп. Наивная версия (main...HEAD) тихо ревьюит не тот дифф в двух случаях: PR открыт против нерелизного base (стек веток, релизные линии) и когда локальный main отстал. Резолв — gh pr view N --json baseRefName,headRefName,headRefOid. Скажи, нужно ли — сделаю вторым PR, мешать с безопасностью не хочу.

Спасибо за плагин, check-if-done — лучшая часть, «модель, только что написавшая код, — худший судья того, работает ли он» стоит того, чтобы быть отдельной командой.

The reviewers were told to list the working tree themselves, untracked files
included. That is the honest description of an uncommitted change and also the
one place a repo keeps .env.local, a stray deploy key, a service-account JSON
and this plugin's own providers.env — so on a private repo the instruction
hands those to an outside model, and by the time any filter could look at the
content, the content is already in somebody else's prompt.

safe-paths.sh settles the scope first, by path name only: it never opens a
file, prints an explicit allow-list plus every withheld path with the rule that
caught it, and fails closed — an unresolvable range allows nothing instead of
degrading into 'review the whole working tree'. Both reviewer prompts now say
not to widen the scope themselves, and both skills run the step before
launching anything.

--self-test covers three checks, one of them a positive control: an ordinary
source file must still come through, or a script that withheld everything would
pass the secret checks too. Verified by mutation — disabling the env rule fails
the negative checks, breaking collection fails the positive one.
@jojoprison

Copy link
Copy Markdown
Contributor Author

Дополняю собственный PR замером, который его же ослабляет.

Открыв PR, я решил проверить, насколько часто класс вообще встречается, и прогнал по всем репозиториям на своей машине: 90 репозиториев, 621 untracked-файл, под deny-правила попало 0. Позитивный контроль пройден — в тестовом репозитории .env.local ловится, то есть ноль настоящий, а не поломка замера.

Что это значит: аргумент про риск в описании выше подан сильнее, чем заслуживает. У дисциплинированного пользователя со зрелым .gitignore секреты в untracked просто не живут, и скрипт исключил бы ровно ноль файлов, оставаясь при этом 244 строками на поддержке. Под твою же линзу на переусложнение он в таком виде попадает.

Чего замер не доказывает: это выборка одного человека. Про типичного пользователя плагина у меня данных нет, а дефолтные бэкенды — бесплатные пулы, куда я как раз ничего не отправляю, так что мой профиль риска не показателен.

Решай с полной картиной. Отказ будет обоснованным — скажи, и закрою сам.

Если решишь брать, есть минимальная версия, и я бы на твоём месте выбрал её: взять только правки в двух промптах (review-codex.sh и review-opencode.sh, по одной строке — не перечислять рабочее дерево самому), а safe-paths.sh и оба SKILL.md оставить за бортом. Две строки, ноль нового кода на поддержке, и бо́льшая часть эффекта: ревьюер перестаёт сам расширять скоуп до всего рабочего дерева, а сужение через --paths у тебя уже есть. Скажи, если так — перепишу PR до этих двух строк.

szarkans added a commit that referenced this pull request Aug 20, 2026
The reviewer prompts used to say "run git status --untracked-files=all and
read what you find". Untracked is where a working tree keeps what was never
meant to travel, so that sentence invited an external model to open a stray
deploy key or a .env nobody remembered to ignore.

Now the orchestrator decides, in code both reviewer scripts already call, and
hands over a finished list. PR #2 proposed the same idea as a separate
210-line script plus a step in the skill markdown; this is the same protection
without a script that can be skipped and without a second copy of the path
collection that already lives in collect-context.sh.

Most of the work turned out to be git's. `--exclude-standard` already drops
everything .gitignore covers, so the rules only ever see files that slipped
past it -- which is exactly how a secret usually escapes.

What the reviews caught, all measured rather than argued:

- Holding shift bypassed every rule: .ENV, ID_RSA, SECRETS.JSON, .NETRC and
  backup.PEM all sailed through. nocasematch works on bash 3.2 and costs no
  subprocess, unlike ${var,,}, which needs bash 4.
- A symlink is judged by where it points, so we refuse to look: an innocent
  notes.txt -> ~/.ssh/id_rsa passed every name rule and the reviewer read the
  target.
- From a subdirectory the note silently dropped every new file elsewhere in
  the repo, under a sentence telling the reviewer not to look for more. That
  was worse than the instruction it replaced. Anchored on the repo root.
- The note ignored --paths while the git diff beside it honoured it, so a
  scoped review disclosed names from the whole tree.
- A git failure printed nothing, and nothing reads as "no new files". It now
  says so out loud.
- Shell metacharacters in a filename reached a model whose tool calls are
  pre-approved. Same guard as --paths, reused.
- Missing names: .git-credentials, .pypirc, .dockercfg, .boto, kubeconfig,
  terraform.tfstate, and my-service-account.json, which the prefix-only rule
  let through.

Deliberately NOT claimed: this is prompt hygiene, not isolation. The reviewers
run inside the real tree with pre-approved tools, so any of them can read a
secret it decides to open. Real isolation means copying the allowed files into
a scratch tree and pointing --dir at that, which is separate work.

Tests: 16 checks, 5 of them positive controls, because a filter that withholds
everything passes every negative check. Four mutations verified each new guard
is actually load-bearing. Green on bash 5.3 and bash 3.2.
@szarkans

Copy link
Copy Markdown
Owner

Привет! Спасибо — проблему ты нашёл настоящую, и она уехала в main коммитом 6a7158e. Но реализацию я сильно урезал, и ты вправе знать почему. Пишу подробно.

Главное: git половину делает бесплатно

Я прогнал замер, прежде чем что-то решать. Оказалось, git ls-files --others --exclude-standardgit status, который выполнил бы ревьюер) уже уважают .gitignore. Игнорируемые файлы надо специально запрашивать флагом --ignored, а его никто не ставит.

Проверка: репозиторий, где в .gitignore лежит .env.local. Этот файл не появляется ни в выводе скрипта, ни в git status. Совсем.

Отсюда неприятный вывод про примеры из описания твоего PR: .env.local в живом проекте почти всегда заигнорен, а providers.env самого плагина живёт в ~/.claude/multi/, вне любого репозитория — git его не увидит никогда.

Но это не отменяет твою находку, а сужает её до самого частого способа слить секрет: файл, который забыли добавить в .gitignore. Забытый deploy_rsa в корне — ровно оно.

Что из этого вышло

Раз git отбирает игнорируемое сам, задача сжалась до одного: список имён, которые не отдаём, даже если они не в .gitignore. Это твой deny_rule — и он единственное, что я взял целиком. Список хороший, я бы сам половину забыл (.pgpass, .docker/config.json, *.jks).

Остальные ~170 строк убрал:

  • Функция collect() почти дословно повторяет блок из collect-context.sh — те же git diff --name-only и git ls-files, то же сужение по --paths, отличаются только имена переменных. Два места с одной логикой расходятся, и это уже случилось (см. ниже).
  • Отдельный скрипт вызывается по инструкции в SKILL.md — то есть шаг можно забыть. Я перенёс фильтр внутрь review-codex.sh и review-opencode.sh, там где промпт и собирается. Забыть больше нельзя.

Итог: две функции в providers.sh, около 40 строк логики.

Два бага в самом PR

1. Пустой список путей роняет скрипт на bash 3.2 (стоковый bash на маке). --paths " " проходит проверку [ -n "$PATHS" ], read -r -a arr даёт пустой массив, а обращение к пустому массиву под set -u в старом bash — ошибка. Воспроизвёл в докере:

/w/safe-paths.sh: line 131: arr[@]: unbound variable
summary: 0 allowed, 0 withheld

Секреты не утекают, но скрипт падает и при этом сообщает код возврата 0, то есть «всё хорошо». Забавно, что рабочая версия этой же проверки лежит в соседнем файле: collect-context.sh пишет [ ${#PATH_ARR[@]} -gt 0 ]. Ты скопировал блок и потерял охрану.

2. Не вызывается multi_check_paths. Это общая проверка путей из providers.sh, она режет ;, `, $ и переводы строк. Её зовут все скрипты — кроме твоего, который как раз и есть граница безопасности.

Что нашлось уже в моём коде

Чтобы не выглядело, будто я тут умнее. Я прогнал на своей версии полное ревью несколькими моделями, и вот что она пропускала:

  • Регистр. .ENV, ID_RSA, SECRETS.JSON, .NETRC, backup.PEM — все проходили насквозь. В bash case по умолчанию учитывает регистр. Чинится через shopt -s nocasematch — он, кстати, работает и на bash 3.2, в отличие от ${var,,}.
  • Симлинки. Файл notes.txt, указывающий на ~/.ssh/id_rsa, проходит любую проверку по имени, а ревьюер читает содержимое ключа. Это касается и твоей версии.
  • Запуск из поддиректории. git ls-files --others показывает только то, что лежит под текущей папкой, а git diff рядом — весь репозиторий. Ревьюер получал неполный список под фразой «больше не ищи». Твоей версии это тоже касается, там тот же вызов.
  • Пропущенные имена: .git-credentials, .pypirc, kubeconfig, terraform.tfstate, и my-service-account.json (правило требовало, чтобы имя начиналось с service-account).

Честно про границы

Твоё описание говорит, что содержимое «здесь не читается никогда». Это правда про сам скрипт, но не про итог. Внешние ревьюеры запускаются внутри репозитория с доступом к шеллу: opencode run --pure --auto --dir ., а codex exec review вообще без флага песочницы. Они могут открыть .env сами, что бы ни было написано в промпте.

То есть и твой фильтр, и мой убирают приглашение («перечисли рабочее дерево и прочитай что найдёшь»), но не убирают возможность. Я это прямо написал в комментарии к коду, чтобы следующий человек не принял его за настоящую изоляцию. Настоящая — копировать разрешённые файлы во временную папку и указывать --dir туда. Отдельная работа, заведу issue.

Что с PR

Оставляю решение за тобой: закрыть как «идея уехала другим коммитом», или ты хочешь довести свою версию. Я не мержил твою ветку, так что авторство коммита моё — если тебе это важно, скажи, придумаем как отразить твой вклад нормально.

И ещё раз: находка твоя, замер лишь показал, где именно она стреляет.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants