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.
This commit is contained in:
dev
2026-09-16 11:33:09 +03:00
commit 278ecca018
34 changed files with 5315 additions and 0 deletions
+275
View File
@@ -0,0 +1,275 @@
# 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` — сабагент не смог вынести канарейку.