Skip to content

feat: recognize a payload projection in doctor and lint - #61

Merged
dapi merged 1 commit into
mainfrom
feat/recognize-payload-projection
Sep 6, 2026
Merged

feat: recognize a payload projection in doctor and lint#61
dapi merged 1 commit into
mainfrom
feat/recognize-payload-projection

Conversation

@dapi

@dapi dapi commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Closes #60 в части, которая нужна прямо сейчас; мутационная часть вынесена (см. ниже).

Суть

Репозиторий, владеющий template/, не является downstream-установкой самого себя. Копировать payload в установленное дерево там незачем, поэтому такой репозиторий может представлять generic-документы симлинками в template/ — они равны payload по построению и не могут разойтись.

CLI считал это дефектом, причём непоследовательно: doctor выдавал manifest.managed_unreadable и governance.unsafe_symlink на каждый такой документ, а lint те же файлы читал как обычные и не возражал.

Что делает PR

internal/projection отвечает на один вопрос: разрешается ли путь внутри корня репозитория И указывает ли он ровно на payload-файл, стоящий за этим путём.

  • doctor читает документ сквозь проверенную проекцию в обоих сканах;
  • lint и governance-скан doctor спускаются в каталожный симлинк, остающийся внутри репозитория. WalkDir в них не заходит, из-за чего содержимое молча выпадало из области, а ссылки на него объявлялись битыми.

Попутно исправлено в обходе lint: цепочка предков вместо общего множества посещённых (две ссылки на один каталог — два законных места, а не цикл), проверка вложенности против репозитория, а не обходимого дерева, и отказ по цели ссылки, а не по её имени.

Защита не ослаблена

Симлинк наружу, симлинк на другой payload-файл, битая ссылка и обычный файл проекцией не считаются и сообщаются как прежде. Репозиторий без template/ не затронут: там ни один путь не может быть проекцией.

Почему объём сокращён

Изначально PR трогал и мутационный путь — pull, init, ownership lock. Два раунда ревью дали 20 находок, и их распределение оказалось показательным: всё опасное сидело в мутационной части (предусловия лока, secure-траверс, порядок проверок, тихая порча digest), всё безопасное и полезное — в чтении.

При этом dapi/memory-bank, ради которого всё делалось, от мутационной части не получает ничего: там больше нет lock и pull не запускается. Поэтому она вынесена в feat/projection-ownership-support — со всеми исправлениями обоих раундов, чтобы работа не пропала, и с собственным ревью, когда до неё дойдут руки.

init и pull этим PR не затронуты. Репозиторий с проекцией не держит lock — что doctor --profile template и предписывает.

Проверка на dapi/memory-bank

Сценарий Старый CLI Новый
doctor на спроецированном инстансе с локом 204 ошибки 0
lint при каталожной проекции (8 симлинков вместо 110) 34 документа в области, FAIL 120, OK
doctor --profile template при обеих формах проекции ошибки 0
Обычный downstream (lint и doctor) OK OK, без изменений

🤖 Generated with Claude Code

https://claude.ai/code/session_01RtxeRwhVf1y8zvxUtk6xrS

@dapi

dapi commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

Ревью нашло тихую порчу лока — исправлено в c8faa6f

Ревью упало на лимите сессии, но успело указать направление: проекция, чей локальный template/ устарел относительно входящего source. Проверил — дефект реальный и серьёзный.

Воспроизведение. Source уходит на payload v2, локальный template/ остаётся на payload v1:

РЕШЕНИЕ:  preserve, конфликтов 0
LOCK payload_digest = sha256:67ce…   (v2, из входящего source)
НА ДИСКЕ content = "payload v1"      (sha256:501d…)

Лок молча записывал digest содержимого, которого у файла нет. Мой аргумент «содержимое равно payload по построению» верен только когда source и есть собственный payload репозитория — а именно так выглядели все мои проверки на dapi/memory-bank, поэтому дефект в них не проявился.

Исправление. Содержимое проекции сверяется с входящим payload по digest и режиму. Совпало — preserve. Разошлось — конфликт с указанием, что обновлять надо template/, прогон не применяется, lock сохраняет прежнюю запись:

РЕШЕНИЕ:  conflict, конфликтов 1
          "payload projection is stale: it resolves to local template content
           that differs from the incoming source; update template/ and re-run"
LOCK payload_digest = sha256:501d…   ← совпадает с тем, что на диске

Регрессия закреплена TestPullRejectsStalePayloadProjection, который проверяет и решение, и то, что записанный в lock digest равен фактическому содержимому.

