Files
houseplan-card/scripts/review-result-gate.mjs
Codexandclaude[bot] b35551884f Сторонние Actions закреплены SHA, права выданы по job, граница проверяется фикстурами
Три вещи, которые аудит 12.09 назвал в §10.

**Перемещаемые ссылки.** `home-assistant/actions/hassfest@master` и
`hacs/action@main` — это произвольный будущий коммит чужой ветки, а ревьюера с
Read/Write/Bash запускал перемещаемый major `anthropics/claude-code-action@v1`.
Все 116 `uses:` в девяти воркфлоу закреплены полным SHA с комментарием-версией;
`scripts/action-pins.mjs` это проверяет, а предполётный вердикт Validate —
исполняет. Локальная переиспользуемая workflow пина не требует и исключена
явно.

**Права.** Один блок `permissions` на весь конвейер выдавал `issues: write` и
OIDC каждой стадии, включая единственную недоверенную — работу модели. Теперь
права выдаются по job: модели только чтение и OIDC для самой
`claude-code-action`, писать в issue умеют детерминированные стадии.

**Граница.** Разбор запечатанного результата переехал из inline-shell в
`scripts/review-result-gate.mjs` — не ради красоты, а потому что в YAML его
нельзя прогнать ни одним отрицательным случаем. Проверяются те же вещи, что и
раньше, и в том же объёме: точный набор файлов, контрольные суммы, схема
паспорта и совпадение КАЖДОГО из семнадцати полей с тем, что посчитала
детерминированная стадия. Сверху — пятнадцать враждебных фикстур: неполный
набор, лишний файл, подменённое содержимое, чужой run и попытка, устаревший
material_sha и tree, чужие задача, этап, раунд и ветка, вердикт вне словаря,
пустой документ, manifest не о тех файлах, неразбираемый JSON.

Настоящих секретов и привилегированных операций фикстуры не трогают.

Issue: #556
User-Visible: no
2026-09-13 16:24:10 +00:00

127 lines
6.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Граница доверия между стадией модели и привилегированной публикацией (#556).
*
* `model_review` — единственная недоверенная стадия конвейера: там исполняется
* чужой код с Read/Write/Bash. Всё, что она может передать дальше, — один
* artifact. Публикация разбора, перестановка метки и слияние в `dev` идут в
* другой job, на чистом checkout `dev`, и обязаны принимать этот artifact как
* недоверенный ввод: полный набор файлов, сходящиеся контрольные суммы и
* совпадение КАЖДОГО поля паспорта с тем, что посчитала детерминированная
* стадия `prepare`. Подменённый, неполный, устаревший или чужой результат
* отвергается fail-closed.
*
* Проверка вынесена из inline-shell в функцию именно ради враждебных фикстур:
* в YAML её нельзя прогнать ни одним отрицательным случаем.
*
* node scripts/review-result-gate.mjs --dir=<путь> # поля ожидания из env
*/
import { createHash } from 'node:crypto';
import { readFileSync, readdirSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
/** Ровно эти файлы и ни одного больше: лишний файл — это уже чужой artifact. */
export const REQUIRED_FILES = ['manifest.sha256', 'prepared.json', 'review-document.md', 'verdict.json'];
/** Паспорт материала. Каждое поле сверяется с outputs стадии `prepare`. */
export const PASSPORT_FIELDS = [
'run_id', 'run_attempt', 'issue', 'stage', 'cycle', 'branch',
'material_sha', 'material_tree', 'material_specs', 'material_issue_body',
'validate_result', 'validate_url', 'rebase_note', 'validated_note',
'spec_body_changed', 'spec_body_doc', 'spec_body_recorded',
];
export const VERDICTS = ['green', 'yellow', 'red'];
export const sha256 = (text) => createHash('sha256').update(text).digest('hex');
/** Разбор строки `sha256sum`: «<hex> <имя>». */
export function parseManifest(text) {
const rows = [];
for (const line of String(text).split('\n')) {
if (!line.trim()) continue;
const match = /^([0-9a-f]{64})\s[\s*](.+)$/.exec(line);
if (!match) return null;
rows.push({ hash: match[1], name: match[2].trim() });
}
return rows;
}
/**
* @param {object} p
* @param {string[]} p.files имена файлов в каталоге artifact
* @param {(name:string)=>string} p.read содержимое файла
* @param {Record<string,string>} p.expected паспорт, посчитанный `prepare`
* @returns {string[]} причины отказа; пустой массив — принять
*/
export function reviewResultProblems({ files, read, expected }) {
const problems = [];
const actual = [...files].sort();
const wanted = [...REQUIRED_FILES].sort();
if (actual.join(',') !== wanted.join(',')) {
return [`набор файлов не тот: ожидалось ${wanted.join(', ')}, получено ${actual.join(', ') || '(пусто)'}`];
}
const manifest = parseManifest(read('manifest.sha256'));
if (!manifest) return ['manifest.sha256 не разобран'];
const covered = manifest.map((row) => row.name).sort();
const mustCover = REQUIRED_FILES.filter((name) => name !== 'manifest.sha256').sort();
if (covered.join(',') !== mustCover.join(',')) {
problems.push(`manifest покрывает не те файлы: ${covered.join(', ') || '(пусто)'}`);
}
for (const row of manifest) {
if (!REQUIRED_FILES.includes(row.name)) continue;
const digest = sha256(read(row.name));
if (digest !== row.hash) problems.push(`контрольная сумма не сходится: ${row.name}`);
}
if (!String(read('review-document.md')).trim()) problems.push('документ ревью пуст');
let prepared;
try {
prepared = JSON.parse(read('prepared.json'));
} catch (error) {
return [...problems, `prepared.json не разобран: ${error.message}`];
}
if (prepared?.schema !== 1) problems.push('prepared.json: не та схема');
for (const field of PASSPORT_FIELDS) {
const want = expected[field] ?? '';
const got = prepared?.[field] ?? '';
if (String(got) !== String(want)) {
problems.push(`паспорт не совпал: ${field} = «${got}», ожидалось «${want}»`);
}
}
let verdict;
try {
verdict = JSON.parse(read('verdict.json'));
} catch (error) {
return [...problems, `verdict.json не разобран: ${error.message}`];
}
if (!VERDICTS.includes(verdict?.verdict)) problems.push(`verdict вне словаря: ${JSON.stringify(verdict?.verdict)}`);
if (typeof verdict?.high !== 'number') problems.push('verdict.high не число');
if (typeof verdict?.medium !== 'number') problems.push('verdict.medium не число');
if (typeof verdict?.summary !== 'string') problems.push('verdict.summary не строка');
return problems;
}
const invokedDirectly = process.argv[1]
&& resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (invokedDirectly) {
const dir = process.argv.find((a) => a.startsWith('--dir='))?.slice(6);
if (!dir) { console.error('usage: review-result-gate.mjs --dir=<путь>'); process.exit(2); }
const env = (name) => process.env[name.toUpperCase()] ?? '';
const expected = Object.fromEntries(PASSPORT_FIELDS.map((field) => [field, env(field)]));
expected.run_id = process.env.GITHUB_RUN_ID ?? '';
expected.run_attempt = process.env.GITHUB_RUN_ATTEMPT ?? '';
const problems = reviewResultProblems({
files: readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile()).map((e) => e.name),
read: (name) => readFileSync(resolve(dir, name), 'utf8'),
expected,
});
if (problems.length) {
for (const problem of problems) console.error(`::error::результат модели отвергнут — ${problem}`);
process.exit(1);
}
console.log('результат модели полон, суммы сходятся, паспорт совпал');
}