Skip to content

Граница принуждения хуков: что покрыто и что нет

Вынесено из agent-contract.md при закрытии powershell-tool-bypasses-bash-firewall — тема самостоятельная и вместе с матрицей покрытия каналов перестала помещаться в контракт (filesize-гейт). EN-зеркало: ../en/enforcement-coverage.md.

Полный разбор событий и покрытия — hooks-events.md. Кратко:

QG-0 (Rule 1) и scope-ACL (Rule 2) исторически висели только на Write|Edit, поэтому запись файла через Bash обходила обе проверки: cat > f <<EOF, sed -i, tee, dd of=, python -c "open(f,'w')" создавали и правили файлы без активной задачи и вне объявленного scope_paths (подтверждено вживую в сессиях #117/#118). С 1.8 эту дыру закрывает bash_write_gate (matcher Bash), а MultiEdit/NotebookEdit добавлены в matcher'ы task_gate/scope_write_gate.

Что гейт ловит: редиректы (>, >>, &>, N>, включая heredoc-заголовок), tee, dd of=, sed -i (GNU и BSD-форма -i ''), cp/mv/install (назначение и -t/--target-directory=), truncate, touch, curl -o/--output, wget -O/--output-document, tar -x … -C DIR, unzip … -d DIR, а также литеральный open(path, 'w'|'a'|'x') внутри python/perl/ruby -c. Тело heredoc'а разбором не сканируется (иначе ->/> в прозе или коде тела давали бы фантомную цель и блокировали честную запись). Гейтятся только цели внутри дерева проекта — как и у Write; запись в scratchpad, /tmp, /dev/null, чужой репозиторий разрешена.

Что НЕ ловит (явная остаточная граница). Shell тьюринг-полон, поэтому тотальный гейт невозможен. НЕ перехватываются: путь, собранный или переданный через переменную (f=scripts/x.py; echo >$f, echo > $SCRATCH/x — токен с $ считается неразрешимым и пропускается); base64 -d | sh и прочая обфускация; команда, собираемая из STDIN (… | xargs -I{} bash -c '…') или исполняемая на другом хосте (ssh host 'cmd' — там пути этого проекта ничего не значат); curl -O/wget без -O (имя из URL); tar/unzip распаковка в текущий каталог без -C/-d; base64 -o, xxd -r; произвольный код интерпретатора, пишущий файл иначе, чем литеральным open(...).

Обёртка bash -c / sh -c — ЗАКРЫТА. bash -c 'echo x > scripts/foo.py' раньше не давал НИ ОДНОЙ цели: перенаправление живёт внутри одного закавыченного аргумента, и разбор внутрь payload'а не шёл. То есть Rule 1 и scope-ACL обходились однострочником того же класса, который Решение #162 закрыло для heredoc'ов, — а остаточная граница при этом утверждала, что цена обхода поднята до «нужно намеренно обфусцировать». bash -c пишут каждый день, обфускацией он не является; формулировка была завышена. Теперь парсер РЕКУРСИВНО разбирает payload у bash/sh/zsh/dash/ksh/ash/busybox при флаге -c (включая слитные формы -lc, -ec), с объявленным пределом вложенности (_MAX_WRAPPER_DEPTH), чтобы bash -c "bash -c '…'" не уходил в бесконечность. Упоминание в кавычках (echo 'bash -c "x > y"'), bash script.sh без -c и длинная опция --color целями не считаются.

Прозрачные префиксы — ЗАКРЫТЫ. env bash -c '…' прятал обёртку ровно так же, как обёртка прятала перенаправление: проверка оболочки смотрела на первый токен, а им был env. Найдено адверсариальным ревью САМОГО ФИКСА обёртки (конвенция #276), а не заменённого кода. Теперь перед определением команды снимаются env, sudo, doas, nohup, nice, ionice, stdbuf, timeout, command, exec — вместе с их флагами, присваиваниями VAR=val и числовым аргументом timeout/nice. Побочно это закрыло и «писателя за обёрткой» (sudo tee), который раньше стоял в списке непойманного: писателя прятал не tee, а слово перед ним. Токен, лишь НАЧИНАЮЩИЙСЯ как префикс (environment.py, timeout_test.sh), префиксом не считается.

TAUSIK_SKIP_HOOKS=1 по-прежнему всё отключает — но этот обход телеметрируется (emit_supervision_bypass), молчаливого выключения надзора нет.

Непарсящаяся команда. Если команда не токенизируется (несбалансированная кавычка), парсер падает в регулярное выражение, которое намеренно ПЕРЕобнаруживает. bash_write_gate (QG-0) этот ответ принимает: худший случай — попросят задачу, которая и так нужна. memory_pretool_block — нет: его блок обвиняет агента в утечке знаний, а выходов только два (маркер, который был бы неправдой, и постоянное исключение в конфиге ради разовой команды), поэтому ложное срабатывание там дороже пропуска и тренирует обход. Пропуск в этом хуке не молчаливый: пишется fail_open_unparseable_<канал> (…_bash, …_powershell — причина называет тот парсер, который не справился, а не сваливает всё на Bash), а in-tree половину deny-list всё равно пересудят гейт memory_route и pre-commit до коммита.

Матрица покрытия каналов

Надзор бывает дырявым не только по ПРАВИЛУ, но и по КАНАЛУ, и второе заметить труднее: все тесты зелёные, потому что все тесты спрашивают покрытый канал. На win32 — первичной платформе проекта — агенту доступны ДВА инструмента-оболочки, Bash и PowerShell, и до задачи powershell-tool-bypasses-bash-firewall второй не матчился ни одним PreToolUse-хуком.

ГейтWrite/EditBashPowerShell
task_gate (Rule 1)— ¹— ¹
scope_write_gate (Rule 2)— ¹— ¹
bash_write_gate (Rule 1 + 2 для оболочек)н/п
bash_firewall (опасные команды)н/п
git_push_gate (push-тикет)н/п
memory_pretool_block (memory-route)
secret_scan (Rule 10.12)✅ ²✅ ²

¹ Не пробел: оболочечный вектор судит bash_write_gate, который импортирует решения scope_write_gate, а не копирует их. У события оболочки нет file_path, читать этим хукам там нечего.

² Пробел закрыт (secret-scan-covers-no-shell-channel, Решение #178). secret_scan теперь на SHELL_MATCHER в обоих bootstrap и пропускает оболочечные инструменты через shell_channel.is_shell_tool — на обоих каналах сразу, потому что закрывать на одной оболочке значило бы снова развести каналы. Сканируется ВСЯ командная строка (строгое надмножество извлечения записываемого значения: ловит heredoc-тело и Set-Content -Value, плюс export KEY=… — литерал секрета в контексте, что Rule 10.12 тоже покрывает), без нового shell-парсинга. Остаток тот же, что у путей: секрет, переданный переменной/env (--token "$TOKEN"), не резолвится и не флагается — это корректный способ передачи, а не пробел.

Конструкции-данные PowerShell: что закрыто, что названо

Вопрос «это команда или это текст?» надо задавать КАЖДОЙ конструкции, а не одной. Здесь он был отвечен для кавычек и не задан для here-string — и гейт заблокировал обычный git commit -m @'...'@ собственного проекта, потому что > в прозе сообщения прочитался как редирект. Аудит по одному:

КонструкцияСтатусКак именно
@'…'@, @"…"@ (here-string)закрытоТело — ОДИН токен. Открывающая кавычка обязана заканчивать строку, терминатор '@ признаётся только В НАЧАЛЕ строки — как требует сам PowerShell. Ослабить любое из двух условий значит снова выпустить тело как живой shell.
'…', "…"закрыто с первой версииУпоминание в кавычках приходит одним токеном и не образует инструкции.
{…} (script block)сознательно НЕ данныеСкобки — разделители, содержимое читается как обычные инструкции. Script block ИСПОЛНЯЕМ: ... | ForEach-Object { Remove-Item -Recurse C:\ } обязан остаться видимым. Проза внутри фигурных скобок не живёт.
$(…), @(…)остаточная границаУровнем вложенности не считаются; токен с $ помечается неразрешимым и отбрасывается, а не угадывается. Тот же остаток, что POSIX-гейт объявляет для $VAR.

Диалект следует за ИНСТРУМЕНТОМ, а не проверяется обоими. Соблазнительная формулировка «читаем обоими токенизаторами и блокируем, если push увидел любой» звучит как усиление и является ослаблением: каждый диалект неверно читает синтаксис другого, поэтому из двух судей выигрывает НЕКОМПЕТЕНТНЫЙ. Реально случилось: POSIX-лексер не знает here-string, ломается на апострофе в слове «PowerShell's», и git push --force из прозы commit-сообщения блокирует обычный коммит. Токенизация идёт через shell_channel по tool_name — той же единственной картой, что и цели записи.

Остаточная граница push-гейта, названная явно: push внутри payload'а обёртки (powershell -Command 'git push') для своего диалекта — один закавыченный токен и гейтом тикета НЕ ловится. bash_firewall в payload'ы спускается, поэтому force-push этим путём по-прежнему блокируется; обычный push этим путём тикета не требует. Закрывать это через scan_target (который склеивает payload интерпретатора сырьём) НЕЛЬЗЯ — он заблокирует честный python -c "print('git push')", то есть обменяет один ложный блок на другой.

Симптом для поиска в будущем: детектор, который в одном месте умеет отличать текст от команды, а в соседнем — нет. Цена такой асимметрии не «пропуск», а ЛОЖНЫЙ БЛОК на рутинной операции, и он тренирует обход дороже, чем стоила бы пропущенная команда (конв. #291).

Остаточная граница PowerShell-канала (сверх общей для оболочек «обфусцированной записи»): цель, пришедшая по КОНВЕЙЕРУ (Get-ChildItem C:\ | Remove-Item -Recurse), не является операндом удаляющего командлета и не видна; -EncodedCommand <base64> не декодируется; вызовы .NET ([IO.File]::Delete) не разбираются. Remove-Item файла проекта не считается записью — это ТОЧНЫЙ паритет с Bash, где rm тоже не писатель; пробел общий для обоих каналов и закрывается отдельно, а не на одной стороне.

Смешанный scope (Rule 2, AC3). Как только ЛЮБАЯ активная задача объявила scope_paths, ACL-энфорсмент включается для ВСЕХ co-active задач: параллельная задача без объявленного scope теряет legacy-свободу и должна либо объявить свою область, либо её запись вне union'а объявленных ACL будет заблокирована (и через Write, и через обе оболочки). Это закрывает трюк «держать одну необъявленную задачу активной, чтобы обойти scope». Legacy-свобода остаётся только когда scope не объявил НИКТО.