Четыре сценария на dapi/memory-bank перепроверены после правки — без изменений: pull строит план с одним настоящим конфликтом, doctor на проекции с локом даёт 0 ошибок, lint при каталожной проекции видит 120 документов, пофайловая проекция остаётся зелёной.

@dapi

dapi commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

Все 11 находок ревью закрыты в 512b901

Ревью показало общий корень: обработка проекции была приклеена к одной ветке buildPlan, поэтому всё, что шло другими путями, ломалось. Модель переделана — проекция читается как обычный файл с содержимым payload на уровне низкоуровневых проверок, а запись сквозь неё запрещена отдельной охраной.

# Находка Как закрыто
1 TOCTOU: классификация проекции пере-проверялась по живой ФС при сборке лока buildPlan возвращает множество спроецированных путей, предусловия берут его, а не пере-статят
2 upstream-удаление проекции роняло весь прогон удаление сквозь проекцию — конфликт с указанием убрать файл из template/
3 новый файл под спроецированным каталогом роняло прогон Covers распознаёт путь под проекцией даже когда файла ещё нет → внятный конфликт
4 pull --plan не строился ни в одном репозитории с проекцией чтение назначения идёт через projection-aware обёртку
5 init при расходящейся проекции не создавал lock спецветка убрана, работает обычная логика adoption
6 одна ссылка на каталог подавляла вторую законную цепочка предков вместо общего множества посещённых
7 ссылка на предка дублировала дерево под фантомным префиксом цепочка предков засеяна корнем обхода
8 вложенность проверялась против обходимого дерева, а не репозитория передаётся настоящий repoRoot
9 игнор по имени ссылки, а не по её цели проверяется база разрешённой цели
10 governance молча не применялся под спроецированным каталогом doctor спускается в него так же, как lint
11 doctor перерезолвливал путь после проверки используется путь, который вернул Resolve

Каждая закреплена тестом. Добавились: удаление проекции, новый файл под каталогом, построение плана, adoption в init, adoption непрослеженной совпадающей проекции, четыре теста обхода lint, governance под проекцией.

Попутно нашлось и исправлено: непрослеженная проекция, чьё содержимое совпадает с входящим payload, давала конфликт «unmanaged file blocks managed template path». Проекция — не пользовательский файл, а указатель на payload, защищать там нечего; теперь она принимается как managed.

Проверка на dapi/memory-bankpull даёт ровно один настоящий конфликт (.gitignore в корне шире шаблонного), doctor чист и при пофайловой, и при каталожной проекции, lint видит все 120 документов, обычный downstream-репозиторий не затронут.

Свойство безопасности сохранено и усилено формулировкой в CHANGELOG: в репозитории без template/ ни один путь не может быть проекцией, поэтому поведение байт-в-байт прежнее.

@dapi

dapi commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

Второй раунд ревью закрыт в 8d25d88

Девять находок, включая одну регрессию за пределами фичи.

# Находка Как закрыто
1 Covers не видел ничего глубже одного уровня под спроецированным каталогом проход вверх продолжается мимо обычных каталогов, а не останавливается на первом
2 выполнение собственной рекомендации конфликта ломало следующий прогон проверка проекции перенесена до чтения назначения; повисшая ссылка распознаётся по тексту (Declares); текст рекомендации теперь просит удалить и саму ссылку
3 спроецированный agent-файл ронял прогон с ELOOP читатель согласован с inspectDestination; запись managed-блока сквозь проекцию заблокирована
4 governance.unsafe_symlink перестал срабатывать на ссылки наружу и повисшие обход сообщает о них вместо молчаливого пропуска
5 пропуск предусловия лока снимал проверку на момент коммита пропуск убран целиком: проверка сверяет диск с digest входящего payload и работает как есть
6 регрессия: .md-ссылка наружу выпадала из корпуса восстановлено дореформенное поведение для файлов; containment требуется только для каталогов как корней обхода
7 в governance не было фильтра игнорируемых каталогов фильтр по цели ссылки, как в lint
8 чтение проекции идёт мимо openat/pinned-root см. ниже
9 inspectDestination отдавал путь внутрь template/ как цель мутации возвращается сам путь назначения; резолвленный — только для чтения

Про находку 6

Отдельно отмечу, потому что это единственное, что задевало репозитории без template/. До этого PR os.ReadFile следовал по ссылке и документ аудировался под своим внутренним путём; мой обход стал его терять, и каждая ссылка на него становилась битой. Мой же прежний тест закреплял это ошибочное поведение — удалён.

Про находку 8 — оставляю осознанно

