ci: гейты наблюдаемых свойств перед publish — image-smoke, migration-order на push, allowlist fail-closed, property-тесты (#476) #477
Open
agent_vscode
wants to merge 2 commits from
ci/476-publish-gates into develop
pull from: ci/476-publish-gates
merge into: vvzvlad:develop
vvzvlad:main
vvzvlad:feat/488-client-fsm
vvzvlad:feat/489-tool-resilience
vvzvlad:feat/487-terminal-lifecycle
vvzvlad:fix/486-iter1-prod-fires
vvzvlad:develop
vvzvlad:feat/481-version-coherence
vvzvlad:refactor/347-client-md-paste
vvzvlad:perf/479-getpage-cache
vvzvlad:refactor/449-write-invariants
vvzvlad:refactor/450-split-client
vvzvlad:feat/443-get-page-context
vvzvlad:feat/443-get-tree
vvzvlad:feat/443-search
vvzvlad:docs/415-lossy-descriptions
vvzvlad:feat/413-markdown-default
vvzvlad:refactor/412-camelcase-tool-names
vvzvlad:feat/425-drawio-graph
vvzvlad:fix/464-diffdocs-cpu-guard
vvzvlad:refactor/411-update-page-markdown
vvzvlad:feat/424-drawio-rules
vvzvlad:fix/409-invalid-node-validation
vvzvlad:refactor/448-generate-inventory
vvzvlad:refactor/445-execute-mapping
vvzvlad:refactor/446-derive-client-types
vvzvlad:fix/447-registry-stamp-ci
vvzvlad:fix/444-agent-loop-guards
vvzvlad:fix/396-sendnow-detached-run
vvzvlad:fix/452-immutable-cache-control
vvzvlad:docs/footnote-authoring-comment-cleanup
vvzvlad:feat/437-error-diagnostics
vvzvlad:fix/442-cursor-pagination
vvzvlad:feat/419-footnote-normalize
vvzvlad:perf/399-comment-resolve-async
vvzvlad:perf/435-collab-token-cache
vvzvlad:perf/344-background-rerenders
vvzvlad:perf/348-backend-lowhanging
vvzvlad:feat/423-drawio-crud
vvzvlad:perf/401-collab-hotpath
vvzvlad:feat/430-live-reconnect
vvzvlad:perf/400-collab-session
vvzvlad:refactor/414-dedup-node-ops
vvzvlad:feat/417-new-comments-signal
vvzvlad:feat/408-createcomment-hints
vvzvlad:feat/407-persist-tool-errors
vvzvlad:feat/420-footnote-render
vvzvlad:dummy-review/mcp-hang-fix
vvzvlad:dummy-review/mcp-hang-base
vvzvlad:feat/381-resumable-sse-pr1
vvzvlad:feat/git-sync-2
vvzvlad:docs/agents-workspace-build-order
vvzvlad:feature/offline-sync
vvzvlad:perf/342-code-splitting
vvzvlad:perf/346-compression-cache
vvzvlad:feat/196-multi-cursor
vvzvlad:feat/370-page-versioning
vvzvlad:perf/343-typing-latency
vvzvlad:docs/how-to-test
vvzvlad:fix/ai-sdk-partial-output-oom
vvzvlad:test/351-generative-converter
vvzvlad:feat/371-roles-catalog
vvzvlad:refactor/345-server-converter
vvzvlad:refactor/294-spec-registry-cont
vvzvlad:fix/363-migration-order
vvzvlad:fix/e2e-callout-and-gate-build
vvzvlad:fix/docker-re2-toolchain
vvzvlad:feat/git-sync
vvzvlad:fix/media-roundtrip-stability
vvzvlad:fix/340-comment-panel-perf
vvzvlad:fix/332-deferred-tools
vvzvlad:fix/329-ephemeral-suggestions
vvzvlad:fix/330-search-in-page
vvzvlad:fix/328-resolved-anchor-spam
vvzvlad:fix/331-intraline-diff
vvzvlad:fix/324-coverage-gate
vvzvlad:fix/325-mobile-390
vvzvlad:feat/293-A-git-sync-package
vvzvlad:feat/300-avatar-oklch
vvzvlad:fix/321-banner-mobile
vvzvlad:feat/300-avatar-colors
vvzvlad:feat/315-comment-suggestions
vvzvlad:feat/scroll-restore-stable-wait
vvzvlad:feat/300-agent-avatar-stack
vvzvlad:feat/300-avatar-polish
vvzvlad:refactor/294-tool-spec-registry
vvzvlad:feat/scroll-restore-ux
vvzvlad:fix/responsive-tablet-sidebar
vvzvlad:feature/ai-chat-page-change-observability
vvzvlad:image-inline-center
vvzvlad:fix/283-short-remap-title
vvzvlad:fix/283-slash-layout
vvzvlad:image-inline-row
vvzvlad:feat/276-ai-chat-dock
vvzvlad:fix/269-table-menu-refocus
vvzvlad:docs/dev-stand-guide
vvzvlad:feat/266-scroll-position
vvzvlad:fix/260-collab-docname-slugid
vvzvlad:test/244-phase2-tail
vvzvlad:fix/262-reindex-progress-realtime
vvzvlad:fix/258-changelog-compare-links
vvzvlad:fix/244-dataloss-bugs
vvzvlad:feat/246-spoiler
vvzvlad:feat/221-image-captions
vvzvlad:test/244-part-b
vvzvlad:feat/251-intentional-clear
vvzvlad:fix/embeddings-reindex-progress
vvzvlad:refactor/193-tool-spec-registry
vvzvlad:fix/255-ws-redis-adapter-leak
vvzvlad:fix/252-e2e-open-handles
vvzvlad:feat/229-catalog-yaml
vvzvlad:feat/243-blob-sandbox
vvzvlad:feat/228-inline-footnotes
vvzvlad:fix/qa-ui-bugs-216-218
vvzvlad:feature/agent-roles-catalog
vvzvlad:fix/share-alias-rename
vvzvlad:fix/ai-chat-empty-render
vvzvlad:feat/191-chat-doc-binding
vvzvlad:feat/201-temporary-notes
vvzvlad:feat/198-interrupt-agent
vvzvlad:feat/ai-chat-full-history
vvzvlad:feat/199-ai-generate-title
vvzvlad:feat/205-share-aliases
vvzvlad:batch/issues-189-187-170
vvzvlad:feat/170-mcp-test-button
vvzvlad:feat/189-context-badge
vvzvlad:feat/198-interrupt-agent-send-now
vvzvlad:fix/issues-190-159
vvzvlad:fix/ai-chat-new-chat-during-stream
vvzvlad:fix/ai-chat-stream-perf
vvzvlad:batch/issues-2026-06-25
vvzvlad:feat/ai-chat-persistent-history
vvzvlad:fix/ai-chat-copy-chat-wysiwyg
vvzvlad:fix/ai-stream-reset-resilience
vvzvlad:fix/ai-stream-undici-timeout
vvzvlad:fix/footnote-review-1227-followup
vvzvlad:fix/ai-chat-token-counter-realtime
vvzvlad:docs/manual-qa-test-plan
No Reviewers
Labels
Clear labels
epic
needs-human
review/approved
review/changes-requested
review/needs
возможно, баг
Large multi-phase effort spanning many changes
эскалация: нужно решение человека
в последнем ревью нет открытых blocking-находок
последнее ревью оставило открытые blocking-находки
head не ревьюился (head != reviewed_head)
Похоже на баг, но требует подтверждения/воспроизведения; чиним, если повторится
No Label
review/approved
Milestone
No items
No Milestone
Projects
Clear projects
No project
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: vvzvlad/gitmost#477
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "ci/476-publish-gates"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #476.
Ретроспектива мержей 22.06–10.07 выявила повторяющийся класс промаха: локальная логика верна, интеграционное свойство не проверено (#361, #353, #452, #172, #435). PR добавляет четыре гейта, чтобы каждый из этих классов падал до push образа
:develop.1. Boot-smoke Docker-образа в
publish(develop.yml+scripts/ci/image-smoke.sh)Образ, который тянет watchtower, раньше нигде не запускался (e2e гоняются из исходников). Теперь в
publishперед push: build сload: true(кэш-хит из параллельного build-job), запуск контейнера с--network hostпротив services postgres/redis, проверки:/api/health≤120s — стартовый мигратор + boot внутри образа (#361-boot, #353 runtime);POST /api/auth/setup— живой API+DB путь;GET /содержит/assets/— клиентский dist реально внутри образа;/assets/*.js:cache-control: …immutable(#452) +content-encoding: br(пре-компрессия доехала до образа).Fail = образ не пушится. Контейнер сохраняется на failure для
docker logsв отдельном шаге.2.
migration-orderтеперь и на push (test.yml)Прямой push в develop обходил PR-only гейт (дыра из ретроспективы). База сравнения: PR — base branch (как раньше); push —
github.event.before; нулевой SHA (создание ветки) → skip; force-push (before недостижим после refetch) → fail-closed с указанием прогнать workflow_dispatch после ручной проверки.3. Allowlist внешних MCP — fail-closed (#172 class)
tool_allowlist→[](deny-all) +logger.errorвместоnull(«все тулы разрешены») + warn;[]сохраняется как jsonb[](deny-all выразим) черезjsonbBind(…, { preserveEmpty: true })— opt-in флаг, остальные jsonb-колонки не затронуты;Array.isArray(allow) ? pick(raw, allow) : raw— убран.length > 0escape, тихо расширявший deny-all до allow-all;nullпри пустом поле тегов — существующие «неограниченные» серверы не сужаются молча при любом сохранении (блокер первого ревью, исправлен); DTO аннотированыstring[] | null.Покрытие двухуровневое (задокументировано в спеке): int-spec репозитория на реальном PG (round-trip
[], corrupt→[]+error) + unit-спек фильтрации тулсета (null→всё,["alpha"]→только alpha,[]/corrupt→ноль).4. Два property-теста на классы тихой деградации
packages/mcp/test-e2e.mjs, секция 6h):exportPageMarkdown → importPageMarkdown → exportPageMarkdownbyte-identical на нетривиальном контенте (headings/списки/код/таблица/callout);collab-session.test.mjs): свежий JWT на каждый вызов + токен-кэш → 1 connect; негативный контроль с TTL=0 → 2 connect'а — документирует, почему токен-кэш (#439) обязателен для хита кэша сессий (урок инцидента #435: тесты с фиксированным токеном были зелёными при hit-rate 0% в проде).Прогоны
tsc --noEmitчист;bash -nsmoke-скрипта, YAML-валидность обоих workflow,node --checke2e — ок.ai-mcp-server-repo.int-spec.ts) локально не гонялся (нет PG) — уйдёт в CItest:int.vite-plugin-compression2реально эмитит.brдля/assets/*.js,@fastify/staticсpreCompressedотвечаетcontent-encoding: brи на HEAD (проверено по исходникам зависимости).Ревью
Два раунда: первый нашёл блокер (форма слала
[]→ deny-all при новой семантике), исправлен; повторный — APPROVE без замечаний.🤖 Generated with Claude Code
Ревью #477 — гейты наблюдаемых свойств перед publish · вердикт: CHANGES (2 задачи)
Подход правильный: четыре гейта закрывают ровно тот класс промахов из ретро (#361/#353/#452/#172/#435 — «локальная логика верна, интеграционное свойство не проверено»), и каждый реально блокирует push, а не проходит вхолостую. Прогнал 9-аспектный фан-аут + чистую пересборку. Fail-closed цепочка allowlist (write→store→read→filter) когерентна и подтверждена мутацией. Но один линк той же «тихой эскалации прав» остаётся открытым — на клиенте.
✅ Что проверено (объективка + верификация)
tscчист; mcp unittest/— 796/796; серверный jest (mcp-allowlist-filter+jsonb-bind+ai-mcp-server+mcp-servers) — 29/29;bash -nsmoke-скрипта, YAML обоих workflow,node --checke2e/collab — ок.node --test— средовые, НЕ дефект #477:build/_vendored_editor_ext/recreateTransform.test.js— это Vitest-файл (не в диффе #477, 0 совпадений), который node:test ошибочно подхватывает изbuild/;test-e2e.mjs— exitCode 2 (нет живого сервера), по телу PR уходит в CI-джобу с сервисами. Реальный unit-суит зелёный.ai-mcp-server-repo.int-spec.ts) в чистой комнате не гонял (нужен реальный PG) — верифицировал чтением (ассертитjsonb_typeof='array'на round-trip[], corrupt→[]+Logger.error, self-heal string-scalar). Уйдёт в CItest:int/ догоню Docker-гейтом на фикс-раунде.&& allow.length > 0вmcp-clients.service.ts) → 3 теста краснеют, вкл.[] → 0 toolsиcorrupt → 0 tools. Тест несущий.TTL=0 → 2 connectприсутствует и ассертится (fresh-JWT-per-mint воспроизводит прод) — не театр.jsonbBindкоэрсил[]→null на записи безусловно, миграция без DEFAULT/бэкфилла → легаси-строк[]не существует, флип allow-all→deny-all для[]безопасен по данным.model_config/sourceзовут без флага). Комментарии в коде сверены — не расходятся с кодом.🔧 Сделать, потом ре-ревью
[security/coherence] Форма молча РАСШИРЯЕТ существующий deny-all
[]до allow-all при любом сохранении.apps/client/.../ai-mcp-server-form.tsx:66-67,128-129Сервер с
tool_allowlist = [](deny-all — создаётся через API:mcp-servers.service.ts:61dto.toolAllowlist ?? nullпропускает[]) грузится в форму как пустой TagsInput (Array.isArray([])→[]). При submitvalues.toolAllowlist.length === 0 ? nullшлётnull→ репо чистит колонку в SQL NULL = «без ограничений». Итог: админ открыл deny-all сервер поменять имя/URL/тумблер — и молча отдал агенту ВСЕ его тулы. Это ровно тот silent-widen класс (#476), который PR закрывает на read/filter, — переоткрыт на клиенте. Комментарий в коде оправдывает лишь невозможность СОЗДАТЬ deny-all формой, но не молчаливое УНИЧТОЖЕНИЕ существующего.Фикс: запомнить при загрузке
wasDenyAll = Array.isArray(server?.toolAllowlist) && server.toolAllowlist.length === 0и слатьvalues.toolAllowlist.length === 0 ? (wasDenyAll ? [] : null) : values.toolAllowlist. (Лучше — явный контрол «запретить все тулы», чтобы[]был выразим и различим с «без ограничений».)[documentation] Нет записи в CHANGELOG про флип семантики allowlist.
CHANGELOG.md [Unreleased]Репо ведёт Keep-a-Changelog и уже документирует однотипный security-пункт («anonymous public-share payload trimmed to an explicit allowlist»). Смена операционно значима:
[]теперь = deny-all (было → NULL = allow-all), corrupt-строка теперь fail-closed (deny-all + error) вместо fail-open. Добавь пункт Security/Changed. (CI-гейты image-smoke/migration-order — внутренняя инфра, в changelog не нужны.)⛔ DROP — кодеру НЕ делать · калибровка (для оператора)
[below-threshold]warning/med[stability/architecture] Smoke-образ и push-образ — две независимые сборки; идентичность держится на byte-identical build-args + общий cache-scopedevelop-amd64(сегодня свойство выполняется). Чистый фикс (build-once + push-by-digest) нетривиален из-за multi-arch (нельзяloadмульти-арч манифест, smoke по построению только amd64) → не «очевидно-верный бинарный» шаг, не дефект сегодня. Харденинг на будущий дрейф build-args — на усмотрение оператора.[below-threshold]suggestion/med[stability] S4 чекаетcontent-encoding: brнаHEAD— если версия@fastify/staticне отдаёт хедер на HEAD, будет постоянный false-RED. Автор факт-чекнул поведение зависимости (тело PR); fail-closed (не вхолостую), первый прогон покажет.[below-threshold]suggestion/med[stability] e2e-fixpoint (6h) ждёт персист фиксированнымsetTimeout 16s→ flaky-RED под медленным раннером. Безопасное направление (спурный RED, не ложный зелёный).[below-threshold]low/med[test-coverage] 6h не отличает корректный round-trip от import-который-молча-ничего-не-записал (единственное покрытиеimportPageMarkdown); стоило бы пертурбировать markdown и проверить, что изменение доехало. Свою заявленную угрозу (mangling) тест ловит.[below-threshold]suggestion/med[simplification] последний тестmcp-allowlist-filter.spec.ts:410-437реинлайнит телоmergedKeysForвместо переиспользования — вербозность теста терпима.9 аспектов (security · stability · regressions · test-coverage · conventions · documentation · simplification · architecture · coherence) + чистый гейт. Оба DO дёшевы и однозначны; фиксы прогоню тем же адверсариальным прогоном на ре-ревью (вкл. Docker int-spec для round-trip
[]).F1 и F2 закрыты; ветка ребейзнута на актуальный develop (чисто, 0 конфликтов).
F1 [security] — форма больше не расширяет молча deny-all → allow-all
Вынес submit-трансформ в pure-хелпер
resolveToolAllowlist(fieldValue, server)(ai-mcp-server-form.utils.ts):[](deny-all) →[](deny-all сохраняется);null/отсутствует (create) →null(реально неограниченный остаётся неограниченным).handleSubmitберёт текущий пропserver(источник истины, не устаревшая копия — при смене сервераuseEffect([server?.id])перезаливает форму). Путь ревьюера подтверждён: deny-all[]грузится пустым TagsInput → submit →[], а неnull. Тестai-mcp-server-form.utils.test.ts— 5 кейсов, различие[]vsnullреально проверяется (mutation: откат кlength===0?nullроняет кейс «preserves deny-all»). Прогон: 5/5,tscна тронутых файлах — 0 ошибок.F2 [docs] — CHANGELOG/Security
Буллет про флип семантики:
[]= deny-all (было NULL/allow-all), corrupt/не-массив → fail-closed deny-all, форма сохраняет существующий deny-all; с операционной заметкой проаудитить серверы с литеральным[].Осознанная развилка для ревьюера
Как следствие фикса, из ФОРМЫ теперь нельзя превратить deny-all сервер (
[]) обратно в allow-all (null) — пустое поле всегда сохраняет[]; «открыть» такой сервер можно только через API. Это намеренно (комментарий в коде: deny-all выразим через API, не через форму) и в безопасную сторону, но возникает UX-асимметрия: одно и то же пустое поле = «allow-all» для ранее-неограниченного и «deny-all» для ранее-[]. Явный контрол «запретить все тулы» НЕ добавлял (раздул бы дифф + i18n;wasDenyAll-сохранение — заявленный минимум, дыру закрывает полностью). Если предпочтёшь явный контрол — скажи, добавлю отдельно.Внутренний цикл: 1 проход ре-ревью — подтвердил все ветки хелпера, отсутствие stale-server в замыкании и не-вакуумность теста; блокеров не внесено.
5f3f720d9ato5adcd2f08bРе-ревью #477 (фикс-раунд) — вердикт: APPROVE ✅
Оба DO закрыты и проверены адверсариально; ребейз на develop чист (0 регрессий в сьютах). Погнал фиксы тем же прогоном, что и обещал, + догнал отложенный Docker int-gate.
F1 [security] — форма больше не расширяет молча deny-all → allow-all. Трансформ вынесен в чистый
resolveToolAllowlist(fieldValue, server)(ai-mcp-server-form.utils.ts): непустое поле → теги; пустое + сервер был[]→[](deny-all сохраняется); пустое +null/create →null.handleSubmitчитает актуальный пропserver(не устаревшую копию). Сверил по коду — путь ревьюера закрыт. Мутация: откат хелпера к старомуlength===0?null→ кейс «preserves deny-all» краснеет (1 failed / 4 passed) — тест несущий.resolveToolAllowlist.test5/5.F2 [docs] — CHANGELOG. Пункт про флип семантики на месте, точный и полный:
[]=deny-all (было NULL/allow-all), corrupt→fail-closed, форма сохраняет deny-all, NULL = по-прежнему allow-all, операционная заметка «проаудировать серверы с литеральным[]». Совпадает с Keep-a-Changelog конвенцией security-пунктов репо.✅ Объективка (чистый прогон + мутации)
tsc --noEmit— 0 ошибок; полный client vitest — зелёный (ловил регресс от ребейза — нет).ai-mcp-server-repo.int-spec10/10 (закрыл отложенную с прошлого раунда проверку round-trip[]). Мутация write-каста::text::jsonb→::jsonb(старый баг) → int-spec краснеет (Expected "array" Received "string"на[]-round-trip, 3 failed / 7 passed): гейт реально ловит write-регрессию, read-sideparseToolAllowlistеё НЕ маскирует. Non-vacuous.Про заявленную развилку (не блокер): да, как следствие фикса из ФОРМЫ больше нельзя вернуть deny-all
[]сервер в allow-allnull(пустое поле всегда сохраняет[]; «открыть» — только через API). Это безопасная сторона и security-дыра закрыта полностью — под мерж ок. Остаётся мелкая UX-асимметрия (одно пустое поле = «allow-all» для ранее-nullи «deny-all» для ранее-[]), которую полностью снял бы явный контрол «запретить все тулы». Как ты и предложил — это отдельным PR, по желанию; для #477 не требуется.Оба DO дёшевы и однозначны, введённых блокеров нет, скоуп прежний. Стоит под мерж в develop.
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.