Files
skills/docs/specs/2026-08-26-ctxguard-design.md
T
dev 278ecca018 Add ctxguard: hook-enforced context sanitization
Keeps credentials, company names, personal names and PII out of the model's
context. Enforcement lives in Claude Code hooks rather than in instructions to
the agent: a skill alone cannot protect anything, because by the time the agent
reads a rule the surrounding context has already been sent.

Credentials are removed irreversibly and marked. Entities from a user-supplied
dictionary become stable aliases, rewritten back to real values on their way to
disk and to the shell, so code and commands referring to them still work.

Published from a clean tree; development history is not included.
2026-09-16 11:33:09 +03:00

276 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ctxguard — санитизация контекста AI-агента
> **Исторический документ.** Это проектный замысел на 2026-08-26, сохранённый как
> есть: он объясняет, *почему* система устроена так, а не описывает текущее
> состояние. Часть запланированного здесь не была реализована в том виде
> (`references/workflows.md`, цель `make install`, `tests/corpus/`, плагин-«ведро»
> для простых скиллов). Актуальное состояние — в README.md.
## Context
Работая через Claude Code, в контекст модели утекает всё, что агент прочитал: содержимое
файлов, вывод команд, вставленные тикеты и письма. Это значит, что провайдеру уходят креды,
названия компаний-клиентов, ФИО, внутренние хостнеймы и данные продакшена. Цель — сделать так,
чтобы этого не происходило, и чтобы гарантия не зависела от того, помнит ли агент правила.
**Главное архитектурное ограничение.** Скилл сам по себе защитить не может: скилл — это
инструкции для модели, а модель читает их уже внутри контекста, который уже отправлен. Перехват
обязан быть вне модели. Единственная такая точка в Claude Code — хуки.
Проверенные возможности хуков (docs.claude.com/hooks, проверено 2026-08-26):
| Хук | Может | Не может |
|---|---|---|
| `PreToolUse` | `permissionDecision: allow/deny/ask`, **`updatedInput`** (shallow merge, при нескольких хуках выигрывает последний) | — |
| `PostToolUse` | `additionalContext`, exit 2 = фидбек | **переписать/вырезать вывод — поля `updatedOutput` нет** |
| `UserPromptSubmit` | `additionalContext`, exit 2 = блок | **переписать текст промпта** |
| `SessionStart` | `additionalContext`, матчеры `startup/resume/clear/compact/fork` | блокировать |
| `PreCompact` | запретить компактинг | повлиять на то, что сохранится |
| egress / транскрипт | — | **перехвата на пути к API нет** |
Следствие: **вся санитизация делается на `PreToolUse` через `updatedInput` — до запуска
инструмента.** Вывод чиним, переписывая вход, который его порождает.
Решения, зафиксированные с заказчиком:
- Защищаем все четыре класса: секреты, бизнес-идентификаторы, PII, данные клиентов.
- Обратимые псевдонимы для имён/компаний/PII; **секреты гасим безвозвратно** (метка `<SECRET:type:hash>`).
- Периметр: ввод/вывод инструментов **и** содержимое репозитория.
- Упаковка: плагин Claude Code (скилл + хуки + скрипты).
- **Fail-closed везде**: скрипт упал, таймаут, непонятный ввод → тул-колл блокируется.
---
## Организация репозитория
Репозиторий `/path/to/skills` пуст. Делаем его **marketplace**, чтобы будущие
скиллы ложились туда же по внятному правилу.
```
skills/
├── .claude-plugin/marketplace.json # манифест маркетплейса, name: ctx-tools
├── README.md # что тут, как поставить, как добавить скилл
├── .gitignore # словари/кеш/аудит НИКОГДА не в git
├── Makefile # test | verify | install | lint
├── docs/specs/2026-08-26-ctxguard-design.md
└── plugins/
├── ctxguard/ # нужны хуки → свой плагин
│ ├── .claude-plugin/plugin.json
│ ├── hooks/hooks.json
│ ├── skills/context-sanitization/
│ │ ├── SKILL.md
│ │ └── references/{policy.md,threat-model.md,workflows.md,troubleshooting.md}
│ ├── commands/{ctx-entity.md,ctx-sanitize.md,ctx-verify.md,ctx-audit.md}
│ ├── scripts/ctxguard/ # Python 3 stdlib-only
│ └── tests/{unit/,corpus/,run.sh}
└── <bucket>-skills/ # «ведро» для простых скиллов без хуков
├── .claude-plugin/plugin.json
└── skills/<name>/SKILL.md
```
**Правило размещения:** скиллу нужны хуки, команды или MCP → отдельный плагин в `plugins/`.
Скилл — это чистые инструкции → каталог внутри `plugins/<bucket>/skills/`. Одна установка
покрывает все простые скиллы, хук-плагины ставятся осознанно и по одному.
Установка: `/plugin marketplace add /path/to/skills`, затем
`/plugin install ctxguard@ctx-tools`.
Конвенции берём из установленных плагинов (это де-факто стандарт здесь):
- Фронтматтер `SKILL.md`: только `name` + `description` в третьем лице со «Use when…»
(образец: `~/.claude/plugins/cache/claude-plugins-official/superpowers/6.3.0/skills/writing-skills/SKILL.md`).
- Progressive disclosure: детали в `references/*.md`, не в `SKILL.md`.
- Скрипты — Python 3 **только stdlib**, вызов `python3 "${CLAUDE_PLUGIN_ROOT}/..."`
(образцы: `~/.claude/skills/jira/scripts/jira.py`, плагин `hookify`).
- Шаблон `hooks.json` — `~/.claude/plugins/marketplaces/claude-plugins-official/plugins/hookify/hooks/hooks.json`.
- Принцип «нашёл, но не цитирую» уже реализован в
`~/.claude/plugins/marketplaces/claude-plugins-official/plugins/claude-security/scripts/lib/secret.py`
(функция `withheld()`) — переиспользуем идею как инвариант CLI.
---
## Хранилище
Всё **вне репозитория**, чтобы не попало в git и не попалось агенту под руку. JSON, а не YAML —
в stdlib нет парсера YAML, а `tomllib` умеет только читать.
```
~/.claude/ctx-guard/
├── policy.json # режим, префикс алиасов, правила, deny-списки
├── projects/<slug>/entities.json # словарь real↔alias, 0600
├── projects/<slug>/index.json # кеш «в каком файле есть сущности» (по mtime+size)
├── cache/<slug>/… # санитизированные двойники файлов, 0700
└── audit.jsonl # 0600, только alias/rule_id, без plaintext
```
**Два жёстких инварианта:**
1. Весь `~/.claude/ctx-guard/` в deny-read для агента, и `Bash` с упоминанием этого пути тоже.
Иначе агент прочитает словарь и одним чтением сольёт всё сразу.
2. **В CLI нет ни одной ветки кода, печатающей реальное значение сущности.** Только алиасы,
типы и хеши. Это делает утечку через сам инструмент невозможной, а не маловероятной.
---
## Матрица перехвата
Всё через `PreToolUse`, один хук-владелец на инструмент (иначе гонка `updatedInput`).
| Поверхность | Механизм | Enforced |
|---|---|---|
| Промпт пользователя | `UserPromptSubmit`: детект → exit 2 + причина с подсказкой `/ctx-sanitize` | да, блоком |
| Строка команды Bash | `updatedInput.command` — обратная подстановка alias→real | да |
| **Вывод Bash** | `updatedInput.command` → `python3 ctxguard run -- <cmd>`: обёртка сама запускает команду и стримит отфильтрованный вывод | да |
| Содержимое `Read` | `updatedInput.file_path` → путь санитизированного двойника в кеше | да |
| `Grep`/`Glob` | `path` → каталог двойников; `pattern`, совпавший с сущностью, — deny | да |
| `Write`/`Edit` | `content` / `old_string` / `new_string` — обратная подстановка alias→real и twin-path→real-path | да |
| `WebFetch`/`WebSearch` | deny при сущности или секрете в url/query | да |
| MCP (`mcp__.*`) | скан всех строковых аргументов, семантика неизвестна → deny при попадании | да |
| `Task` (сабагенты) | скан аргумента `prompt` | да |
| Скриншоты, изображения | **не покрыто** | нет |
| Текст, сгенерированный самой моделью | **не покрыто** | нет |
Ключевой приём — обёртка Bash. Она же бесплатно закрывает `git log`, `git blame`,
`git remote -v` и почтовые адреса коммитов, которые иначе текут мимо любой фильтрации файлов.
**Симметрия путей обязательна.** `Read` отдаёт двойник → агент дальше правит *двойник*. Поэтому
`Write`/`Edit` обязаны отображать twin-path обратно в реальный путь и делать обратную
подстановку содержимого. Модуль `pathmap` двунаправленный; без него правки уходят в кеш и
теряются.
Префикс алиасов — `CTXG_` (`CTXG_COMPANY_A`, `CTXG_PERSON_3`): валидный идентификатор,
не ломает код, коллизии с реальным текстом крайне маловероятны.
---
## Движок детекции
Три семейства, `detect.py`:
1. **Словарные** (детерминированные, из `entities.json`). Одна скомпилированная альтернация,
отсортированная от длинных к коротким. Устойчивость строится прямо в regex, без карт
офсетов: классы гомоглифов (`[аa]`, `[еe]`, `[оo]`, `[рp]`, `[сc]`) против обхода
смешанными алфавитами, гибкость по пробелам и дефисам, регистронезависимость.
Русская морфология — per-entity режим `exact | stem | regex`; `stem` даёт
`Петров(?:[а-яё]{0,4})?\b`, покрывая падежи.
2. **Паттерные (секреты).** Курируемый набор: AWS `AKIA/ASIA`, GitHub `ghp_/gho_/ghs_/github_pat_`,
Slack `xox[baprs]-`, Stripe `sk_live/rk_live`, Google `AIza`, OpenAI `sk-proj-`,
Anthropic `sk-ant-`, JWT `eyJ`, PEM-блоки, SSH-ключи, `postgres://user:pass@`, generic
`(api[_-]?key|secret|token|password)\s*[:=]\s*…{16,}`. Плюс Луна для карт и РФ-специфика:
ИНН, ОГРН, СНИЛС, паспорт, `+7`-телефоны, email.
FP-фильтры: энтропийный порог (Шеннон > 3.5) для generic-правил и allow-list заведомо
фейковых значений (`AKIAIOSFODNN7EXAMPLE`, `example.com`, `sk-test-*`), иначе хук будет
блокировать документацию.
3. **Структурные.** По пути: `.env*`, `*.pem`, `id_rsa*`, `credentials`, `~/.aws`, `~/.ssh`,
`.git-credentials`. По команде: `env`, `printenv`, `aws configure get`, `gh auth token`,
`kubectl get secret -o yaml`, `docker inspect`, `op read`, `pass show`.
**Самозащита** — отдельная группа deny, без неё всё остальное декоративно: агент не может
писать в `settings.json`, `settings.local.json`, `hooks/hooks.json`, `scripts/ctxguard/**` и
`~/.claude/ctx-guard/**`. Иначе он выключит собственный контроль.
---
## Чтобы агент не забывал
Четыре слоя, по убыванию силы. Порядок принципиален: первый слой делает забывание безвредным,
остальные лишь сохраняют агента полезным.
1. **Механическое принуждение.** Хук блокирует независимо от того, что агент помнит. Память —
оптимизация UX, граница безопасности — хук.
2. **Переинъекция на `SessionStart`** с матчерами `startup|resume|clear|compact|fork`: компактный
блок правил (цель — ≤400 токенов) плюс глоссарий алиасов. В глоссарий уходит **только**
сторона алиасов и безопасное поле `hint` («`CTXG_COMPANY_A` — ритейл-заказчик»,
«`CTXG_PERSON_3` — бэкендер»). Агент получает смысл без значения. Переживает компактинг.
3. **Коррекция в момент ошибки.** Каждый `deny` несёт `permissionDecisionReason`, который
повторяет правило и даёт готовое действие: «используй `CTXG_COMPANY_A`; если это новая
сущность — `/ctx-entity add`». Обучение в точке ошибки работает лучше любого баннера.
При подстановке — `additionalContext`, объясняющий, почему путь изменился.
4. **`CLAUDE.md`** — две строки-указатель, чтобы правило выживало, если плагин отключили,
плюс триггероёмкий `description` у скилла.
Напоминания на `UserPromptSubmit` дозируем: одна строка, только если недавно было нарушение.
Баннер в каждом промпте выжигает внимание и токены.
---
## Проверяемость (без неё «эффективно» — это вера)
- **`ctxguard verify`** — канареечный корпус: подаём на реальные точки входа хуков настоящий
hook-JSON на stdin и проверяем решение. Кейсы: фейковый AWS-ключ; компания; русское ФИО в
шести падежах; обход гомоглифами; секрет внутри base64; секрет в JSON в выводе Bash;
попытка записи в `settings.json`. Это одновременно регрессионный тест и метрика.
- **`ctxguard scan-transcript`** — измерение по факту, а не по замыслу. Транскрипт
`~/.claude/projects/<slug>/*.jsonl` — это буквально то, что было отправлено. Грепаем его по
словарю и паттернам, считаем реальные утечки за сессию. Вешаем на `SessionEnd`.
- **`ctxguard audit`** — jsonl 0600: `ts, session, tool, rule_id, alias, action`. Без plaintext.
- **`ctxguard verify --adversarial`** — сабагент пытается вынести канарейку
(приём из `superpowers/skills/writing-skills/testing-skills-with-subagents.md`).
---
## План работ
**Фаза 0 — каркас репозитория.** `marketplace.json`, оба `plugin.json`, `.gitignore`, `Makefile`,
`README.md`, скелет плагина-«ведра». Коммит. Риска нет.
**Фаза 1 — движок и тесты, без хуков.** `policy.py`, `store.py` (словарь, 0600),
`detect.py`, `morph.py`, `substitute.py`, юнит-тесты, канареечный корпус, `verify`,
`scan-transcript`. TDD (`superpowers:test-driven-development`). Хуки не подключены → нулевой
риск, но уже появляется базовая метрика утечек на текущих транскриптах.
**Фаза 2 — калибровка.** Прогон движка в режиме наблюдения по истории транскриптов и репозиторию,
чистка false positives, наполнение allow-list. Это шаг разработки, а не режим поставки: в
проде — fail-closed, как решено.
**Фаза 3 — слой запрета.** `hooks.json` + `pretooluse.py`: структурные deny (пути, команды),
секреты, самозащита, MCP/Task fail-closed. Уже здесь система реально защищает.
**Фаза 4 — слой подстановки.** `ctxguard run` (обёртка Bash), двойники для `Read`/`Grep`,
`pathmap`, обратная гидратация `Write`/`Edit`.
*Перед реализацией — короткий спайк:* обёртка Bash не должна сломать персистентный шелл
Claude Code. Проверить проброс кода возврата, `cd`/`export`/`source` (их не оборачиваем — вывода
у них нет, но строку команды всё равно сканируем), построчную буферизацию и
`run_in_background`. Это единственное место с неизвестным поведением рантайма.
**Фаза 5 — слой памяти.** `sessionstart.py` (инъекция правил + глоссарий алиасов),
`userpromptsubmit.py` (детект + exit 2 + подсказка), тексты `permissionDecisionReason`,
строки для `CLAUDE.md`.
**Фаза 6 — оформление.** `SKILL.md`, четыре `references/*.md`, слэш-команды
(`/ctx-entity`, `/ctx-sanitize`, `/ctx-verify`, `/ctx-audit`), `--adversarial`, README.
---
## Что эта система честно не закрывает
Пишем это в `references/threat-model.md` первым разделом, а не мелким шрифтом.
- **Псевдонимизация ≠ анонимизация.** По структуре проекта, доменам в конфигах и именам пакетов
заказчик часто восстанавливается. Если требование юридическое или контрактное, правильный
контроль — zero-retention договор или self-hosted развёртывание, а обфускация лишь снижает
объём утечки.
- **Скриншоты и изображения** не фильтруются. Текст на картинке уходит как есть.
- **История git не переписывается.** Реальные имена остаются в объектах; их ловит фильтр вывода
при чтении, но сам репозиторий остаётся «грязным».
- **Собственный вывод модели** не контролируется: агент может воспроизвести реальное имя,
если вывел его из контекста.
- **`~/.claude/settings.local.json` уже содержит `Bash(python3 *)`** в allow. Хуки это не
обходит (они срабатывают всегда), но человек из цикла подтверждения выпадает. Стоит сузить.
- **Первый прогон до наполнения словаря защищает слабо.** Словарные детекторы знают только то,
что в них внесли; секретные и структурные работают сразу.
---
## Верификация
1. `make test` — юнит-тесты движка (детекция, морфология, гомоглифы, подстановка, pathmap).
2. `make verify` → `ctxguard verify`: таблица по канареечному корпусу, все кейсы `PASS`.
Отдельно проверить, что запись в `settings.json` заблокирована.
3. Ручной e2e в отдельном каталоге-песочнице: положить файл с фейковым ключом и фейковой
компанией, попросить агента прочитать файл и запустить `env`, `cat .env`, `git log`.
Ожидаемо: чтение отдано двойником, секрет — `<SECRET:…>`, компания — `CTXG_COMPANY_A`,
`env` и `cat .env` — deny с внятной причиной.
4. `ctxguard scan-transcript` по транскрипту этой e2e-сессии → ожидается **ноль** реальных
значений. Это финальная приёмка: она измеряет то, что действительно ушло.
5. Проверка памяти: `/compact`, затем спросить агента правила — блок правил и глоссарий должны
вернуться через `SessionStart` с матчером `compact`.
6. `ctxguard verify --adversarial` — сабагент не смог вынести канарейку.