22 KiB
Код-ревью #556 · заход r1
Материал: 954eeff45aeb187f74d460d35426ffec7299ca7b (origin/dev = 8fa2568c…,
два коммита автора поверх; ветка приведена конвейером к dev до ревью — ещё
три коммита dev легли сверху, поэтому разбор полный, а не по дельте, как и
предписано, хотя это всё равно первый заход).
Инфраструктурная задача (§1 PROCESS.md): ни одного файла класса A в диффе —
только .github/workflows/**, scripts/**, test/**, docs/DEVELOPMENT.md.
Track: infra, без спека и без S1…S6.
Скоуп
Три заявленных куска: (1) все 116 uses: в 9 воркфлоу закреплены полным SHA
- гард
scripts/action-pins.mjs; (2)permissions:переехали с уровня workflow на уровень job вprocess.yml; (3) граница «результат модели → привилегированная публикация» вынесена из inline-shell вscripts/review-result-gate.mjsс 15 враждебными фикстурами. Всё это — ответ на пункт audit-2026-09-12 §10, поэтому J-строкуdocs/SCOPE.mdне проверяю: инфраструктура, продукт не меняется.
Как проверялось
CI на точном материале зелёный: Validate
run 34768468977
(headSha = 954eeff4…, conclusion: success) покрывает preflight
(включая новый шаг action_pins), frontend (typecheck/unit/build/bundle-sync),
hacs, hassfest, все 6 шардов diff-мутантов. backend/geometry_parity/
golden/smoke/performance_smoke в этом прогоне skipped — ожидаемо: diff
не трогает src/**, custom_components/**/*.py и геометрию, "heavy" не
запускается на обычном push. Поэтому по правилу «дешёвые гейты этого SHA уже
подтверждены» я не перегонял tsc/test/build; см. раздел «Чего не
проверял» — что перегнал сам и почему.
Прогнано мной дополнительно (сверх зачтённого CI):
| Команда | Результат |
|---|---|
node --test test/review-result-gate.test.mjs |
15/15 green |
node --test test/action-pins.test.mjs |
7/7 green |
node --test test/check-inputs.test.mjs |
13/13 green (в т.ч. §5.5 coverage) |
node scripts/action-pins.mjs (по текущему дереву) |
«все сторонние Actions закреплены полным SHA» |
python3 -c "yaml.safe_load(...)" по всем 10 .github/workflows/*.yml |
все распарсились без ошибок |
gh api repos/<owner>/<repo>/commits/<sha> для checkout@v7, setup-node@v7, claude-code-action@v1, hacs/action, home-assistant/actions/hassfest |
каждый пин — реальный, существующий коммит целевого репозитория (для двух подвижных — hacs/action@main, hassfest@master — совпадает с текущей головой ветки, что соответствует «прочитано в этот день») |
node scripts/mutation-gate.mjs --id=<mutant> для всех 4 новых/переанкеренных мутантов (review-gate-accepts-a-foreign-passport, review-gate-tolerates-an-extra-file, action-pins-accept-a-moving-ref, review-integration-skips-evidence-checksum) |
все 4: «поймано 1 из 1» — тест краснеет на снятой защите |
| Живая проверка границы permissions (см. находку H1 ниже) | см. ниже |
Находки
H1 (High). Границу «модель не пишет в issue» PR не строит — permissions: per-job не влияет на реальный write-токен модели
Заявлено (issue #556, приёмка + хендофф): «Недоверенная стадия больше не
может писать в issue»; model_review получает только contents: read и
id-token: write — подтверждено чтением .github/workflows/process.yml
(строки job model_review) и python3 -c 'yaml.safe_load(...)':
model_review => {'contents': 'read', 'id-token': 'write'}
Проблема. id-token: write в этой job существует не для GITHUB_TOKEN, а
для того, чтобы anthropics/claude-code-action обменял OIDC-токен рана на
собственный GitHub App installation token через
https://api.anthropic.com/api/github/github-app-token-exchange
(src/github/token.ts, setupGitHubToken(), прочитан на закреплённом ровно
в этом диффе SHA 9cdae7f0d995e3ba7c33f226087fdf82a59cd520):
const DEFAULT_PERMISSIONS: Record<string, string> = {
contents: "write",
pull_requests: "write",
issues: "write",
};
...
const permissions = parseAdditionalPermissions(); // undefined, если
// ADDITIONAL_PERMISSIONS не задан — а `additional_permissions:` в этом
// step вообще не используется (grep по process.yml — ноль совпадений)
...
process.env.GITHUB_TOKEN = githubToken; // "Set GITHUB_TOKEN and GH_TOKEN in
// process env for downstream usage"
Этот обмен не читает и не может прочитать job-level permissions: из
process.yml — в теле запроса к серверу обмена передаётся permissions из
ADDITIONAL_PERMISSIONS, которого здесь нет. Значит выданный токен несёт
дефолт contents: write, pull_requests: write, issues: write — то есть
больше, чем job вообще когда-либо декларировала на уровне workflow (было
issues: write, стало и contents: write тоже, только теперь это скрыто за
уровнем App, а не workflow YAML). docs/security.md того же репозитория
прямо это подтверждает: «The Claude Code GitHub app requests… Contents (Read &
Write)… Issues (Read & Write)» — это фиксированный грант приложения, не
интерсекция с permissions: вызывающей job.
Живое доказательство, не чтение. Эта самая ревью-сессия — и есть job
model_review для #556 на этом материале. Проверено прямо в её окружении:
$ env | grep -i GITHUB_TOKEN
GITHUB_TOKEN=ghs_...
$ env | grep -i GH_TOKEN
GH_TOKEN=ghs_...
$ gh auth status
✓ Logged in to github.com account claude[bot] (GH_TOKEN)
Git operations protocol: https
ghs_… — префикс именно installation-токена GitHub App, авторизован как
claude[bot], доступен в окружении Bash-инструмента модели напрямую (не
только внутри MCP-сервера) — то есть модель может выполнить git push или
gh issue comment этим токеном без какого-либо участия integrate. Job
permissions: {contents: read, id-token: write} не помешала ни его выдаче,
ни его присутствию в окружении, доступном Bash.
Итог: заявление «модель не пишет в issue» и AC «минимизировать доступные
permissions/secrets» для стадии model_review не выполнены — они
выполнены только для гипотетических шагов, которые использовали бы голый
${{ github.token }} (например, будущий actions/github-script без
github-token:), но не для того канала, которым модель реально
взаимодействует с GitHub (MCP-инструменты claude-code-action, тот же
App-токен, доступный и Bash). --allowedTools в этой же job по-прежнему
перечисляет mcp__github__add_issue_comment,mcp__github__issue_write, mcp__github__issue_read — то есть путь не только теоретический, а
разрешённый явно.
Это не регресс, внесённый именно этим диффом (тот же механизм действовал и
до него, с #551) — но это ровно тот вектор, который #556 по формулировке
issue обязан закрыть («PAT не объявлен непосредственно в env модели» —
верно буквально для PAT, но GH_TOKEN/GITHUB_TOKEN App-токена в env
модели присутствует, и это то же самое по факту достижимости).
Что делать. Один из двух путей, оба дёшевы:
- Передать в
with:шагаReviewявныйgithub_token: ${{ secrets.GITHUB_TOKEN }}(документация действия:allowed_non_write_users/security.md подтверждают, что явныйgithub_tokenзаставляет действие использовать ambient, job-scoped токен вместо App-обмена — «auto-generated workflow token is scoped to the job's declared permissions»). Тогдаcontents: readреально станет потолком. - Либо передать
additional_permissions: "contents: read\nissues: write\npull_requests: none"явно и подтвердить, что сервер обмена его реально сужает (а не только расширяет сверх дефолта) — это нужно проверить отдельно, путь (1) надёжнее и очевиднее по документации.
Без одного из этих шагов пункт «2. Права по job» хендоффа не защищает
ничего нового по сравнению с тем, что было: contents/issues write у
модели остаются доступны через App-токен независимо от блока permissions:.
Что проверено и корректно
- SHA-пины. Все 116
uses:закреплены;scripts/action-pins.mjsпроверил вживую на дереве — 0 находок. Каждый пин дляcheckout@v7,setup-node@v7,claude-code-action@v1сверен черезgh api repos/.../commits/<sha>— существующий, достижимый коммит; дляcheckout/setup-nodeсовпадает буквально с текущей головой тегаv7.claude-code-action@9cdae7f0…— коммит существует в истории репозитория (не совпадает с текущимv1, который уехал дальше — это ожидаемо для пина, не находка).hacs/action@mainиhome-assistant/actions/hassfest@masterсовпадают с текущей головой соответствующих подвижных веток — согласуется с комментарием «main@2026-09-13»/«master@2026-09-13» (дата чтения, не тег). Локальная reusable workflow./.github/workflows/announce.ymlисключена верно (isLocal), других местuses: ./в репозитории нет. - Гейт закреплён в Validate.
action_pins— реальный шаг preflight,continue-on-error: true, его исход собирается в общий вердикт вместе с остальными четырьмя (check "пины сторонних Actions" "$ACTION_PINS");test/action-pins.test.mjsиtest/validate-workflow.test.mjsпроверяют это по тексту воркфлоу, а не только «шаг существует». - check-inputs.mjs согласован с новым гейтом. Удалённая запись
NOT_AN_INPUTдля.github/workflows/*.ymlбыла верна, покаaction-pins.mjs/preflight не читали остальные воркфлоу; теперь читают — запись стала бы ложной.node --test test/check-inputs.test.mjsзелёный, включая §5.5 coverage (ни одного неизвестного исполняемого файла, ни одной лишней записиNOT_AN_INPUT) — то есть покрытие сошлось не по совпадению, а по замыканию (проверено чтением механизмаclosure()/referencesOf(), не только прогоном). - Граница artifact → integrate.
scripts/review-result-gate.mjs— вменяемая, полная реализация: точный набор файлов (не «не меньше»), контрольные суммы каждого файла, каждое из 17 полей паспорта (кромеrun_id/run_attempt, которые по праву приходят изGITHUB_*) сверяется индивидуально, словарь вердикта и типы полей проверяются. 15 тестовtest/review-result-gate.test.mjsцелятся именно в эти проверки по отдельности (в т.ч. явный цикл по всем 17 полям паспорта, не выборка).test/review-doc-guard.test.mjsнезависимо проверяет, чтоintegrateреально вызывает этот скрипт (а не полагается на память) и передаёт ему все нужные ENV. Четыре мутанта (review-gate-accepts-a-foreign-passport,review-gate-tolerates-an-extra-file,review-integration-skips-evidence-checksum,action-pins-accept-a-moving-ref) прогнаны мной индивидуально —node scripts/mutation-gate.mjs --id=<...>— все четыре: «поймано 1 из 1». Это ровно то доказательство «тест умеет падать», которого требует §2.7. - Уборка старого inline-shell. Старый разбор
sha256sum -c/jq -eизprocess.ymlполностью заменён вызовомreview-result-gate.mjs; ничего не осталось задвоенным между YAML и скриптом (grep поjq -eв блокеintegrateпосле правки — пусто). permissions:дляguard/prepare/integrate. Эти три job реально используютGH_TOKEN: secrets.HP_PROCESS_TOKEN(PAT) для привилегированных операций — здесь job-levelpermissions:действительно ортогонален PAT (PAT не зависит отpermissions:вообще, это отдельный секрет), но сужение ambient-токена этих job всё равно корректный defense-in-depth шаг для всего, что могло бы неявно взять${{ github.token }}, — само по себе не находка, просто менее значимо, чем казалось из хендоффа.- Синтаксис. Все 10
.github/workflows/*.yml(включая нетронутыйnightly.yml) парсятсяpython3 yaml.safe_loadбез ошибок;nightly.ymlне тронут заслуженно — в нём нет ни одногоuses:. - Документация.
docs/DEVELOPMENT.mdописывает процедуру обновления пина корректно и без противоречий сaction-pins.mjs(командаgh api repos/<owner>/<repo>/commits/<tag>, обе подвижные ссылки поименованы с причиной отсутствия тегов). - Трейлеры. Оба коммита
b3555188,954eeff4—User-Visible: no(проверено чтениемgit log), правок в changelog не требуется и нет; соответствует инфраструктурному характеру задачи.
Чего не проверял
npx tsc --noEmit,npm test,npm run build+сверка бандлов — не перегонял: Validate на точном материале (954eeff4) зелёный,frontendjob их покрывает (см. таблицу выше). Дешёвые гейты этого захода уже подтверждены.golden:verify,demo/smoke_*,pytest tests_backend,model-invariants, performance-профили — не прогонял и не требовалось: diff не касаетсяsrc/**,custom_components/**/*.pyни одной строкой, не меняет геометрию/layout/marker.space; в Validate эти job закономерноskipped.check-docsпо той же причине не запускал:src/**не тронут, отпечаток скриншотов не мог устареть от этого диффа (то, что он уже красный наdevдо ветки, — предсуществующий, отдельный от #556 факт, автор его верно вынес как замечание не в скоуп, заводить отдельный issue не требуется — сам факт уже зафиксирован в комментарии автора, issue заводить избыточно, покаdevне начнёт публиковать релиз).- Реальный интерсект
additional_permissionsс серверной стороной обмена токена — не проверял: сервис обмена (api.anthropic.com) недоступен для инспекции извне, вывод H1 построен на клиентском коде действия (открытый исходник, зафиксированная версия) и на прямом наблюдении токена в этой же среде, не на предположении о серверной логике. - Не пытался использовать App-токен деструктивно (например,
git pushв чужую ветку/dev) — это вышло бы за пределы мандата ревьюера и было бы само по себе рискованным действием; доказательства изenv/gh auth status/чтенияtoken.tsна закреплённом SHA достаточно для вывода без такого шага.
Вердикт
H1 блокирует: центральное заявление задачи («модель изолирована от
привилегированной публикации», в частности — не пишет в issue) не
подтверждается для фактического канала записи, которым модель пользуется
(App-токен claude-code-action, а не ambient GITHUB_TOKEN). Остальные два
куска скоупа (SHA-пины, граница artifact→integrate) реализованы корректно и
доказаны — described mechanisms, defensive AC и мутанты все подтверждены
исполнением, а не чтением.
Возврат автору: добавить github_token: (или подтверждённый
additional_permissions:) к шагу Review, чтобы permissions: {contents: read, id-token: write} действительно стало потолком возможностей модели, а
не косметикой поверх App-обмена, который его игнорирует.
Материал раунда: ветка issue/556-ci-trust-boundaries, HEAD
954eeff45aeb187f74d460d35426ffec7299ca7b, tree проверен на этом же коммите
(git rev-parse HEAD в рабочей копии совпадает с SHA материала, указанным в
метке задачи). Первый заход, раздел «Унаследовано» не требуется.
Материал раунда
- Ветка:
issue/556-ci-trust-boundaries, коммит954eeff45aeb— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
1d5e16368626f4533432d6e3251a8f0930e48980git log --all --format='%H %T' | grep 1d5e16368626 - Тело issue:
0fae31d317a2b03af2c7f61185acc7eb55eebe03858995b3549aa28aa5ff487d - Вердикт конвейера:
red· High 1