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:
@@ -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` — сабагент не смог вынести канарейку.
|
||||
Reference in New Issue
Block a user