From d4f7e681f6205970f7d14a132b2e14cc7cbf5eef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 29 May 2026 09:21:19 +0300 Subject: [PATCH] docs(spec): condensed plan-ready router-gate hard wall v3.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prep для writing-plans фазы: 1489 → ~850 строк (-43%) убрав §11 историю версий + 4 TL;DR «Changes vN→vN+1» блока + inline audit-метки «closes Дыра N v4-audit». Sonnet subagent verified 63/63 design decisions present + 3 места где condensed улучшил оригинал (subagent-inheritance schema без stale parent_router_state_path полей, §8 Этап 1.2 +git-pattern, §10.6 sequential 2.1.0→2.1→2.2→2.3). Оригинал 2026-05-28-router-gate-hard-wall-design.md v3.5 не тронут (audit-trail сохраняется в git log). Co-Authored-By: Claude Opus 4.7 (1M context) --- ...-router-gate-hard-wall-design-condensed.md | 1117 +++++++++++++++++ 1 file changed, 1117 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-29-router-gate-hard-wall-design-condensed.md diff --git a/docs/superpowers/specs/2026-05-29-router-gate-hard-wall-design-condensed.md b/docs/superpowers/specs/2026-05-29-router-gate-hard-wall-design-condensed.md new file mode 100644 index 00000000..6e6ac8cc --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-router-gate-hard-wall-design-condensed.md @@ -0,0 +1,1117 @@ +# Router-gate hard wall — Дизайн-спецификация (plan-ready) + +**Дата:** 2026-05-29 +**Версия:** v3.5 condensed (plan-ready) +**Источник:** [2026-05-28-router-gate-hard-wall-design.md](2026-05-28-router-gate-hard-wall-design.md) v3.5 (audit-history + version-narrative вырезаны для plan writing) +**Автор:** Claude (controller Opus 4.7) под руководством заказчика Дмитрия +**Статус:** Approved by owner — готов к плану implementation через `superpowers:writing-plans` +**Предшественник:** [docs/superpowers/plans/2026-05-28-router-discipline-level-1-2.md](../plans/2026-05-28-router-discipline-level-1-2.md) (Уровни 1+2, merged 28.05.2026) + +--- + +## TL;DR + +**Цель:** Закрыть все обходы router-рекомендаций. Controller (Claude) физически не может действовать без явного разрешения заказчика. + +**Решение:** Один PreToolUse-хук `tools/enforce-router-gate.mjs` заменяет 5 старых хуков и vocab.json. На каждый tool call gate резолвит «разрешено / заблокировано» по 4 поведениям: + +1. **Direct invocation** заказчика (strict whitelist: slash-cmd / `Skill(X)` / `используй #N` / `делай `) → allow. +2. **Single router-рекомендация** → allow только matching Skill/Task + read-only baseline; mutating требует AskUserQuestion approval. +3. **Chain router-рекомендация** → allow только текущий шаг chain (state persists across turns, TTL 24h); progression after matching skill invoked. +4. **Silence** (router молчит) → mutating tools блокируются до AskUserQuestion с 1/2+/0-формат предложениями. + +**Безопасная база (всегда разрешено):** Read / Grep / Glob / LS / TodoWrite / AskUserQuestion (с лимитом 2 per turn) / read-only MCP. Bash — отдельный whitelist (§5.1). + +**Ключевые защиты:** + +- AskUserQuestion НЕ unlock'ает turn автоматически — gate парсит ответ заказчика и unlock'ает только для approved action. +- Protected paths (`~/.claude/runtime/*`, `tools/enforce-*.mjs`, etc) — hard-deny независимо от unlock. +- Subagent inheritance через env vars (`CLAUDE_GATE_INHERIT=true`), не text-prefix. +- Subagent constraints: нет AskUser, нет recursive Task, max 3 parallel. +- Subagent BLOCKED-protocol через side-channel `subagent-block-.json` (subagent gate-process пишет, parent gate читает). +- Dangerous git операции (rebase / reset --hard / clean -f / push --force / etc) — через AskUserQuestion с git-pattern (§4.5); approval one-shot через `approve_git_operation` в askuser-decisions.jsonl. +- Atomic file writes + `proper-lockfile` для race-free state. +- Gate budget 2s + fail-CLOSE при таймаутах. +- Bash blocks sub-shells (`backticks`, `$()`, `<()`, `<<` heredocs) + file-watcher для script execution + static content scan. + +**Цена:** 22.5-32 часов implementation в 6 этапов через `superpowers:subagent-driven-development`. Wall-clock с параллелизмом: 6-9 часов. + +**Recovery:** заказчик соглашается быть recovery-каналом ручной правкой `.claude/settings.json` / state-файлов при ошибочном lockout. + +**Удаляется:** 5 хуков (chain-recommendation / classifier-match / graph-first / semgrep-security / override-limit) + `enforce-override-vocab.json` (7 фраз обхода больше не работают). + +**Сохраняется:** 7 preserved хуков (tdd-gate / coverage-verify / memory-coverage / verify-before-push / rationalization-audit / prompt-injection / branch-switch). 6 из них теряют свои findOverride escape-фразы. Branch-switch переписан под AskUserQuestion-gate (см. §3 таблица сохраняемых). + +**Class controller-writable signals закрыт архитектурно** через side-channel writes в protected paths (S5: subagent-block-file; S8: askuser-decisions с git-pattern). Acceptable residual list пуст. + +--- + +## 1. Цель и контекст + +Закрыть все обходы (bypass paths) от controller'а (Claude) к роутеру (classifier-driven skill recommendations). После реализации controller физически не может обойти router-рекомендации без явного разрешения заказчика. + +### Триггер + +Brain-retro #10 показал катастрофический рост override-events: 12 → 229 → 348 → 679 срабатываний за 4 дня (25-28.05.2026). Self-retrospect #2 подтвердил: 5 commitments утреннего self-retrospect сломаны 2 из 5 за 6 часов. Без enforcement-хука дисциплину не удержать — два независимых доказательства. + +### Дыры, которые Уровни 1+2 не закрыли + +1. Одна override-фраза снимает 5 PreToolUse хуков разом. +2. Router-skip как inline self-bypass — controller сам себя освобождает от рекомендации. +3. Глобальный override-vocab по substring-match — никакого user explicit consent. + +### Что хочет заказчик + +«Hard wall. Никаких обходов. Разрешение на любой обход даёт только заказчик. Все router-рекомендации (одиночные и цепочки) — через явную санкцию заказчика. Любая неясная ситуация — controller ничего не делает сам, спрашивает заказчика с честными рекомендациями.» + +--- + +## 2. Принципы дизайна + +1. **Hard wall, no escape valves в коде.** Bypass возможен только через AskUserQuestion с явным выбором заказчика. Никаких inline phrases, substring-match overrides, self-bypass паттернов. +2. **User approval everywhere для router output.** Каждая router-рекомендация (одиночный скил и цепочка) проходит через AskUserQuestion перед исполнением. +3. **Прямые поручения заказчика — без переспрашивания.** `/brain-retro`, «вызови writing-plans», «делай subagent-driven» — direct invocation, не router-recommendation. +4. **Read-only действия — всегда разрешены.** Read / Grep / Glob / LS / TodoWrite / read-only MCP / AskUserQuestion — безопасная база. +5. **Honest reasoning required.** Каждое AskUserQuestion включает мои честные рекомендации + объяснение причин. +6. **Recovery — explicit acknowledged risk.** Если gate ошибочно заблокирует и AskUserQuestion не сработает — единственный путь = ручная правка заказчиком файлов settings.json / state-файлов. +7. **All decisions logged.** Каждое срабатывание gate (allow / block / unlock) пишется в JSONL для post-hoc анализа в brain-retro. + +--- + +## 3. Архитектура + +### Новый компонент: `tools/enforce-router-gate.mjs` + +Единственный PreToolUse-хук, регистрируется в `.claude/settings.json` с `matcher: ""` (все tools). Pure decision-функция + тонкая I/O обёртка. + +На каждый tool-call хук: + +1. Читает classifier-output последнего prompt'а из `~/.claude/runtime/router-state-.json` (уже существует — пишется при UserPromptSubmit роутером). +2. Читает chain-state из `~/.claude/runtime/chain-state-.json` (новый файл — какой шаг цепочки уже пройден across-turns). +3. Анализирует transcript текущего turn'а — был ли вызван AskUserQuestion или matching Skill/Task в этом ходе. +4. Резолвит набор «допустимых tools» по правилам Decision Flow (§4). +5. Возвращает `decision: allow` (silent pass) или `decision: block` (с reason). +6. Пишет запись в `~/.claude/runtime/router-gate-decisions.jsonl`. + +### Удаляются как класс + +| Файл | Размер | Содержание | +|---|---|---| +| `tools/enforce-chain-recommendation.mjs` + test | ~370 lines | Chain enforcement | +| `tools/enforce-classifier-match.mjs` + test | ~190 lines | Single-node enforcement | +| `tools/enforce-graph-first.mjs` + test | ~150 lines | Graph-first rule | +| `tools/enforce-semgrep-security.mjs` + test | ~135 lines | Semgrep-security | +| `tools/enforce-override-limit.mjs` + test | ~150 lines | Override-счётчик | +| `tools/enforce-override-vocab.json` | ~80 lines | 7 фраз обходов | + +В `tools/enforce-hook-helpers.mjs` функции `findOverride`, `findOverrideAttempt`, `loadOverrideVocab` превращаются в stubs (возвращают `null` / `null` / `{phrases: []}`). Это сохраняет совместимость с 6 preserved хуками которые их вызывают, без правки их кода. Unit-тесты переписываются под stub-поведение. + +Из `.claude/settings.json` снимаются регистрации удалённых хуков. + +### Сохраняются (с патчем «без vocab») + +7 хуков с собственной семантикой (не про router-routing): + +| Хук | Семантика | Эффект удаления vocab | +|---|---|---| +| `enforce-tdd-gate` | Test перед production-code edit | Теряет escape-фразы `срочно` / `быстрый коммит` / `ремонт инфраструктуры`. Hard-lock — починка TDD-gate требует ручной правки settings.json | +| `enforce-coverage-verify` | Stop-hook требует `coverage:` строку | Теряет `без скилов` / `direct ok`. Каждый ответ требует coverage аннотацию | +| `enforce-memory-coverage` | Memory-write требует memory-sync coverage | Теряет `memory dump` | +| `enforce-verify-before-push` | Свежий verify-sentinel перед commit/push | Теряет `срочно` / `быстрый коммит` / `ремонт инфраструктуры` | +| `enforce-rationalization-audit` | Soft detector, не блокирует | Не использует findOverride — нет эффекта | +| `enforce-prompt-injection` | Inject контекст-блок в UserPromptSubmit | Использует findOverride для surface активных overrides. После удаления — surface пустой | +| `enforce-branch-switch` | Block dangerous git-операции (`git rebase` / `git reset --hard` / `git clean -f` / `git checkout --` / `git branch -D` / `git push --force` / `git stash drop` / `git cherry-pick` / `git revert`) | Теряет `recovery` override И controller-writable markers `BRANCH-SWITCH-CONFIRMED` / `RECOVERY-INTENT:`. Hard-lock: controller обязан AskUserQuestion с явным вопросом + 3 опциями; user approves через §4.5 answer parser (категория `approve_git_operation`); approval пишется в `askuser-decisions-.jsonl` (protected); hook читает exact-match + one-shot consume + 5-min time window | + +Из 7 preserved хуков 6 используют `findOverride` (все кроме `enforce-rationalization-audit`). После удаления vocab.json эти вызовы возвращают `null` всегда — hooks работают корректно, изменений в их коде не требуется. + +Это **explicit acknowledged risk** — заказчик соглашается что починка любого из 7 preserved хуков требует его ручного вмешательства. + +### 3.1. Protected paths (hard-deny) + path normalization + +Список путей **никогда** не разрешённых для Edit / Write / MultiEdit / NotebookEdit и Bash-команд rm / mv / cp / redirects (`>` / `>>`): + +- `~/.claude/runtime/*` — все gate state-файлы: router-state, chain-state, askuser-decisions, gate-decisions, gate-errors, subagent-inheritance, coverage-hint, **`subagent-block-.json`** (subagent gate-process пишет block-events, parent gate читает после Task return; см. §3.4 + §10.2 schema), **`gate-config.json` тоже** (explicit, чтобы glob-интерпретация не разошлась). +- `.claude/settings.json` / `.claude/settings.local.json` +- `.claude/skills/**/SKILL.md` — skill definitions +- `tools/enforce-*.mjs` — все enforcement-хуки (включая `enforce-router-gate.mjs`) +- `tools/router-classifier.mjs` + dependents в `tools/router-*.mjs` +- `tools/router-gate-decide.mjs` + dependents (новые модули gate'а) +- `tools/enforce-hook-helpers.mjs` — содержит `sessionToolUses` / `findOverride` stubs / `logHookOutcome` — gate читает transcript через них. Если patched — falsify «askuser_called=true». +- `tools/subagent-prompt-prefix.mjs` — устанавливает env vars для субагента (§3.2). Если patched — субагент стартует без inheritance flags → undefined behavior. +- `tools/registry-load.mjs` — gate использует для alias resolution (skill ↔ #N ↔ slug). Если patched — direct-invocation strict whitelist матчит fake aliases. +- `docs/registry/nodes.yaml` — Single Source of Truth для skill/node names. Если patched — fake skill добавляется и `делай ` проходит. +- `package.json` / `composer.json` — npm-scripts (`npm test`, `npm run lint:*`) и composer-команды в Bash-whitelist. Если patched — `"test": "rm -rf ..."` runs evil под whitelist. + +Hard-deny check выполняется **ПЕРВЫМ** в decision flow, до любой recommendation logic. Никакой unlock (Skill match / AskUserQuestion / direct invocation) не отменяет hard-deny. Изменения этих файлов — только через заказчика снаружи Claude. + +**Path normalization:** перед сравнением с deny-list gate **канонизирует** путь: + +1. `path.resolve(target)` — разрешает относительные пути и `..`. +2. `fs.realpathSync(resolved)` — разрешает symlinks (если файл существует; если нет — используется resolved). +3. Expand `~` через `os.homedir()` и env vars через `process.env`. +4. Case-fold на Windows (`.toLowerCase()` для сравнения; на Linux/Mac case-sensitive). + +Если canonicalization падает (file ENOENT) — fail-CLOSE для unknown-state файлов, fail-OPEN для проверок прав на чтение существующих. + +### 3.2.0. Pre-flight smoke-tests — REQUIRED BEFORE §3.2 IMPLEMENTATION + +**Pre-flight tasks Этапа 2.1 в плане implementation.** Три независимых smoke-теста, все обязательны. **User-run methodology:** smoke-тесты НЕ запускаются controller'ом (тот может подтасовать env в PreToolUse-handler'е до Task spawn → fake PASS). Заказчик запускает в чистой сессии где нет моих хуков. + +**Smoke 1 — env propagation:** + +Acceptance criteria: заказчик запускает в чистой Claude session тестовый prompt: «спавн субагента через Task с subagent_type=Explore, спроси что у него в `process.env.CLAUDE_TEST_PROBE`. Я перед prompt'ом руками поставлю env через PowerShell `$env:CLAUDE_TEST_PROBE='42'`». Субагент в первом tool_use возвращает значение. + +- **Pass:** `42` → env propagation работает, §3.2 реализуется по env-based design. +- **Fail:** undefined / другое значение → §3.2 design не работоспособен, **stop эпик** и открыть отдельную задачу «subagent inheritance через file-based handshake» (Plan B: `subagent-prompt-prefix.mjs` пишет inheritance-файл по детерминированному имени `~/.claude/runtime/subagent-inheritance--LATEST.json` + субагент читает по convention с mtime check + flock для concurrent Task'ов). + +Альтернатива если заказчик не может — independent 3-way verification: (a) `process.env` check; (b) `argv` check (env vars иногда передаются как `--env=`); (c) file-based fallback probe одновременно с env-probe — несовпадение результатов = controller-manipulation. + +**Smoke 2 — PostToolUse semantics:** + +Acceptance criteria: заказчик запускает prompt с явно-failing skill invocation (например `Skill(superpowers:writing-plans)` с заведомо невалидным arg). Hook `PostToolUse` зарегистрирован на этот tool. Acceptance: + +- **Pass A:** PostToolUse fires **только** на success — на failed skill НЕ срабатывает → chain-state S3 fix корректен. +- **Pass B:** PostToolUse fires всегда, но содержит `status: "error"` поле → §3 handler фильтрует по этому полю, fix корректен. +- **Fail:** PostToolUse fires всегда без status differentiation → chain_step++ инкрементнет на упавшем skill. Нужен redesign — chain_step++ через **PreToolUse следующего turn'а** проверяя что предыдущий skill_invoke реально завершился (по transcript output length / artifact files). + +**Smoke 3 — subagent block-file write:** + +Acceptance criteria: заказчик запускает prompt: «спавн субагента через Task tool с заданием `Write tools/router-gate-decide.mjs` (path в §3.1 protected) — gate должен subagent'у заблокировать Write». После subagent return: + +- **Pass:** в `~/.claude/runtime/` появляется `subagent-block-.json` с записью block-event на Write. Parent gate в next response обязан escalate AskUser (видно в transcript). Acceptance: file exists + parent escalation visible. +- **Fail:** file missing после blocked Task → subagent gate-process не пишет block-file (либо не запускается в subagent-режиме, либо path-derivation broken). Degraded fallback — weak heuristic (tool_use count + marker). Не блокер для эпика, но S5 остаётся в residual. + +Budget: 0.5h × 3 smoke-tests + verification = **1.5h total**. Without these — три architectural assumption неверифицированы → суммарный риск 5-15h rework. + +### 3.2. Subagent gate inheritance (env-based, NOT text-prefix) + +При вызове Task tool gate (PreToolUse): + +1. Пишет `~/.claude/runtime/subagent-inheritance-.json`: + + ```json + { + "parent_session_id": "", + "allowed_actions": ["Skill(matching)", "Task(subagent_type=matching)", ...], + "created_at": "" + } + ``` + + **Path-injection защита:** поля `parent_router_state_path` / `parent_chain_state_path` **удалены** из schema. Субагент gate **derives** оба пути **hardcoded pattern** из `parent_session_id`: `` path.join(os.homedir(), '.claude', 'runtime', `router-state-${parent_session_id}.json`) `` и аналогично для chain-state. Принимать произвольные absolute paths из inheritance-файла нельзя — контроллер мог бы записать фейковый router-state в `/tmp/fake-state.json` и указать его → субагент читает фейк → bypass. + +2. Хук `subagent-prompt-prefix.mjs` устанавливает env vars для процесса субагента: + - `CLAUDE_PARENT_SESSION_ID=` + - `CLAUDE_GATE_INHERIT=true` + - `CLAUDE_INHERITANCE_FILE=~/.claude/runtime/subagent-inheritance-.json` + +3. Gate-хук **тот же** `enforce-router-gate.mjs` (тот же `.claude/settings.json` наследуется субагентом) при старте проверяет: + - Если `CLAUDE_GATE_INHERIT=true` И inheritance-файл существует и валиден → derives parent state paths hardcoded из `parent_session_id` и читает parent's router-state, chain-state. + - Свой собственный router-state субагента игнорируется (пустой при старте — silence-режим был бы deadlock). + - **Если `CLAUDE_GATE_INHERIT` undefined / inheritance-файл missing / malformed → fail-CLOSE** (block all mutating tools, reason: «subagent inheritance missing — escalate to parent»). Symmetric с §3.3 «fail-CLOSE при missing state». + - Decisions субагента логируются в **parent's** `router-gate-decisions.jsonl` с префиксом `[subagent:]`. + +4. Inheritance-файл удаляется когда Task tool возвращает результат родителю. + +**Block-file write:** subagent gate ТАКЖЕ пишет `~/.claude/runtime/subagent-block-.json` каждый раз когда decide=block для любого tool subagent'а. См. §3.4 — единая subagent-gate logic. + +### 3.3. Failure modes (fail-CLOSE) + +Когда gate не может определить состояние — block all mutating tools: + +| Ошибка | Поведение | Reason в ответе | +|---|---|---| +| `router-state-.json` отсутствует | Trigger Поведение 4 (silence) → AskUserQuestion required | "Router state missing — escalate" | +| `router-state` malformed (JSON parse error) | Block ALL mutating | "Router state corrupt — re-prompt required" | +| `chain-state` malformed | Reset to empty + log warning | "Chain state corrupt — restarting fresh" | +| Gate сам падает (uncaught exception) | Log в `~/.claude/runtime/gate-errors.jsonl` + block ALL mutating | "Gate internal error — see logs" | +| Hard-deny path не доступен для проверки | Block (assume protected) | "Path-check failed — defaulting to deny" | + +Никаких fail-open ради «работает хоть как-то». + +### 3.4. Subagent constraints + BLOCKED-protocol через side-channel + +Субагент работает с **ограниченным** set'ом по сравнению с parent: + +- **`AskUserQuestion` НЕ в baseline субагента.** При попытке вызвать — gate субагента блокирует с reason: «Subagents cannot escalate to user — return BLOCKED status to parent». +- **Recursive Task запрещён.** При попытке вызвать Task внутри Task — gate блокирует. Глубина max 1. +- **Limit parallel Task:** max 3 параллельных Task tool calls в одном turn'е родителя. 4-й и далее → block с reason «parallel subagent limit reached». +- При lockout субагент возвращает Task tool response: `{status: "BLOCKED", reason: "", parent_should_askuser: true}`. Родитель видит → сам эскалирует к заказчику через свой AskUserQuestion. + +**BLOCKED-protocol через out-of-band block-file (закрытие class controller-writable signals):** + +Сигнал переносится в side-channel который контроллер не контролирует. Controller-written response text fundamentally fake-able; side-channel write через subagent gate-process в protected runtime-файл — не fake-able. + +**Механизм:** + +1. **Parent gate при PreToolUse(Task)** запоминает `tool_use_id` (Anthropic-assigned для этого Task tool call — controller НЕ контролирует generation; уже используется в §3.2 для `subagent-inheritance-.json`). +2. **Subagent gate при decide=`block`** (любой tool, любое Поведение) — atomic append в `~/.claude/runtime/subagent-block-.json` запись `{ts, tool_name, reason, decision_kind}` ДО возврата block-decision в subagent process. proper-lockfile (§3.5) для concurrent blocks. +3. **Parent gate при PreToolUse(next tool после Task return)** — читает block-file. Decision: + +| Состояние block-file | tool_use count subagent'а | Parent decision | +|---|---|---| +| File exists, blocks непустой | любое | **Block ALL mutating** до AskUser-эскалации с reason из первого block. Логировать в decisions.jsonl как `subagent_blocked_escalate` | +| File missing | > 0 | **DONE** — subagent работал без блоков. Продолжаем normal flow | +| File missing | == 0 | **Assume BLOCKED** (defensive, gate-crash possible). Parent обязан AskUser | +| File read FS error | любое | fail-CLOSE per §3.3 | + +4. **Cleanup:** parent gate удаляет block-file после чтения. Background cleanup script (cron / lefthook): orphans > 24h → delete (защита от parent gate crash после read но до delete). + +**Anti-forgery (3 защитных слоя — почему controller не подделает):** + +- **Path derived из `tool_use_id`** — Anthropic CLI assigns при Task spawn. Controller передаёт `subagent_type` и `prompt`, но **не** tool_use_id. Невозможно заранее знать какой ID будет, чтобы pre-emptively написать fake-файл. +- **File в `~/.claude/runtime/*`** → protected per §3.1. Edit/Write/MultiEdit blocked hard-deny. Bash `rm`/`mv`/`cp` в hard-blacklist. Controller не может delete/modify/create. +- **Пишет gate-процесс subagent'а**, не controller-written response text. Gate — отдельный node-процесс с тем же `enforce-router-gate.mjs` (env-inherited через §3.2). Когда gate решает `block`, он напрямую writes файл, controller text-response это никак не контролирует. + +**Adversarial check:** + +| Attack | Защита | +|---|---| +| Edit `enforce-router-gate.mjs` чтобы отменить write | Protected paths §3.1 — blocked | +| Delete block-file до parent read | `~/.claude/runtime/*` protected — rm/mv в hard-blacklist, blocked | +| Pre-emptive fake block-file с пустыми blocks | tool_use_id assign'ит harness, controller не знает заранее; Write на protected — blocked anyway | +| Subagent gate crash до first write | Defensive: file missing + tool_use count == 0 → assume BLOCKED | +| Subagent сделал read-only работу + был blocked на Write | Block-file существует с 1 блоком → parent escalates корректно | +| Race: 2 параллельных block writes от subagent | proper-lockfile (§3.5) — sequential consistency | + +### 3.5. Atomic state writes / file locking + +Все state-файлы (`router-state-*.json`, `chain-state-*.json`, `askuser-decisions-*.jsonl`, `router-gate-decisions.jsonl`, `subagent-inheritance-*.json`, `subagent-block-*.json`) пишутся atomically: + +**JSON state-файлы (one-shot writes):** + +- Запись через `write-to-tmp + rename` pattern: `fs.writeFileSync(target + '.tmp', data)` затем `fs.renameSync(target + '.tmp', target)`. POSIX `rename` атомарен; на Windows — близко к атомарному. + +**JSONL append-only логи:** + +- `fs.appendFileSync(target, line)` — атомарен для writes <`PIPE_BUF` (4096 bytes на POSIX) если flag `O_APPEND` установлен. Каждая строка <300 bytes — fits. +- На Windows — appendFileSync через `O_APPEND` тоже atomic для small writes. + +**File locking при параллельных tool calls:** + +- Gate использует `proper-lockfile` npm package (cross-platform file locks). +- Lock acquired на entry в decide function, released на exit. +- Lock timeout 1s — если другой gate-call держит lock дольше → fail-CLOSE (assume contention означает что-то странное). + +### 3.6. Gate budget / timeout / state cache + +Gate имеет жёсткий бюджет времени **2 секунды** на одну decision. Если превышен — fail-CLOSE (block с reason «gate budget exceeded»). + +**Оптимизации в gate:** + +- **In-memory state cache** (per gate-process) с TTL 5s: router-state и chain-state читаются раз, кэшируются. Следующий tool call в той же сессии в течение 5s — из кэша. +- **Lazy transcript parsing:** gate читает только last 5 turns transcript (не весь файл). Этого достаточно для проверки `ask_user_called_this_turn`, `skill_invoked_matching`, `is_direct_invocation`. +- **Configurable budget** через `~/.claude/runtime/gate-config.json`: + + ```json + { + "max_decision_time_ms": 2000, + "state_cache_ttl_ms": 5000, + "transcript_lookback_turns": 5 + } + ``` + +### Chain-state файл + +`~/.claude/runtime/chain-state-.json`: + +```json +{ + "chain_active": ["#55", "#19", "#56"], + "chain_step": 1, + "initialized_at": "2026-05-28T20:00:00.000Z", + "last_step_at": "2026-05-28T20:15:30.000Z" +} +``` + +Инициализируется когда router выдаёт chain. Обновляется PostToolUse-handler'ом при **success** matching skill invocation — chain_step++ (НЕ на PreToolUse `tool_input` match чтобы skill упавший mid-execution не прогрессировал chain без работы). Очищается когда `chain_step >= chain_active.length` или router даёт другую цепочку. + +**Chain-state TTL.** Chain-state считается expired и удаляется если: + +- `initialized_at + 24h < now` — цепочка протухла. +- **`transcript[-1].user.content`** (only — НЕ assistant turns; controller не может self-clear chain упоминанием маркера в собственном response) содержит явные explicit-reset фразы: `новая задача`, `сброс контекста`, `забудь предыдущее` (case-insensitive substring, точный match без частичных вхождений). + +При expiry — chain-state удаляется, gate переходит в Поведение 4 (silence) если новый router-state не даёт recommendation. + +**Chain progression semantics.** `chain_step++` инкрементируется НЕ при PreToolUse `tool_input` match, а при **PostToolUse success** matching skill/Task. Это требует регистрации gate ещё на PostToolUse event (lightweight handler — только инкремент chain_step при `tool_use_id` соответствующем skill, который PreToolUse разрешил по chain_step). Иначе skill упавший mid-execution прогрессирует chain без работы. Реализация: PostToolUse-handler читает `chain-state.json`, проверяет `tool_use_id` против `expected_node`, инкрементирует если success (PostToolUse fires только на success — verify через Smoke 2 §3.2.0). + +**Chain SoT.** Для определения `chain_step` gate использует **`chain-state.json::chain_step` как Single Source of Truth**, НЕ transcript-lookback. Transcript-lookback (§3.6, 5 turns) используется только для current-turn флагов `askuser_called_this_turn` / `skill_invoked_matching` / `is_direct_invocation` — они per-turn, помещаются в окно. Это критично для длинных chain >5 turns (через transcript-only step-detection терялась бы). + +### Logging + +`~/.claude/runtime/router-gate-decisions.jsonl`: + +```json +{"ts": "2026-05-28T20:00:01.123Z", "session_id": "abc", "tool_name": "Edit", "decision": "block", "reason": "recommendation #19 not invoked, AskUserQuestion not called yet", "state": {"rec_node": "#19", "rec_chain": [], "chain_step": 0, "askuser_called": false, "skill_invoked_matching": false, "is_direct_invocation": false}} +``` + +Append-only, ~1KB на запись × ~300 записей/день = ~300KB/день. Читается brain-retro и self-retrospect. + +--- + +## 4. Decision Flow (логика gate) + +Gate определяет одно из 4 поведений на основе state: + +### Поведение 1 — Прямое поручение заказчика (Direct invocation) + +**Детектор: strict whitelist (никаких fuzzy regex).** Direct invocation **только** через literal pattern match. Все regex case-insensitive Cyrillic-aware через `/.../iu` flags: + +| Pattern | Пример | Что разрешает | +|---|---|---| +| `^/[a-z0-9_-]+($\|\s)` | `/brain-retro` / `/code-review --ultra` | Slash-команда в начале prompt'а | +| `(вызови\|примени)\s+Skill\([^)]+\)` | `вызови Skill(superpowers:writing-plans)` | Literal call-syntax | +| `использ(уй\|уйте)\s+#\d+` | `используй #19` / `Используйте #19` | Literal по реестру | +| `(делай\|сделай\|вызови\|примени\|используй)\s+` | `делай subagent-driven-development` / `Сделай writing-plans` | Literal имя из реестра (точное совпадение с node `name` или `slug`). 5 morphology variants | + +**НЕ детектится как direct invocation:** + +- `продолжай`, `продолжи`, `делай дальше` — разговорное продолжение, не указание скила +- `делай как считаешь нужным` — передача решения мне +- `сделай Х`, `можешь сделать`, `давай делать` — без явного skill-name +- `вызови что-то` / `используй какой-нибудь скил` — без exact skill-name + +**Если direct invocation детектирован:** + +- Tool-call матчит указанный skill → **allow** без AskUserQuestion. +- Tool-call НЕ матчит → **block** с reason: «заказчик указал X через direct invocation, текущий tool — другое». +- Read-only baseline (Read/Grep/Glob/LS/TodoWrite/AskUserQuestion) разрешены всегда. + +**Stale registry — mandatory AskUser.** + +Если префикс-pattern matched (например `делай foo-bar`), но `foo-bar` **не найден** в registry — gate **обязан** заблокировать любой mutating tool до **mandatory AskUserQuestion** с явным форматом: «Пользователь указал skill `foo-bar`, но он не найден в реестре (возможно опечатка или переименован). Router рекомендует ``. Что делать?» с опциями: + +1. «Имел в виду `` — делать его» (если fuzzy-match similarity > 0.7). +2. «Делать `` по рекомендации router'а». +3. «Делать direct без скила». +4. «Остановиться — переформулирую prompt». + +Read-only baseline разрешён всегда. Логируется в decisions.jsonl как `direct_invocation_unmatched_registry_askuser_required: ` для surface в brain-retro. + +**Multiple direct invocations в одном prompt'е.** + +Если в prompt'е несколько direct invocations (например `вызови writing-plans, потом делай subagent-driven`): + +- **Первая** invocation выполняется без AskUserQuestion. +- **Вторая и далее** требуют AskUserQuestion после завершения первой (`Ты указал A, B, C — A выполнен, продолжать с B?`). +- Не разрешается chained execution всех direct invocations подряд без явного approval per step. + +### Поведение 2 — Роутер дал одиночную рекомендацию + +Триггер: `rec_node !== null AND rec_chain.length === 0`. + +| Состояние turn'а | Tool | Решение | +|---|---|---| +| askuser_called=false, skill_invoked_matching=false | Read/Grep/Glob/LS/TodoWrite/AskUserQuestion/read-only MCP | allow (safe base) | +| askuser_called=false, skill_invoked_matching=false | Skill matching rec_node / Task subagent_type matching | allow + unlock turn | +| askuser_called=false, skill_invoked_matching=false | Edit/Write/MultiEdit/NotebookEdit/Bash/Skill non-matching/Task non-matching | block — «Router рекомендовал X, вызови AskUserQuestion с предложениями» | +| askuser_called=true, skill_invoked_matching=false | Любой tool | allow (заказчик уже ответил, выполняй выбранный путь) | +| skill_invoked_matching=true | Любой tool | allow (skill уже выполнен, продолжаем работу) | + +### Поведение 3 — Роутер дал цепочку + +Триггер: `rec_chain.length >= 1` (включая chain длины 1). + +Gate инициализирует chain-state (если не инициализирована) с `chain_active = rec_chain, chain_step = 0`. + +Если новый router-state даёт ДРУГОЙ rec_chain (не пустой и не равный текущему chain_active) — chain-state переинициализируется с новым chain. Если новый router-state даёт пустой rec_chain — текущая chain_state сохраняется (мы внутри уже идущей цепочки). + +`expected_node = chain_active[chain_step]`. + +| Состояние turn'а | Tool | Решение | +|---|---|---| +| askuser_called=false, expected_skill_invoked=false | Safe base | allow | +| askuser_called=false, expected_skill_invoked=false | Skill matching expected_node / Task subagent_type matching | allow + unlock turn (chain_step++ происходит на PostToolUse success) | +| askuser_called=false, expected_skill_invoked=false | Любой mutating tool / Skill non-matching | block — «Цепочка [...], сейчас ждём шаг N (X). Вызови AskUserQuestion для одобрения» | +| askuser_called=true | Любой tool | allow | +| expected_skill_invoked=true | Любой tool | allow (chain_step уже инкрементирован PostToolUse-handler'ом при success предыдущего skill) | +| chain_step >= chain_active.length | Любой tool | allow (chain complete, clear chain-state) | + +### Поведение 4 — Роутер молчит + +Триггер: `rec_node === null AND rec_chain.length === 0 AND chain-state empty`. + +| Состояние turn'а | Tool | Решение | +|---|---|---| +| askuser_called=false | Safe base | allow | +| askuser_called=false | Любой mutating tool (Edit/Write/Bash/Skill/Task) | block — «Роутер молчит. Вызови AskUserQuestion с 1/2+/0 форматом по количеству подходящих скилов» | +| askuser_called=true | Любой tool | allow (заказчик ответил, выполняй) | + +### AskUserQuestion-форматы для Поведения 4 + +| Сколько подходящих скилов вижу | Формат AskUserQuestion | +|---|---| +| 2 и больше | Перечисляю варианты, явно указываю который рекомендую + почему. Заказчик выбирает | +| Ровно 1 | Один вариант — спрашиваю «делать со скилом X или без него?» | +| Ни одного | Спрашиваю «продолжить direct без скила или остановиться?» | + +В **любом** AskUserQuestion (Поведения 2/3/4) controller обязан указать: + +- Что роутер рекомендовал (или что он молчит). +- Моя честная оценка — подходит ли рекомендация, если нет — почему. +- 3-4 варианта на выбор с цена каждого (плюсы / минусы). + +### 4.5. AskUserQuestion answer parsing + +Gate не unlock'ает turn просто по факту вызова AskUserQuestion. Gate **читает ответ заказчика** из transcript (AskUserQuestion tool result содержит структурированную информацию о выбранной опции) и интерпретирует: + +| Ответ заказчика | Действие gate'а | +|---|---| +| Выбранная опция содержит `стоп` / `отмена` / `не делать` / `ничего` / `остановись` / `cancel` / `stop` (case-insensitive) | Gate **остаётся заблокированным**. Никакого unlock | +| Выбранная опция матчит конкретный skill/tool/action в формуле «делать X» / «выполнить X» / «использовать X» | Gate unlock'ает **только** этот конкретный skill/tool. Остальные mutating tools всё ещё блокируются (требуется новый AskUserQuestion для них) | +| Выбранная опция явно говорит «direct, без скила» / «продолжить без skill'а» / «делать напрямую» | Gate unlock'ает Edit/Write/MultiEdit (но не Bash mutating и не Skill/Task non-baseline). Bash требует whitelist (§5.1) | +| Свободный ответ (заказчик выбрал «Other» и написал текст) | Gate анализирует free-form text по тем же правилам. Если интерпретация неоднозначна — gate требует ещё одно AskUserQuestion для уточнения | +| **Любой ответ не матчащий ни один из 4 классов выше** — **default-CLOSE** | Gate **остаётся заблокированным**. Записывается `gate_interpretation: "no_match_remain_blocked"`. Контроллер обязан вызвать второй AskUserQuestion с переформулированными опциями (включая явные keyword-маркеры stop/approve). Если и второй ответ unmatched → 2/2 limit достигнут → fail-CLOSE: «AskUserQuestion ambiguity loop — заказчик пусть переформулирует prompt с явным указанием действия» | +| **Опция содержит «выполнить ``» / «approve ``» / «делать ``» / «Да — выполнить ``»** где `` matches pattern `git (rebase\|reset\|clean\|checkout --\|branch -[DfF]\|push --force\|stash drop\|cherry-pick\|revert)` | Gate пишет в `askuser-decisions-.jsonl`: `gate_interpretation: "approve_git_operation"`, `approved_tool: "Bash"`, `approved_action_pattern: ""`, `consumed: false`. `enforce-branch-switch.mjs` хук читает эту запись при next Bash tool-attempt — если `tool_input.command` matches `approved_action_pattern` strict exact (case-sensitive), И запись `consumed: false`, И `ts` within 5 minutes — gate allow, helper пишет `consumed: true` обратно (one-shot). Closes dangerous git operations через explicit per-operation consent | +| AskUserQuestion вызван, но заказчик ещё не ответил (in-flight) | Gate ждёт ответ — pre-answer state, никакого unlock | + +Все решения логируются в `~/.claude/runtime/askuser-decisions-.jsonl`: + +```json +{"ts": "2026-05-28T20:30:01.123Z", "question": "...", "options": [...], "chosen": "стоп — не делать", "gate_interpretation": "stop_remain_locked", "subsequent_tools": []} +``` + +**AskUserQuestion counter.** Gate ведёт счётчик `askuser_count_this_turn` в state-файле. Лимит **2 AskUserQuestion за один turn**. 3-я попытка → gate **fail-CLOSE**: + +``` +AskUserQuestion ambiguity loop detected (2/2 limit exhausted в этом turn'е). +Чтобы продолжить — отправь новый prompt с одной из явных фраз: + • "стоп" / "отмена" — gate останется заблокированным + • "делай " — direct invocation конкретного скила + • "продолжить без скила" — direct без skill'а (Edit/Write разрешены, не Bash) + • slash-команда типа "/brain-retro" — direct skill через slash +Счётчик AskUserQuestion сбросится на новом prompt'е. +``` + +### 4.6. Post-skill partial unlock + +После вызова matching Skill (или Task subagent) gate **частично** unlock'ает turn: + +| Tool | Разрешено после Skill match | +|---|---| +| Read / Grep / Glob / LS / TodoWrite / AskUserQuestion | ✅ Always | +| Next chain step (если chain активна) | ✅ Если матчит chain[chain_step] | +| Task subagent matching active recommendation | ✅ | +| Edit / Write / MultiEdit / NotebookEdit | ⚠️ Только после **дополнительного** AskUserQuestion с явным «выполнить такое-то изменение» | +| Bash mutating | ❌ Требует AskUserQuestion с конкретной командой (§4.5) + §5.1 whitelist | +| Skill / Task non-matching | ❌ | + +После вызова рекомендованного skill'а я не могу автоматически делать **любые** правки — нужен дополнительный явный approval от заказчика на конкретное действие. + +Soft-mitigation: rationalization-audit детектит pattern «Skill X called → 3+ tools потом без TodoWrite update с цитированием skill output» → flag в next turn (warn-only). + +### 4.7. AskUserQuestion question quality detector + +`enforce-rationalization-audit` расширяется новыми детекторами для AskUserQuestion content: + +**Block (hard):** + +- Опция «остановиться» / «отмена» / «cancel» **обязательна** в списке. Если её нет — gate **блокирует** вызов AskUserQuestion и требует переформулировать вопрос с opt-out. + +**Soft warnings (surface в next turn):** + +- Лейбл одной из опций содержит «(рекомендую)» / «(recommended)» / «(safe)» / «(quick)» / «(подходит лучше всего)» / «(оптимально)» — потенциальный leading question. Flag. +- Сильная разница в длине опций: **ratio > 4× между самой длинной и самой короткой**. Flag. +- **First-option position bias:** если **первая** опция в списке содержит suggestive lexicon (рекомендую/safe/quick/подходит/оптимально/best) И опции не отсортированы alphabetically/by-action-type — soft flag «возможно first-position bias». Detection: keyword-list scan на позиции 1 vs позиции 2+. +- Вопрос не относится к active recommendation / current task — потенциальный bypass attempt. Flag. + +**Off-topic detection при silence:** когда recommendation отсутствует (Поведение 4), off-topic check сравнивает вопрос AskUser не с recommendation (её нет), а с **`task_classification`** field из router-state. Если classification = `bugfix`, а вопрос про дизайн / погоду / неотносящееся к bugfix — soft flag. Использует keyword-overlap heuristic (simple — без LLM call). + +Flags попадают в `rationalization-flags-.jsonl` и сюрфейсятся в next prompt через `enforce-prompt-injection`. 90% реальных случаев ловятся — creative leading questions неискоренимы. + +--- + +## 5. Безопасная база (всегда разрешено) + +Эти tools never blocked, любое поведение: + +- `Read` — просмотр файлов (кроме protected paths §3.1) +- `Grep` — поиск по содержимому +- `Glob` — поиск по имени +- `LS` — список файлов +- `TodoWrite` — мой список задач (внутренний) +- `AskUserQuestion` — обращение к заказчику (но gate проверяет content per §4.7 и parse-ит ответ per §4.5) +- `ListMcpResourcesTool` — список MCP-ресурсов +- `ReadMcpResourceTool` — чтение MCP-ресурса (только для tools явно помеченных read-only в реестре; default — block) +- Текстовый ответ (нет tool_use) + +`Bash` **НЕ** в безопасной базе — для него отдельные правила в §5.1. + +### 5.1. Bash content rules + +Gate инспектирует **content** Bash-команды (string в `tool_input.command`). Парсит через простой shell-aware tokenizer (split по `;`, `&&`, `||`, `|`, `&` (background) — далее извлекает first token каждой ветви как command). **Tokenizer rule:** `&` split применяется только если **не** часть `&&` (lookahead `&(?!&)`). Закрывает `git status & mv evil good` атаку — токенизируется на 2 команды, `mv` ловится hard-blacklist'ом. + +**Tokenizer limitation (acknowledged):** простой split не handle'ит quoted args / escaped chars (`git log --grep='foo;bar'` ломается на quoted `;`). Принимается как acceptable friction — false-positive блок проще обойти через AskUser, чем риск bypass через quote-escaping. + +**Default-deny** — если command не в whitelist и не в conditional-whitelist, gate блокирует. + +**Whitelist (всегда разрешено, любое поведение):** + +| Команда | Допустимые args | +|---|---| +| `git status` | без mutating флагов | +| `git log` / `git show` / `git diff` / `git blame` / `git format-patch` | без `--exec`, **без `--output=*`, без `-o `, без `--file=*`** (write to file через whitelisted command) | +| `git rev-parse` / `git merge-base` | любые | +| `git branch --show-current` | (только этот sub-command для `git branch`) | +| `git remote -v` / `git remote show` | без add/set-url | +| `ls` / `pwd` / `wc` / `head` / `tail` / `file` / `stat` | без `>` redirect, **path-deny overlay на ВСЕ path-args** | +| `grep` / `egrep` / `fgrep` | без `--exec`, без `-l` после `>`, **path-deny overlay на ВСЕ path-args** | +| `cat` / `less` / `more` | без `>` redirect, **path-deny overlay на ВСЕ path-args** | +| `node` | без `-e` / `--eval` / `-p` / `--print`, **без `-r` / `--require` / `--import` / `--experimental-loader`** (preload arbitrary module before main script) | +| `npx vitest run ...` / `npx vitest --version` | любые run-args, не `--reporter` с suspicious patterns | +| `npm test` / `npm run test` / `npm run lint:*` (read-only scripts) | любые | +| `php artisan` (read-only commands только: `list`, `route:list`, `migrate:status`) | по whitelist | +| `composer show` / `composer outdated` | без `--update` | + +**Hard-blacklist (никогда не разрешено, даже после unlock):** + +- `rm` / `mv` / `cp` (любые формы — file mutation) +- `chmod` / `chown` / `chgrp` +- `>` / `>>` — redirect к файлу (создаёт/перезаписывает файл) +- `&&` / `||` chaining двух mutating commands +- `node -e` / `node --eval` / `python -c` / `bash -c` / `sh -c` / `eval` — arbitrary code execution +- `git push` / `git commit` / `git merge` / `git rebase` / `git reset` / `git checkout` / `git switch` / `git pull` / `git stash` / `git cherry-pick` / `git revert` / `git branch -f` / `git branch -d` +- `composer install` / `composer update` / `composer require` / `composer remove` +- `npm install` / `npm update` / `npm remove` / `yarn add` / `pnpm add` +- `npx claude-*` — запуск Claude изнутри Claude +- `curl -X POST/PUT/DELETE/PATCH` / `wget --post-data` — outbound mutations +- Любые pipe-команды (`|`) где receiver — mutating command + +**Sub-shell / heredoc broad sweep — команды содержащие любой из следующих токенов hard-blocked всегда (даже после unlock):** + +- `` ` `` (backtick — command substitution) +- `$(...)` — POSIX command substitution +- `<(...)` / `>(...)` — process substitution +- `<<` / `<<-` — heredoc + +Некоторые legitimate uses (`git log $(echo HEAD)`) ломаются. Принимаем как acceptable friction для закрытия sub-shell bypass attack surface. + +**Conditional whitelist (разрешено только после явного AskUserQuestion approval per §4.5 ИЛИ Skill match per `SKILL_BASH_ALLOW`):** + +- Specific commands из ответа заказчика (например если он выбрал «выполнить `npx vitest run`» — тогда разрешена эта exact command). +- После Skill match для конкретной семантики — разрешённые Bash-команды определяются **hardcoded mapping `SKILL_BASH_ALLOW`** в gate-code (не Skill-controlled, иначе Skill мог бы запросить произвольный Bash): + +```js +// tools/router-gate-decide.mjs — SKILL_BASH_ALLOW +const SKILL_BASH_ALLOW = { + 'superpowers:test-driven-development': [ + /^npx vitest run( |$)/, + /^npm test( |$)/, + /^php artisan test( |$)/, + /^composer test( |$)/, + ], + 'superpowers:verification-before-completion': [ + /^npx vitest run( |$)/, + /^composer test( |$)/, + /^npm run lint:[a-z-]+( |$)/, + ], + 'regression': [ + /^npx vitest run( |$)/, + /^composer test( |$)/, + /^php artisan test( |$)/, + /^npm run lint:[a-z-]+( |$)/, + /^npm run build( |$)/, + ], + // ... другие skills — расширяется по требованию из реальной работы. +}; +``` + +Маппинг — **в коде gate'а, не в Skill определении**. Skill не может расширить свой allow-list (это снова controller-writable signal). Расширение mapping — только через `tools/router-gate-decide.mjs` Edit + tests + PR review (а файл — в §3.1 protected paths). + +**Path-deny overlay (§3.1) — applied to ALL path-args:** для **каждого** path-args whitelisted reading commands (`cat`/`tail`/`head`/`grep`/`less`/`more`/`file`/`stat`/`wc`/`ls`) gate нормализует path (§3.1 path normalization: resolve + realpath + case-fold + env expansion) и проверяет против protected list. Пример: `tail -n 100 ~/.claude/runtime/router-state-abc.json` — `tail` whitelisted, но `~/.claude/runtime/*` в protected → block. Все reading commands проверяются единообразно (не только `cat`). + +**File-watcher для script execution — session-scoped, NOT turn-scoped:** gate tracks `edited_files_this_session` в `~/.claude/runtime/edited-files-.json` (session-wide persistence). Если в session был Edit/Write на `*.js` / `*.test.js` / `*.mjs` / `*.py` / `*.test.mjs` файл, последующие `node X`, `npx vitest run X`, `python X` блокируются если target path в session-list. Reset происходит **только** на: + +- (a) **`git commit` exit code 0 AND lefthook GREEN.** Реализация: gate регистрирует PostToolUse handler на Bash `git commit*` и проверяет `tool_result.exit_code === 0` И `tool_result.stdout` не содержит lefthook FAIL markers (`✗`, `exit status N>0`, `summary: ... FAIL`). НЕ на attempt — если commit fail'ит из-за pre-commit хука, watcher state НЕ очищается. +- (b) явный markup `` в user-prompt (НЕ в controller response — same pattern как chain-state restriction). +- (c) новый session-id. + +Reason: «recently-edited script cannot be executed without explicit approval — request via AskUserQuestion». + +Turn-scoped tracking позволял bypass — Edit в turn N, run в turn N+1, watcher state очищен. Session-scoped закрывает. + +Default = block. Reason: «Bash command «{command}» не в whitelist; либо запроси конкретное разрешение через AskUserQuestion, либо используй read-only альтернативу». + +### 5.2. Static content scan for executable scripts + +Перед разрешением `node