{ "index": 103, "slug": "editorial-2025-02-field-ai-code-verification", "title": "Как проверить AI-предложение кода до merge", "excerpt": "Зелёный happy path не доказывает сохранность API и правил доступа. На примере трёх границ разбираем отрицательные тесты, команды проверки и критерий готовности изменения.", "contentHtml": "

AI-ассистент предложил короткое исправление: основной тест прошёл, diff выглядел аккуратно, а запрос без роли всё равно получил доступ к операции. Такой дефект легко пропустить, потому что проверка ответа и проверка права отвечают на разные вопросы. Цена ошибки — не только повторная отладка: при неверной авторизации пользователь может прочитать или изменить чужой ресурс.

\n

Надёжный вопрос перед merge звучит так: какой контракт меняется, где находится граница доверия и каким наблюдением можно опровергнуть предложение? AI не является доказательством. Он ускоряет перебор вариантов, но каждое принятое утверждение нужно проверить в коде, тесте и контексте системы.

\n

Сначала зафиксируйте границу изменения

\n

Начните с diff, а не с объяснения ассистента. Запишите вход, выход, потребителя и правило, которое нельзя нарушить. Для функции доступа это субъект, действие и ресурс; для mapper — форма результата; для функции preview — неизменность входного состояния. Одно предложение может затронуть сразу несколько границ, даже если изменена одна строка.

\n
git diff --check\ngit diff --unified=80 -- src/auth/can-edit.ts\nrg -n 'canEdit|authorize|role|tenant|owner' src test
\n

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

\n

Граница API: правильное число не спасает неправильную форму

\n

Представим mapper, который получает subtotalCents и taxCents. AI заменил поле ответа на total. Арифметика осталась верной, поэтому тест, сравнивающий только число, зелёный. Потребитель читает amountCents и получает undefined. Это не «мелкое переименование»: форма ответа — часть контракта.

\n

Проверяйте ключи и способ чтения результата тем же кодом, который использует приложение. Если изменение имени действительно нужно, сначала меняется контракт и все потребители, а затем тесты. Переписать assertion под новый ключ — не исправление, пока владелец API не принял несовместимое изменение.

\n
function toAmount({ subtotalCents, taxCents }) {\n  return { amountCents: subtotalCents + taxCents };\n}\n\nconst result = toAmount({ subtotalCents: 1000, taxCents: 180 });\nif (JSON.stringify(Object.keys(result)) !== JSON.stringify(['amountCents'])) {\n  throw new Error('response shape changed');\n}\nif (result.amountCents !== 1180) {\n  throw new Error('amount calculation changed');\n}
\n

Этот фрагмент проверяет учебный объект и порядок ключей, заданный самим примером. В production контракт лучше закрепить схемой или типом и тестом реального потребителя; здесь намеренно показана минимальная проверка, которую можно запустить без фреймворка.

\n

Граница состояния: preview не должен незаметно мутировать вход

\n

Вторая ловушка — побочный эффект под безобидным именем. Функция preview должна построить представление, но предложенная реализация присваивает draft.status = 'normalized'. Caller передал собственный объект и после preview ожидает исходный статус. Проверка только строки ответа этого не увидит.

\n

Снимите состояние до вызова и сравните его после. Для простого плоского объекта пример выглядит так:

\n
function preview(draft) {\n  return { ...draft, status: 'normalized' };\n}\n\nconst draft = { id: 'd-17', status: 'new' };\nconst before = JSON.stringify(draft);\nconst view = preview(draft);\n\nif (JSON.stringify(draft) !== before) {\n  throw new Error('preview mutated input');\n}\nif (view.status !== 'normalized' || draft.status !== 'new') {\n  throw new Error('preview contract failed');\n}
\n

Поверхностная копия не защищает вложенные массивы и объекты: если функция меняет draft.items[0], понадобится глубокая копия, иммутабельная структура или отдельный тест на каждый изменяемый уровень. Поэтому нельзя автоматически заменить любой Object.assign на знак качества. Сначала определите владение данными и разрешённые мутации.

\n

Граница доступа: allow-list сильнее списка исключений

\n

Третий пример — проверка роли. Условие actorRole !== 'viewer' блокирует viewer, но пропускает undefined, опечатку и любую новую строку. Happy path для editor и даже отрицательный тест только для viewer не закрывают эту дыру.

\n

Если в учебном контракте разрешена ровно одна роль, разрешённое множество должно быть явным, а неизвестное значение — отклоняться:

\n
function canEdit(actorRole) {\n  const allowedRoles = new Set(['editor']);\n  return allowedRoles.has(actorRole);\n}\n\nfor (const [role, expected] of [\n  ['editor', true],\n  ['viewer', false],\n  [undefined, false],\n  ['edtiro', false],\n]) {\n  if (canEdit(role) !== expected) {\n    throw new Error('unexpected decision for ' + String(role));\n  }\n}
\n

Это тест чистой функции, а не всей авторизации. В реальном запросе нужно проверить, откуда взялась identity, кто выбирает tenant и ресурс, где сервер принимает решение и что происходит при отказе. Проверка роли в браузере может улучшить интерфейс, но не должна выдавать доступ: решающий контроль находится на сервере, шлюзе или серверной функции.

\n

Почему зелёные сигналы расходятся

\n
СигналЧто он доказываетЧего не доказываетСледующее действие
Unit-тест happy pathРазрешённый вход даёт ожидаемый результатОтказ, форма ответа, побочные эффекты и соседний ресурсДобавить отрицательный и contract-тест
Линтер или SASTНайден или не найден известный шаблонБизнес-правило и смысл конкретного ресурсаРазобрать finding вручную по data flow
AI code reviewАссистент сформулировал комментарии в scope reviewПолноту находок и обязательное решение владельцаПроверить комментарии и повторить review после нового diff
Ручной сценарийНаблюдаемое поведение на выбранном окруженииДругие входы, окружения и параллельные entry pointЗакрепить сценарий автоматическим тестом
ApprovalУполномоченный принял решение по известному scopeОтсутствие неизвестных рисковЗаписать остаточный риск и условия отката
\n

Разные статусы не складываются в магическое «всё безопасно». У каждого сигнала есть scope и слепая зона. Например, GitHub описывает Copilot code review как источник комментариев; по умолчанию это не approval, а автоматическая оценка готовности сама по себе не считается обязательным подтверждением. Это важное различие между подсказкой инструмента и полномочием на merge.

\n

Воспроизводимая последовательность перед merge

\n
  1. Опишите инвариант. Запишите точную форму ответа, условие неизменности или пару «субъект — действие — ресурс». Слово «качественно» нельзя проверить.
  2. Определите scope. Перечислите изменённые файлы, входы, выходы, потребителей и серверные точки доступа. Для security-изменения отдельно отметьте trust boundary.
  3. Запустите базовые проверки. Выполните форматтер, typecheck, unit-тесты и git diff --check. Сохраните команды и версии инструментов.
  4. Проверьте разрешённый путь. Сравните значение, тип, форму ответа и побочные эффекты. Тест должен читать результат как реальный потребитель.
  5. Добавьте отрицательные входы. Используйте отсутствующее значение, неверную роль, чужой tenant, другой идентификатор ресурса и отказ downstream — только те варианты, которые входят в модель угроз.
  6. Проследите поток данных. Найдите источник identity, место авторизационного решения и обработку отказа. Не считайте клиентский флаг или скрытую кнопку защитой.
  7. Повторите review по новому diff. После исправления проверяется не старый комментарий, а обновлённый scope. Если сигнал относится к другой версии кода, он не подтверждает текущий merge.
  8. Выберите вердикт. merge означает, что заявленные проверки пройдены; revise — известен следующий фикс; block — есть расхождение, которое должен снять владелец риска.
\n

Минимальный журнал проверки может быть обычным текстом в описании изменения:

\n
scope: src/auth/can-edit.ts, test/auth/can-edit.test.ts\ncontract: only editor may edit a resource in the actor's tenant\npositive: editor + own tenant -> allow\nnegative: viewer, missing role, other tenant -> deny\nside_effects: request context unchanged\nchecks: npm test -- can-edit && git diff --check\nowner: team-auth\nverdict: revise
\n

Строка verdict: revise здесь не формальность: если не проверен чужой tenant, честный результат — не merge. Журнал не заменяет тест и не делает проект безопасным, но оставляет проверяемую связь между риском, наблюдением и решением.

\n

Что делать при расхождении evidence

\n

Если форма API не совпала с потребителем, остановите подготовку merge и найдите владельца контракта. Если preview мутирует данные, зафиксируйте вход до и после, затем выберите иммутабельный результат или явно названную мутацию. Если запрос без роли или с чужим tenant получает доступ, блокируйте изменение до серверной проверки и негативного теста.

\n

Stop, revert и rollback — разные действия. Stop не меняет историю и лишь запрещает продолжать merge. Revert создаёт новое изменение, отменяющее конкретный коммит. Rollback возвращает уже доставленную систему к прежнему состоянию и требует отдельного плана для данных, миграций, флагов и совместимости. Не называйте блокировку rollback: это создаёт ложное ощущение, что восстановление уже подготовлено.

\n

Ограничения применимости

\n

Примеры выше — небольшие чистые функции, а не доказательство безопасности приложения. Они не проверяют токены, identity provider, срок сессии, CSRF, rate limit, кеши, гонки, multi-tenant запросы, базу данных, конфигурацию шлюза или эксплуатационные права. Даже полный набор unit-тестов не исключает дефект в маршруте, middleware или другом entry point.

\n

Allow-list подходит для показанного правила с одной ролью, но не заменяет policy engine для сложных правил, где важны атрибуты ресурса, отношения владельца, время и состояние. Для таких систем тестируйте таблицу разрешений и отказов на уровне API и интеграции. Секреты, персональные данные и production-токены нельзя помещать в prompt или учебный репозиторий; используйте обезличенные фикстуры.

\n

Официальные рекомендации также не являются сертификатом. OWASP говорит, что ручной security-review дополняет автоматические инструменты и особенно нужен для бизнес-логики, потоков данных и контекстных уязвимостей. NIST SSDF задаёт общий набор практик безопасной разработки и общий словарь, но не выбирает контракт конкретного сервиса. Эти документы помогают построить процесс, а границы и остаточный риск всё равно определяет команда.

\n

Критерий готовности

\n

AI-предложение готово к merge, когда можно независимо ответить на четыре вопроса: какой контракт оно меняет; какой позитивный и какой отрицательный путь проверены; где выполняется окончательная проверка доступа; какой владелец принял остаточный риск. К ответам приложены текущий diff, воспроизводимые команды и результат тестов.

\n

Если хотя бы один вопрос остаётся без наблюдаемого ответа, меняйте статус на revise или block. Такой вердикт не обвиняет инструмент и не требует отказаться от AI. Он просто оставляет merge только для изменения, чья граница, проверка и ответственность видны.

\n
\"Схема
Каждый этап отвечает на свой вопрос. При расхождении scope подготовку merge останавливают и формулируют вопрос владельцу риска; сама подсказка AI не становится разрешением на доставку.
\\n

Проверяемые источники

\n" }