From e9ba6fb9a27dde8784d087d4ef2b6c40a6c9f1b1 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: Thu, 18 Jun 2026 10:52:47 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20B+C=20part1=20-=20=D1=81=D0=BC=D0=BE?= =?UTF-8?q?=D1=82=D1=80=D1=8F=D1=89=D0=B8=D0=B5=20=D0=B8=D0=BD=D1=81=D1=82?= =?UTF-8?q?=D1=80=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D1=8B=20=D1=81=D0=B2=D0=BE?= =?UTF-8?q?=D0=B1=D0=BE=D0=B4=D0=BD=D1=8B=20=D0=BF=D0=BE=D0=B4/=D0=B1?= =?UTF-8?q?=D0=B5=D0=B7=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=B0=20(isQueryOnly)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новый предикат isQueryOnly (ToolSearch/WebFetch/WebSearch/read-only браузер: navigate/snapshot/wait/screenshot) проведён во все ветки стены: смотрящие и спрашивающие инструменты проходят и в разговорном режиме (осмотр чужого сайта без плана), и под опечатанным планом, не двигая указатель шагов. Действующие инструменты (клик/ввод) сюда не входят - они пойдут через сеанс осмотра (часть 2 B+C). Свод зелёный: 4229 passed, 2 skipped. Co-Authored-By: Claude Opus 4.8 --- ...6-06-18-wall-interactive-session-design.md | 143 ++++++++++++++++++ tools/enforce-supreme-gate.mjs | 23 ++- tools/enforce-supreme-gate.test.mjs | 44 +++++- 3 files changed, 205 insertions(+), 5 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-18-wall-interactive-session-design.md diff --git a/docs/superpowers/specs/2026-06-18-wall-interactive-session-design.md b/docs/superpowers/specs/2026-06-18-wall-interactive-session-design.md new file mode 100644 index 0000000..ae90da2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-wall-interactive-session-design.md @@ -0,0 +1,143 @@ +# Дизайн: осмотр и инструменты под стеной — «сеанс» (объединяет пункты B + C) + +**Дата:** 2026-06-18 · **Репозиторий:** claude-brain (управляющий слой) · **Кодовая фраза:** «роутер-наставник». +**Источник:** баги `bags/2026-06-17-wall-interactive-walk-blockers-bug.md` (Баги 1/2/3). Пункты **B** (`tools-json`) +и **C** (интерактивный осмотр) допила эталона — слиты в один механизм. +**Поглощает:** `2026-06-18-wall-impl-tool-allowlist-design.md` (B как отдельная спека — отменена этой). +**Брат:** `2026-06-18-wall-impl-read-freedom-design.md` (A — чтение). Тот же принцип «не-шаг сбоку от очереди». +**Тип:** дизайн-доказательство (brainstorming → writing-plans). Правки `tools/*.mjs` — далее по церемонии TDD. + +--- + +## 1. Проблема (по факту, с цитатами) + +Три независимых стопора, все про «использовать инструменты / осматривать сайт под стеной»: + +1. **Инструменты режутся под планом.** observe-only = `{Read, Grep, Glob}` + read-only Bash + ([enforce-supreme-gate.mjs:78-82](../../../tools/enforce-supreme-gate.mjs#L78-L82)). `ToolSearch`, MCP + (`mcp__*`), `WebFetch`/`WebSearch`, браузер (`browser_*`) — НЕ в списке → под планом блок. Механизма + `tools-json` (заявить инструменты в плане) в коде нет (строка есть только в баге). +2. **В разговорном режиме инструменты тоже режутся.** Без печати плана пускаются только seed/observe/ + authoring ([enforce-supreme-gate.mjs:365-368](../../../tools/enforce-supreme-gate.mjs#L365-L368)) — + `ToolSearch`/браузер блокируются. Значит «просто посмотреть сайт без плана» невозможно. +3. **Интерактивный проход нельзя выразить шагами (корень).** Чтобы кликнуть/ввести, нужен `ref` элемента + из ЖИВОГО снимка страницы — на момент печати плана его не существует. Шаги матчатся по op+object + ([plan-lock.mjs:89-104](../../../tools/plan-lock.mjs#L89-L104)) — клик по динамическому `ref` так не + задать. Плюс `op:"Skill"` опечатывается, но указатель не двигает + ([enforce-supreme-gate.mjs:265](../../../tools/enforce-supreme-gate.mjs#L265)) → дедлок. + +**Вывод владельца (2026-06-18):** «заранее расписать, что и сколько смотреть, нельзя — ни на нашем сайте, +ни на чужом». Значит «план просмотра по кликам» — тупик. План должен фиксировать **намерение и +результат**, а не клики. + +## 2. Принцип + +- **Не-мутирующее действие не расширяет набор разрешённого → безопасно** (как чтение в A). Смотреть/ + спрашивать инструментом — это «чтение», только внешнее. +- **Клик/ввод — действие, но НЕ над нашим репозиторием.** Несущая защита стены — мутация репозитория + через шаг. Внешний клик репозиторий не меняет; деттерминированный якорь интерактива — то, что агент + в итоге **запишет к нам** (отчёт/наблюдения). +- **Указатель двухтактный (F-J)** — не-шаг очередь не двигает + ([enforce-supreme-gate.mjs:446-448](../../../tools/enforce-supreme-gate.mjs#L446-L448)). + +## 3. Решение — три части + +### 3.1 Смотрящие инструменты — свободны в ОБОИХ режимах +Именованный набор заведомо read-only пускается и в разговорном режиме, и под планом, без объявления: +- `ToolSearch`, `WebFetch`, `WebSearch`; +- read-only браузер: `browser_navigate`, `browser_snapshot`, `browser_wait_for`, `browser_take_screenshot`. + +Механика: новый предикат `isQueryOnly(toolUse)` рядом с `isObserveOnly` +([:78-99](../../../tools/enforce-supreme-gate.mjs#L78-L99)); `decideMode` пускает его И в ветке «нет +frozenPlan» (разговорный, [:365-368](../../../tools/enforce-supreme-gate.mjs#L365-L368)), И под планом +([:282](../../../tools/enforce-supreme-gate.mjs#L282)). Указатель не двигают, пишутся в тихий лог (A). +**Это закрывает «чужой сайт без плана»: смотрю и рассказываю, никакой печати не нужно.** + +### 3.2 «Сеанс осмотра» — новый тип шага для интерактива +Когда надо реально пройтись и пощёлкать (логин, формы, сценарий клиента) — план объявляет НЕ список +кликов, а **один шаг-сеанс**: + +``` +{ "op": "session", "goal": "<что осмотреть>", "tools": [<действующие инструменты>], "produces": "<путь итогового файла>" } +``` + +- **`tools`** — действующие инструменты сеанса (`browser_click`, `browser_type`, `browser_fill_form`, + `browser_select_option`, конкретные MCP). Это и есть бывший `tools-json`, теперь привязанный к сеансу. +- **`produces`** — файл(ы), которые сеанс ОБЯЗАН произвести (наблюдения/отчёт). ≥1. +- Пока указатель на шаге-сеансе: разрешены `tools` сеанса + смотрящие (3.1), **сколько угодно, по живым + `ref`** — стена клики с шагами НЕ сверяет (динамический `ref` — не проблема). Указатель НЕ двигается. +- **Сеанс закрывается** записью `produces`-файла: это обычный `Write`-шаг, он матчится и двигает + указатель дальше. Деттерминированный якорь = итоговый файл, а не клики. + +Этим снимается и **дедлок `op:"Skill"`**: навык не делается шагом — он объявляется в `skills-json` и +вызывается свободно (как сейчас, [:265](../../../tools/enforce-supreme-gate.mjs#L265)). `op:"Skill"` в +`steps-json` запрещаем с явным сообщением «Skill не может быть шагом — объяви в skills-json». «Проверка» +осмотра — это `produces`-отчёт, а не шаг-навык (убирает требование наставника «дай шаг verify»). + +### 3.3 Предохранитель (ядро безопасности) +`tools` сеанса **НИКОГДА** не содержат: +- `Write`/`Edit`/`MultiEdit`/мутирующий `Bash` — правки репозитория остаются ШАГАМИ плана (вкл. сам + `produces`-файл сеанса — это обычный шаг записи); +- floor-опасное (install/cloud-CLI/ssh/redirect/destructive, `matchBashHardBlacklist`, PowerShell-записи, + запись в `~/.claude/runtime`/секреты) — остаётся за полом/escape. + +Запрещённое имя в `tools` **отбрасывается с видимым предупреждением** (решение владельца Q2 = вариант «а»: +не валить план целиком из-за описки). Сеанс/`tools` входят в **хеш плана** — подмена после печати ломает +печать; наставник и судья видят `goal`/`tools`/`produces` при разборе. + +### 3.4 Логирование +Все вызовы инструментов и кликов под планом/в сеансе пишутся в тот же тихий лог, что impl-чтения (A): +только для ретро, без баннера, без порога-warn (зеркало решений A; решения владельца Q1/Q2 спеки A). + +### 3.5 Точки изменения в коде +- **`enforce-supreme-gate.mjs`**: `isQueryOnly` (новый, рядом [:78-99](../../../tools/enforce-supreme-gate.mjs#L78-L99)); + `decideMode` — пускать query-only в обоих режимах ([:282](../../../tools/enforce-supreme-gate.mjs#L282), + [:365-368](../../../tools/enforce-supreme-gate.mjs#L365-L368)); `decide` — ветка «указатель на + шаге-сеансе»: вызов из `frozenPlan` сеанса `tools` → allow без advance; `produces`-Write → обычный + матч-шаг с advance. +- **`plan-lock.mjs`**: распознать `op:"session"` (goal/tools/produces) в дереве плана; `produces` — + матч-якорь шага; применить предохранитель §3.3 (отбросить чёрный набор с предупреждением); запретить + `op:"Skill"` как шаг; сеанс+`tools` в хеш плана. +- **наставник/судья** (`mentor-verdict.mjs`/`judge-*`): показать `goal`/`tools`/`produces` сеанса в + разборе плана (чтобы ревью видело набор инструментов и обещанный результат). +- **read-LOG** (общий с A): принять записи tool-use/клик под планом. + +## 4. Что НЕ трогаем +- **Шаг-гейт для мутаций репозитория** (Write/Edit/Bash-write, вкл. `produces`-файл сеанса) — несущая + защита, без изменений. +- **Пол M5 / escape** — необратимое и floor-опасное по-прежнему через них. +- **Чтение A** — отдельная спека; общий тихий лог переиспользуем. +- **Машинерия указателя (F-J)** — не касаемся; инструмент/клик-не-шаг очередь не двигает. + +## 5. Критерий «починено» +- «Чужой сайт без плана»: в разговорном режиме работают `ToolSearch`/`WebFetch`/read-only браузер. +- Под планом-сеансом: `browser_click`/`type`/`fill` работают по живым `ref`, без escape на каждый клик, + сколько нужно; указатель не двигается. +- Сеанс закрывается записью `produces`-файла (обычный шаг двигает указатель). +- `op:"Skill"` как шаг — отбит с явным сообщением; навык из `skills-json` вызывается свободно. +- `tools` сеанса с `Write`/`rm -rf`/`ssh` → отброшены с предупреждением, под планом не проходят. +- Сеанс+`tools` в хеше плана: подмена после печати ломает печать. +- Ни клик, ни tool-вызов не двигают указатель (регресс F-J держит). +- Лог фиксирует tool-use/клики (видно в ретро). + +## 6. Тесты (TDD-набросок) +- `isQueryOnly`: ToolSearch/WebFetch/read-only браузер → true; browser_click/Write → false. +- `decideMode` без frozenPlan: query-only → allow (разговорный осмотр чужого сайта). +- `plan-lock` парсит `op:"session"`: goal/tools/produces извлечены; `produces` пуст → план невалиден. +- Предохранитель: `Write`/`ssh` в `tools` сеанса → отброшены + warn. +- `op:"Skill"` в steps-json → план отвергает шаг с сообщением. +- `decide` указатель на сеансе: `browser_click` (в `tools`) → allow, advance=false. +- `decide` указатель на сеансе: запись `produces`-файла → allow, advance=true (сеанс закрыт). +- `decide` указатель на сеансе: инструмент НЕ в `tools` и не query-only → block (default-deny держит). +- Хеш: план с другим `tools`/`produces` → другой `plan_id`. +- Регресс F-J: клик/tool-use не двигает указатель. + +## 7. Риски / открытые вопросы +- **Q-A (для владельца):** ограничивать ли длительность/объём сеанса (тайм-аут, максимум кликов)? + Предлагаю **нет** (зеркало «без порога» из A) — сеанс и так обязан кончиться записью `produces`, иначе + план не сдвинется. Незавершённый сеанс просто висит, как любой недоделанный план. +- **Q-B (для владельца):** один сеанс = один `produces`-файл, или можно несколько (наблюдения + отчёт)? + Предлагаю **разрешить несколько** — гибче; последний из списка закрывает сеанс. +- Наставник/судья должны понимать новый тип шага — иначе завернут как «непонятный шаг». Часть §3.5. +- Связанный хвост (баг read-block Баг-2 Дефект 1): наставник видит не все поля шага. Пересекается с + «показать goal/tools/produces наставнику» — закрывается тем же изменением §3.5. diff --git a/tools/enforce-supreme-gate.mjs b/tools/enforce-supreme-gate.mjs index 3afe928..44eeb36 100644 --- a/tools/enforce-supreme-gate.mjs +++ b/tools/enforce-supreme-gate.mjs @@ -77,6 +77,20 @@ export function isPlanDeclaredSkill(toolUse, frozenPlan) { // Зелёный проход по СПОСОБНОСТИ = «нет долговременного И нет исходящего эффекта». const OBSERVE_ONLY_TOOLS = new Set(['Read', 'Grep', 'Glob']); // локальные «смотрят» const EPHEMERAL_META_TOOLS = new Set(['TodoWrite']); // меняют только черновик сессии, не мир + +// B+C (2026-06-18): «смотрящие/спрашивающие» внешние инструменты — ничего не меняют, как чтение. +// Свободны и в разговорном, и под планом (decideMode). НЕ путать с isObserveOnly (локальный zero-effect): +// здесь намеренно входят WebFetch/WebSearch/ToolSearch и read-only браузер. Действующий браузер +// (click/type/fill/select) и MCP-запись сюда НЕ входят — они идут через tools-json сеанса. +const QUERY_ONLY_TOOLS = new Set(['ToolSearch', 'WebFetch', 'WebSearch']); +const READONLY_BROWSER_SUFFIXES = ['browser_navigate', 'browser_snapshot', 'browser_wait_for', 'browser_take_screenshot']; +export function isQueryOnly(toolUse) { + if (!toolUse || typeof toolUse.name !== 'string') return false; + const n = toolUse.name; + if (QUERY_ONLY_TOOLS.has(n)) return true; + return READONLY_BROWSER_SUFFIXES.some((s) => n.endsWith(s)); +} + export function isObserveOnly(toolUse, { classifyBash = classifyBashCommand } = {}) { if (!toolUse) return false; if (OBSERVE_ONLY_TOOLS.has(toolUse.name)) return true; @@ -279,6 +293,7 @@ export function decide({ toolUse, frozenPlan, frozenArtifact = null, stepPtr = 0 return { decision: 'block', reason: `${ev.gate.reason}${t} (W2)` }; } } + if (isQueryOnly(toolUse)) return { decision: 'allow', reason: 'смотрящий инструмент (query-only) — свободно под планом (B+C), указатель не двигается' }; if (isObserveOnly(toolUse)) return { decision: 'allow', reason: 'observe-only (зелёный проход по способности)' }; if (!frozenPlan) return { decision: 'block', reason: 'нет замороженного плана — действие вне плана запрещено (default-deny)' }; if (!verifyImpl(frozenPlan, key)) return { decision: 'block', reason: 'печать плана невалидна (seal/signature) — требуется заново одобрить план' }; @@ -363,14 +378,14 @@ export function decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr = 0, k return { decision: 'allow', mode: 'conversational', finishPlan: true, reason: 'владелец завершил план досрочно (plan-done) — печать снята, возврат в разговор' }; } if (!frozenPlan) { - if (isSeed(toolUse) || isObserveOnly(toolUse) || isAuthoringWrite(toolUse)) return { decision: 'allow', mode: 'conversational', reason: 'seed/observe/authoring (разговорный режим)' }; + if (isSeed(toolUse) || isObserveOnly(toolUse) || isQueryOnly(toolUse) || isAuthoringWrite(toolUse)) return { decision: 'allow', mode: 'conversational', reason: 'seed/observe/query/authoring (разговорный режим)' }; return { decision: 'block', mode: 'conversational', reason: 'разговорный режим: только думать/спрашивать (реализация — после печати артефакта и плана)' }; } if (!frozenArtifact || !verifyArtifactImpl(frozenArtifact, key)) { // F-B (аудит 2026-06-07): observe-only (Read/Grep/Glob/readonly-Bash/TodoWrite) пускаем // и в этом деградированном состоянии — инвариант finding 9 «смотрящие не душатся» + // согласованность с decide() (там observe-only allow безусловно). Бэкстоп держит только мутаторы. - if (isSeed(toolUse) || isObserveOnly(toolUse) || isAuthoringWrite(toolUse)) return { decision: 'allow', mode: 'conversational', reason: 'seed/observe/authoring (бэкстоп: артефакт не опечатан)' }; + if (isSeed(toolUse) || isObserveOnly(toolUse) || isQueryOnly(toolUse) || isAuthoringWrite(toolUse)) return { decision: 'allow', mode: 'conversational', reason: 'seed/observe/query/authoring (бэкстоп: артефакт не опечатан)' }; return { decision: 'block', mode: 'conversational', reason: 'нет опечатанного артефакта разговорной фазы — вернись в разговор (бэкстоп C-10)' }; } // SE-2 (fail-closed whitelist): энфорсмент ТОЛЬКО при live-block на ОБЕИХ печатях. @@ -382,8 +397,8 @@ export function decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr = 0, k // (→ разговорный), но ГРОМКО: warn-поле в возврат (owner-резюме гейта-1 покажет, wiring в C). const warnFields = judgeModeMismatch(frozenPlan.judge_mode, frozenArtifact.judge_mode) ? { warn: true, warnReason: 'judge_mode рассинхрон план≠артефакт — энфорсмент off (O18)' } : {}; - if (isSeed(toolUse) || isObserveOnly(toolUse)) { - return { decision: 'allow', mode: 'conversational', reason: 'seed/observe (не live-block-печать, SE-2)', ...warnFields }; + if (isSeed(toolUse) || isObserveOnly(toolUse) || isQueryOnly(toolUse)) { + return { decision: 'allow', mode: 'conversational', reason: 'seed/observe/query (не live-block-печать, SE-2)', ...warnFields }; } return { decision: 'block', mode: 'conversational', reason: 'не live-block-печать (shadow/наблюдение) — не одобрение к энфорсменту (SE-2)', ...warnFields }; } diff --git a/tools/enforce-supreme-gate.test.mjs b/tools/enforce-supreme-gate.test.mjs index 56786fa..b72e282 100644 --- a/tools/enforce-supreme-gate.test.mjs +++ b/tools/enforce-supreme-gate.test.mjs @@ -4,7 +4,7 @@ import { isSeed, SEED_SKILLS } from './enforce-supreme-gate.mjs'; import { decide, actionOf } from './enforce-supreme-gate.mjs'; import { runGate } from './enforce-supreme-gate.mjs'; import { decideMode } from './enforce-supreme-gate.mjs'; -import { isObserveOnly } from './enforce-supreme-gate.mjs'; +import { isObserveOnly, isQueryOnly } from './enforce-supreme-gate.mjs'; import { resolveStepPtr } from './enforce-supreme-gate.mjs'; import { resolveSessionId } from './enforce-supreme-gate.mjs'; import { signStepState, verifyStepState } from './enforce-supreme-gate.mjs'; @@ -337,6 +337,48 @@ describe('isObserveOnly (зелёный проход = нет долговрем }); }); +describe('isQueryOnly (B+C: смотрящие инструменты — свободны в обоих режимах)', () => { + it('ToolSearch/WebFetch/WebSearch → true (только смотрят/спрашивают)', () => { + for (const n of ['ToolSearch', 'WebFetch', 'WebSearch']) expect(isQueryOnly({ name: n })).toBe(true); + }); + it('read-only браузер (navigate/snapshot/wait_for/take_screenshot, в т.ч. mcp-префикс) → true', () => { + for (const n of ['mcp__playwright__browser_navigate', 'mcp__playwright__browser_snapshot', 'mcp__playwright__browser_wait_for', 'mcp__playwright__browser_take_screenshot']) + expect(isQueryOnly({ name: n })).toBe(true); + }); + it('действующий браузер (click/type/fill) → false (это не «смотреть»)', () => { + for (const n of ['mcp__playwright__browser_click', 'mcp__playwright__browser_type', 'mcp__playwright__browser_fill_form']) + expect(isQueryOnly({ name: n })).toBe(false); + }); + it('мутаторы и null → false', () => { + for (const n of ['Write', 'Edit', 'MultiEdit', 'Bash']) expect(isQueryOnly({ name: n })).toBe(false); + expect(isQueryOnly(null)).toBe(false); + }); +}); + +describe('B+C: смотрящие инструменты пускаются в обоих режимах (wiring)', () => { + it('разговорный (нет плана): ToolSearch/WebFetch/браузер-смотр → allow', () => { + for (const n of ['ToolSearch', 'WebFetch', 'WebSearch', 'mcp__playwright__browser_navigate']) + expect(decideMode({ toolUse: { name: n, input: {} }, frozenPlan: null, frozenArtifact: null, stepPtr: 0, key: 'k' }).decision).toBe('allow'); + }); + it('под планом: смотрящий (WebFetch) → allow без сдвига указателя', () => { + const plan = { artifact_id: null, steps: [{ n: 1, op: 'Edit', object: 'tools/foo.mjs' }] }; + const r = decide({ toolUse: { name: 'WebFetch', input: {} }, frozenPlan: plan, frozenArtifact: null, stepPtr: 0, key: 'k', verifyImpl: () => true, verifyArtifactImpl: () => true, normalize: (p) => String(p).toLowerCase() }); + expect(r.decision).toBe('allow'); + expect(r.advance).not.toBe(true); + }); + it('shadow-режим (не live-block печать): смотрящий тоже allow', () => { + const plan = { artifact_id: null, steps: [{ n: 1, op: 'Edit', object: 'tools/foo.mjs' }], judge_mode: 'shadow' }; + const art = { artifact_id: null, judge_mode: 'shadow' }; + const r = decideMode({ toolUse: { name: 'WebFetch', input: {} }, frozenPlan: plan, frozenArtifact: art, stepPtr: 0, key: 'k', verifyImpl: () => true, verifyArtifactImpl: () => true, normalize: (p) => String(p).toLowerCase() }); + expect(r.decision).toBe('allow'); + }); + it('артефакт не опечатан (бэкстоп C-10): смотрящий тоже allow', () => { + const plan = { artifact_id: null, steps: [{ n: 1, op: 'Edit', object: 'tools/foo.mjs' }] }; + const r = decideMode({ toolUse: { name: 'WebFetch', input: {} }, frozenPlan: plan, frozenArtifact: null, stepPtr: 0, key: 'k', verifyImpl: () => true, verifyArtifactImpl: () => false, normalize: (p) => String(p).toLowerCase() }); + expect(r.decision).toBe('allow'); + }); +}); + describe('decide() пропускает зелёный проход без шага плана', () => { it('Read проходит без плана', () => { const r = decide(ctx({ toolUse: { name: 'Read', input: { file_path: 'x' } }, frozenPlan: null, stepPtr: 0 }));