Чтение проекции идёт readRegularFile(resolved) вместо openat-цепочки. Считаю это приемлемым и не хочу маскировать: Resolve уже проверил, что путь разрешается внутрь репозитория ровно в payload-файл, чтение не ведёт к записи, а для эксплуатации нужен локальный доступ на запись в репозиторий — при котором злоумышленник просто отредактирует template/ напрямую. Переписывание secure-траверса под «следовать по последней ссылке» — отдельная работа с собственным риском, и делать её мимоходом в этом PR неправильно.

Проверка на dapi/memory-bank после правок: pull — один настоящий конфликт, doctor чист при обеих формах проекции, lint видит все 120 документов, обычный downstream (lint и doctor) не затронут.

@dapi
dapi force-pushed the feat/recognize-payload-projection branch from 8d25d88 to 0a00318 Compare September 6, 2026 06:29
@dapi dapi changed the title feat: recognize an intentional payload projection feat: recognize a payload projection in doctor and lint Sep 6, 2026
Репозиторий, владеющий `template/`, не является downstream-установкой самого
себя. Копировать payload в установленное дерево там незачем, поэтому такой
репозиторий может представлять generic-документы симлинками в `template/`.

`doctor` считал это дефектом: `manifest.managed_unreadable` и
`governance.unsafe_symlink` на каждый такой документ. `lint` при этом те же
файлы читал как обычные и не возражал — инструменты расходились между собой.

`internal/projection` отвечает на один вопрос: разрешается ли путь внутри
корня репозитория И указывает ли он ровно на payload-файл, стоящий за этим
путём, в репозитории, который действительно несёт `template/memory-bank`.
Симлинк наружу, симлинк на другой файл, битая ссылка и обычный файл проекцией
не считаются и сообщаются как прежде.

Объём сознательно минимален. Прошлые редакции этой ветки трогали мутационный
путь и обход каталожных симлинков; три раунда ревью показали, что там своя
поверхность риска — от тихой порчи lock до факториального роста числа
документов при перекрёстных ссылках, причём обход менял поведение для всех
репозиториев, а не только для тех, что несут payload. Обе части вынесены в
feat/projection-ownership-support.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RtxeRwhVf1y8zvxUtk6xrS
@dapi

dapi commented Sep 6, 2026

Copy link
Copy Markdown
Owner Author

Объём сокращён второй раз — и это ставит вопрос о нужности PR

Третий раунд ревью дал 10 находок, и решающей оказалась не отдельная из них, а сводка в конце: изменения обхода применялись ко всем репозиториям, а не только к тем, что несут payload. Обе высокие находки — оттуда: ссылка на предка дублировала поддерево фантомом и давала ложные падения lint, а семь перекрёстно связанных каталогов раздували корпус до 82 201 документа за 11.5 с.

Хождение по каталожным симлинкам было нужно только ради каталожной формы проекции. В dapi/memory-bank проекция пофайловая, и lint с ней работает без единой правки — это было видно ещё в первом эксперименте. Поэтому обход возвращён к main целиком: internal/lint в диффе больше нет.

Осталось 283 строки, из них 96 — тесты: новый internal/projection и чтение сквозь проверенную проекцию в doctor.

Попутно закрыты находки 8 и 10: признание проекции требует, чтобы репозиторий действительно нёс template/memory-bank (иначе посторонний каталог template/ в downstream давал бы признание), и PayloadRoot больше не второе определение — переиспользует ownership.CanonicalTemplateRoot, чтобы переименование корня не выключило проверку молча.

Но вот факт, который стоит обсудить до мержа

Проверил старым, релизным CLI ту самую спроецированную ветку dapi/memory-bank#134:

memory-bank-cli doctor --profile template   →   0 errors

Причина простая: репозиторий-источник шаблона не держит lock, а без lock doctor не сверяет managed-файлы инстанса вовсе. Именно это он и предписывает сам («an installed-template lock is not expected»). CI шаблона запускает lint --repo-root ., который файловые симлинки читает и без правки.

То есть после сужения у этого PR нет потребителя сегодня. Он остаётся честным исправлением несогласованности (doctor называл дефектом то, что lint спокойно читает) и пригодится dual-role репозиторию, который зачем-то сохранит lock, — но задачу, ради которой всё затевалось, решил не он, а сама модель проекции в #134.

Решение о мерже или закрытии оставляю владельцу репозитория. Если закрывать — ветки feat/recognize-payload-projection и feat/projection-ownership-support стоит сохранить: там разобранная поверхность риска и тесты, которые пригодятся, если тема вернётся.

@dapi
dapi force-pushed the feat/recognize-payload-projection branch from 0a00318 to 963513d Compare September 6, 2026 06:41
@dapi
dapi merged commit 9e3bb03 into main Sep 6, 2026
4 checks passed
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.

Recognize an intentional payload projection instead of rejecting it as an unsafe symlink

1 participant