Трастовые тиры конфигурации
Реализация: scripts/config_trust.py. SPEC: sec-config-trust-tiers (SEC, v1). Задача: l26-config-trust-tiers.
Проблема
.tausik/config.json — обычный файл внутри репозитория, и в нём жили выключатели принуждения:
| Ключ | Что выключал |
|---|---|
qg0.scope_hard_gate | запрет правок вне заявленного scope задачи |
risk.l3_block_on_high | требование внешнего L3-ревью при закрытии с тонким доказательством |
task_done.auto_verify | закрытие задачи inline-прогоном в обход подписанного чека |
gates.<имя>.enabled | сам гейт |
gates.<имя>.severity | понижение block → warn |
gates.<имя>.trigger | отвязка от событий: гейт включён и не срабатывает никогда |
gates.<имя>.file_extensions | сужение фильтра входов до непопадания |
Одной записи в отслеживаемый git-ом файл хватало, чтобы агент снял с себя надзор. Та же премиса уже была зафиксирована в дереве точечно — bootstrap_opencode.py: «.tausik/config.json едет вместе с репозиторием, поэтому переопределение НЕДОВЕРЕННОЕ». Этот модуль обобщает её.
Правило
Конфиг читается из трёх тиров, от наименее доверенного к наиболее:
| Тир | Где | Едет с репозиторием |
|---|---|---|
| project | .tausik/config.json | да |
| user | ~/.tausik/config.json | нет |
| managed | $TAUSIK_MANAGED_CONFIG | нет |
Старшие тиры выигрывают при слиянии. Сверх этого: охраняемый ключ из project-тира применяется, только если он не слабее того, что уже установили доверенные тиры (а если они молчат — фреймворковый дефолт).
- Ужесточение из project-тира проходит без изменений. Живой пример из конфига этого репозитория:
gates.mypy.enabled: trueпри дефолтеfalse— остаётся. - Ослабление отбрасывается, а на его место подставляется применённое значение. Не удаляется: удаление работало бы только потому, что каждый потребитель случайно подставляет строгий дефолт — неявная связность, которая ломается от первого
cfg["gates"]["x"]["enabled"]без дефолта. - Ужесточение выигрывает и у доверенного тира. Если оператор в
~/.tausik/config.jsonпросто повторил дефолт (mypy.enabled: false), а проект ставитtrue, побеждаетtrue: правило «project может только ужесточать» действует и в эту сторону, иначе одобренное политикой ужесточение молча терялось бы при слиянии. На НЕохраняемых ключах обычный приоритет тиров сохраняется. - Отбрасывание не молчаливое: каждое отклонение уходит в лог и в
tausik doctor.
$ tausik doctor
WARN Config trust tier qg0.scope_hard_gate: project value False rejected
(project scope may only tighten scope hard gate
(blocks edits outside a task's declared scope));
True appliedЧто охраняется, а что нет
Охраняются семь ключей из таблицы выше. Критерий отбора сформулирован явно: ключ охраняется, только если он ОТКЛЮЧАЕТ надзор — не сужает его область и не настраивает его параметр.
Сознательно вне периметра (решение #137):
gates.filesize.exempt_files— сужает область гейта. Легитимные значения (сгенерированные каталоги, дампы исследований) по природе специфичны для проекта, и осмысленного дома в машинном user-тире у них нет. Отдельно разбирается вl26-filesize-gate-revisit.С задачи
filesize-mro-exempt-mcpсужение приходит из ДВУХ источников, и оба надо держать в голове: gitignored project-scopeconfig.json(как раньше) и закоммиченныйtausik/gates.json, который читаетgate_filesize._resolve_exempt_files. Второй источник едет с веткой, то есть переживает свежий клон и виден в code review — это усиление, а не ослабление вывода выше. Две особенности механизма стоит знать до того, как менять исключения: (1) закоммиченный конфиг ОБЪЕДИНЯЕТСЯ поверх хардкодных умолчаний, поэтому из него можно исключение ДОБАВИТЬ, но нельзя УБРАТЬ — снятие требует осознанной правки исходника; (2) поискtausik/gates.jsonостанавливается на границе.git, чтобы файл постороннего предка (родителя монорепо, забытого клона) не расширил молча то, что обходит лимит в ЭТОМ проекте.verify_cache_ttl_seconds— параметр, а не выключатель. Настоящий контроль свежести кэша этоfiles_hash: кэш инвалидируется при изменении файлов, а у разных задач разныеrelevant_files, значит разные хеши. TTL ограничивает только неизменившееся дерево, где получасовой green — свидетельство о том же самом дереве. Легитимное значение определяется длительностью тест-сьюты конкретного проекта.
Выключить гейт можно другим написанием
Охрана одного enabled была бы декоративной. Гейт, оставшийся включённым, но потерявший все триггеры, не срабатывает никогда; гейт с сужённым до непопадания file_extensions не получает входов. Оба — то же «выключено», записанное иначе. Поэтому trigger и file_extensions охраняются как МНОЖЕСТВА: проект может их расширять, но не сокращать. Значение не-списком там, где ожидается список, тоже отклоняется — это не читаемое ужесточение.
Команды гейтов
_validate_custom_gate исторически вызывался только для гейтов, отсутствующих в DEFAULT_GATES. Значит, переопределение gates.ruff.command не проходило проверку по списку разрешённых исполняемых файлов вовсе — клон репозитория мог направить встроенный гейт на произвольный бинарник, и безшелловый раннер его запускал. Теперь валидируется любая команда из переопределения; отклонённая откатывается к дефолту, остальные ключи переопределения продолжают действовать.
Остаётся открытым: команду можно свести к РАЗРЕШЁННОЙ, но инертной (gates.ruff.command = "python -c pass" — python в списке, гейт формально работает и всегда зелёный). Вектор заведён как l26-gate-command-neutering.
Поверхность угроз — честные границы
Закрыто: репозиторий не может выдать сам себе полномочия..tausik/config.json приезжает с каждым клоном, форком и PR; ~/.tausik/config.json — нет. Участник больше не может прислать ветку, которая тихо выключит гейты на машине ревьюера, а агент, правящий конфиг проекта, больше не может понизить собственную планку.
Не закрыто, и это записано, а не замолчано: агент, который может выполнять произвольные shell-команды, способен сам записать ~/.tausik/config.json или экспортировать TAUSIK_MANAGED_CONFIG. Тиры — не песочница. Они дают поднятую планку и, что важнее, видимость: ослабление теперь обязано происходить вне репозитория и больше не может спрятаться в диффе, выглядящем как обычная правка конфига. Настоящая изоляция требует точки принуждения, до которой агент не дотягивается вовсе, — это за рамками задачи.
Контракт чтения и записи
Разделение обязательное, а не косметическое:
| Функция | Что возвращает | Кому |
|---|---|---|
load_config() | эффективный конфиг (слияние + политика) | читателям |
load_config_with_rejections() | то же + список отклонений | doctor, интроспекция |
load_project_config() | сырой project-слой | писателям |
save_config() сохраняет то, что ему дали. Писатель, прочитавший эффективный конфиг, скопировал бы настройки пользователя и оператора в файл репозитория. Все round-trip-писатели (ProjectService.gate_enable/gate_disable, MCP _handle_gate_toggle, brain _ConfigOps) читают сырой слой.
Поведение gates disable
Отключение охраняемого гейта из project-тира записывает ключ, но не меняет поведение. Рапортовать успех здесь было бы ровно тем тихим враньём, ради устранения которого задача и делалась, поэтому вызывающему говорят правду:
$ tausik gates disable filesize
Gate 'filesize' NOT disabled — project scope may only tighten gate on/off
switch. The key was written to .tausik/config.json but the effective config
keeps True. To disable it for real, set it in the user tier
(~/.tausik/config.json) or in $TAUSIK_MANAGED_CONFIG.Переменные окружения
| Переменная | Назначение |
|---|---|
TAUSIK_MANAGED_CONFIG | путь к managed-тиру; не задана — тир отсутствует |
TAUSIK_USER_CONFIG | переопределение пути user-тира (тесты, машины с несколькими аккаунтами) |
Миграция существующих проектов
Проект, отключавший гейт в .tausik/config.json, после обновления обнаружит гейт включённым, а ключ — названным в tausik doctor. Это намеренно. Если отключение легитимно (песочница, CI-образ, неприменимый к проекту гейт), перенесите его в ~/.tausik/config.json либо в файл из $TAUSIK_MANAGED_CONFIG. Ужесточение из project-тира работает без изменений.
Битый, не-объектный, слишком большой или нечитаемый доверенный слой игнорируется с предупреждением: он никогда не роняет фреймворк и никогда не читается как разрешение ослабить — политика остаётся в силе.