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

22 KiB
Raw Blame History

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 — сабагент не смог вынести канарейку.