diff --git a/CHANGELOG.md b/CHANGELOG.md index e7530cb..bb74439 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ - Что: положить `engineering/ai-data-perimeter` в сборку (`dist/vasily/skills/engineering/`) — **в сборке этой категории нет вообще**, поэтому переносится каталог целиком, вместе с `references/perimeter-rules.md` и `templates/soul-perimeter-block.md`. - Что, дополнительно (иначе канон ссылается на отсутствующий файл): перенести в сборку исполнение — `skills/productivity/docx/references/anonymization.md` и `skills/productivity/docx/scripts/scan_open_pii.py`. Оба сейчас есть только в профиле ai-si; в `dist/vasily/skills/productivity/docx/` их нет. Копировать без `__pycache__`. +- **Сделано 05.11.2026 — файлы в сборке:** `skills/engineering/ai-data-perimeter/` (SKILL.md, `references/perimeter-rules.md`, `references/hermes-intercept.md`, `templates/soul-perimeter-block.md`), `skills/productivity/docx/references/anonymization.md`, `skills/productivity/docx/scripts/scan_open_pii.py`. md5 совпадают с профилем ai-si, `__pycache__` не попал, синтаксис скрипта проверен. - Проверить, что категории `engineering` и `ailab` доезжают до установленного профиля при установке и обновлении (`ailab` сейчас не доезжает — см. таблицу выше). - Критерий приёмки: после чистой установки в профиле есть `skills/engineering/ai-data-perimeter/SKILL.md` + `references/perimeter-rules.md`; третьей редакции правила в профиле нет. - Причина: скилл, лежащий только в профиле разработчика или только в сборке, в работе не участвует. Правило в SOUL.md на него ссылается — битая ссылка хуже отсутствия ссылки. @@ -206,7 +207,7 @@ ### Этап 2. Реализовать и проверить -- [ ] Скилл `engineering/ai-data-perimeter` в сборке и в установленном профиле (п.1). +- [ ] Скилл `engineering/ai-data-perimeter` в сборке и в установленном профиле (п.1). *В сборке лежит с 05.11.2026; осталась проверка установленного профиля после чистой установки.* - [ ] Раздел «Периметр данных» в `SOUL.md` сборки и профиля (п.2). - [ ] Журнал применения: форма + запись по факту задачи (п.3). - [ ] Плагин, шаг 1: `tool_request` + `policy.yaml` + журнал в режиме «только запись», без маски (п.4). diff --git a/skills/engineering/ai-data-perimeter/SKILL.md b/skills/engineering/ai-data-perimeter/SKILL.md new file mode 100644 index 0000000..eb16b85 --- /dev/null +++ b/skills/engineering/ai-data-perimeter/SKILL.md @@ -0,0 +1,176 @@ +--- +name: ai-data-perimeter +description: "Use when обезличивание, утечка в модель, 152-ФЗ, периметр." +version: 1.0.0 +author: AI_Lab_SCRUM Co +license: MIT +platforms: [linux, macos, windows] +metadata: + hermes: + tags: [security, anonymization, 152-fz, privacy, ai-governance] + category: engineering + related_skills: [docx, om-documentation-review, grounded-citations] +--- + +# Периметр данных при работе с ИИ + +## When to Use + +- Разобрать папку или комплект материалов по безопасности ИИ — выжимка, каркас + презентации, раздел регламента. +- Проверить, что документ заказчика не уходит в модель необезличенным. +- Ответить на «у нас это соблюдается?» — про организационный процесс или про + агента и скилл. +- Собрать регламент, перечень разрешённых сервисов, инструкцию по обезличиванию. +- Решить, что можно отдать в облачную модель, а что только локально. + +Исходная логика, на которой держится всё остальное: суду неважен канал (флешка, +почта, чат-бот) — важен факт пересечения периметра; пересекло — отвечает компания. +Поэтому вопрос не «какая модель безопаснее», а «какие данные в неё кладут». + +## Правило 0 — «у нас соблюдается» проверяется по файлам, не по памяти + +Просьба подтвердить заявленную меру — это аудит, а не согласие. Проверяй **два слоя +сразу**, в одном проходе: + +| Слой | Что читать | Признак, что меры нет | +|---|---|---| +| Организационный | приказ, регламент, лист ознакомления, журнал применения | всё лежит незаполненными шаблонами, ни одной записи | +| Технический | `profiles//SOUL.md`, `skills/`, `hooks/`, `config.yaml` | правила в SOUL нет; нужный скилл не установлен; `hooks/` пуст | + +Ответ «подтвердить нечем» без проверки технического слоя — ошибка: пользователь +может иметь в виду агента и скилл, а не бумаги. И наоборот: установленный скилл не +доказывает организационный процесс. + +Проверять установленность, а не наличие в сборке: `ls profiles/

/skills/` против +`dist/<сборка>/skills/`. Скилл, лежащий только в сборке, в работе не участвует. + +Обратное направление проверять так же: скилл, лежащий **только в профиле** (у разработчика), +до получателей не доедет, и ссылка на него из `SOUL.md` сборки окажется битой. Правило, +которое цитируется в `SOUL.md`, должно физически лежать в сборке — иначе порядок работы +такой: сначала скилл в сборку, потом правило в SOUL. + +## Правило 1 — декларация в промпте ≠ контроль + +Правило, записанное в SKILL.md или SOUL.md, срабатывает по триггеру в description и +зависит от того, вспомнит ли модель применить его в нужный момент. Принуждение — это +перехват исходящего запроса. Пока его нет, честная формулировка: «правило записано и +инструментовано, механизма принуждения нет». Не называть это «соблюдается». + +**Перехват в Hermes делается плагином, а не каталогом `hooks/`.** Пустой `hooks/` +(это наблюдатели) не доказывает, что принуждения не бывает — проверять надо +зарегистрированные middleware: `ctx.register_middleware("llm_request", fn)` в +плагине (`hermes_cli/plugins.py`), применяется к kwargs провайдера в +`agent/turn_api_request.py` через `apply_llm_request_middleware` +(`hermes_cli/middleware.py`). Точки: `tool_request` (до чтения файла), +`llm_request` (до отправки), `llm_execution` (обёртка вызова, обратная подстановка). +Устройство и порядок внедрения — `references/hermes-intercept.md`. + +## Правило 2 — агент сам является каналом утечки + +Любое чтение файла инструментом (`read_file`, DOCX/PDF-парсер, OCR, `vision_analyze`) +отправляет его в модель целиком, до всякой проверки. Порядок работы с документом +заказчика: + +1. Решить, откуда берётся обезличенная копия: её даёт пользователь или агент делает + копию и читает только её. +2. Читать/разбирать только копию. Оригинал не открывать «чтобы посмотреть объект». +3. Таблицу соответствия токенов и реальных значений не читать никогда — она и есть + закрываемые данные. + +## Один канон процедуры, а не три редакции + +Одна и та же процедура расползается по трём местам: файл `anonymization.md` в каталоге +`references/` скилла `docx` вместе со скриптом `scan_open_pii.py`, отдельный скилл +`ailab/anonymization-check` и этот скилл. Три редакции одного правила разъезжаются +и начинают противоречить друг другу. + +Держать иерархию: + +| Место | Роль | +|---|---| +| этот скилл (`ai-data-perimeter`) | **канон**: правила периметра, аудит «соблюдается ли», порядок чтения документа, чек-лист и нормы в `references/` | +| файл `anonymization.md` в `references/` скилла `docx` + `scan_open_pii.py` | исполнение под каноном: техника правки DOCX-пакета и проверка полноты | +| отдельный скилл-обёртка того же правила | либо тонкая ссылка на канон, либо убрать | + +Перед тем как писать правило в третий раз — искать, где оно уже сформулировано, и +расширять существующее место. Решение «свести к ссылке или удалить дубль» принимает +владелец, не агент: в плане работ оно остаётся развилкой. + +## Проверка полноты обезличивания + +Скилл `docx` несёт процедуру (файл `anonymization.md` в его каталоге `references/`) +и скрипт: + +```bash +python skills/productivity/docx/scripts/scan_open_pii.py "<файл.docx>" --summary +# --header — готовая строка для шапки отчёта, --json — машинный вывод +``` + +- **Агент читает `--summary` (счётчики), человек — полный отчёт.** Полный прогон + печатает сами находки: наименование организации, фамилию подписанта, телефон, + индекс — то есть ровно то, что прячут. Их попадание в контекст агента отправляет + эти данные в модель и подрывает правило изнутри. +- Признавать **оба** способа маркировки: `[АДРЕС-003]` и `«________»`. Чекер, + знающий один, на файле, размеченном другим способом, объявит «маркеров нет» и + остановится. +- **Ноль находок = «шаблоны не сработали», не «документ чист».** Заказчик, + названный описательно, ни под один шаблон не попадает. Так и формулировать. +- Сканируется весь пакет: тело, **ячейки таблиц**, все `headerN/footerN.xml`, + `docProps/core.xml`, `app.xml`, картинки отдельно. +- Разделять находки, которые обязан оценить человек: ресурсоснабжающие организации + и сетевые операторы стоят в любом городском ТЗ и объект не идентифицируют; + заказчик, управляющая организация, адрес, кадастровый номер — идентифицируют. + +## Питфоллы + +- **`grep -i` не сворачивает регистр кириллицы** в Windows-шелле: поиск «ооо» не + найдёт «ООО», счётчик молча выйдет нулевым. Сворачивать регистр в Python + (`text.lower()`), шаблоны писать под нужный регистр явно. +- **Содержимое ТЗ и договоров лежит в ячейках таблиц**, а не в абзацах: проверка + только по абзацам даёт пустой результат, который читается как «всё чисто». +- **Метаданные DOCX — главный пропускаемый канал:** `dc:creator` и + `cp:lastModifiedBy` часто содержат почту и полное имя автора, `` в + `app.xml` — ещё одну почту. В теле документа их не видно. +- **Колонтитулы — отдельная часть на каждую секцию**: правка `document.xml` + оставляет название компании на всех страницах. +- **Скриншоты**: реквизиты и заголовки остаются по углам и в EXIF. +- **«Локальная модель = безопасно» неверно**: без отключённого внешнего доступа и + без HTTPS при раздаче по сети данные всё равно уходят или ходят открытым текстом. +- **Вторичный источник не равен факту**: собранные в папку методички, карточки и + посты канала — чужие материалы, они не доказывают, что процесс есть у нас. + Цифры (штрафы, проценты утечек) перепроверять по действующей редакции нормы; + нормы в методичке обычно отсутствуют вовсе. + +## Как оформляется результат + +- Сначала вывод, потом обоснование. Таблица вместо абзаца, строка вместо таблицы. +- **Организационные меры «под подпись» не предлагать.** В этой компании директор и + владелец такие бумаги не подписывают, поэтому регламент, приказ и лист ознакомления + как основной механизм нереалистичны. Опора — на технический контроль (перехват, + маска, журнал) и пилот на одной задаче; бумаги возвращать в план только если + владелец сам о них попросит. +- **Слайд про собственный продукт — со статусом и остаточными рисками.** В надзаголовке + «релиз N, в разработке», внизу честная строка о том, чего механизм не делает. + Слайд без этого обещает больше, чем есть, и разваливается на первом вопросе + «а оно точно не уйдёт?». +- В выжимке по папке: инвентарь (что за файлы и **чьи они**) → ядро правил → + рабочие инструменты → нормы → статус «что проверено / что нет». +- Раздел статуса — отдельной таблицей «что проверял → факт», без «проверено» без + доказательства. +- Полезный артефакт помимо текста: каркас презентации по разделам выжимки. + Колоду по этим материалам собирать средствами скилла `powerpoint` (в нём тёмная + брендовая тема и каркас сборки — файл `templates/brand_deck_build.py` внутри скилла + `powerpoint`) — не рисовать слайды с нуля и не тащить светлую тему. + +## Справочник + +- `references/perimeter-rules.md` — светофор данных, четыре контура, чек-лист + обезличивания, ответственность и сроки инцидентов, штрафы, минимальный пакет + документов, дорожная карта внедрения. +- `templates/soul-perimeter-block.md` — готовый блок «Периметр данных» для вставки в + `SOUL.md`: то, что действует до первого чтения файла, со ссылкой на этот скилл как + канон. Вставлять и в сборку, и в рабочий профиль. +- `references/hermes-intercept.md` — как сделать принуждение на исходящем запросе: + три точки middleware, `policy.yaml` с ролями по типам документов, режим «маска + + журнал», порядок внедрения и остаточные риски. diff --git a/skills/engineering/ai-data-perimeter/references/hermes-intercept.md b/skills/engineering/ai-data-perimeter/references/hermes-intercept.md new file mode 100644 index 0000000..dcee1ad --- /dev/null +++ b/skills/engineering/ai-data-perimeter/references/hermes-intercept.md @@ -0,0 +1,101 @@ +# Принуждение на исходящем запросе в Hermes + +Как из правила в промпте сделать механизм: маскировать то, что уходит в модель, и +писать строку в журнал. Ниже — проверенный контракт и решения, которые принимает +владелец, а не агент. + +## Точки перехвата + +Плагин регистрирует middleware из `register(ctx)`; вызовы применяются в рантайме, +а не в промпте: + +| Точка | Что получает | Что может | Где применяется | +|---|---|---|---| +| `tool_request` | `tool_name`, `args`, `original_args` | заменить аргументы инструмента или отказать до выполнения | до проверок доступности, guardrails и approval | +| `llm_request` | `request` (полные kwargs провайдера, включая `messages`/`input`), `original_request` | заменить запрос целиком; исключение прерывает вызов | `agent/turn_api_request.py` → `apply_llm_request_middleware` | +| `llm_execution` | `request`, `next_call` | обернуть или заменить сам вызов провайдера | вокруг исполнения | + +Порядок на каждый запрос: собрать kwargs → `llm_request` → наблюдатели +`pre_api_request` → `llm_execution` → `post_api_request`. Регистрация: +`ctx.register_middleware("llm_request", fn)` в `hermes_cli/plugins.py`. + +Практический вывод: `hooks/` — это наблюдатели, они ничего не меняют. Пустой `hooks/` +не означает «принуждения быть не может»; смотреть надо на зарегистрированные +middleware (включённые плагины в `config.yaml`). + +## Три точки = три задачи + +| Точка | Задача | +|---|---| +| `tool_request` на `read_file`, DOCX/PDF-парсер, OCR | определить **тип документа** (имя файла, титул, штамп), сверить с ролью пользователя, поставить метку категории в сессии; при запрете — отказ **до** чтения, содержимое в модель не попадает | +| `llm_request` | замаскировать по правилам категории из метки и записать строку в журнал | +| `llm_execution` | вернуть реальные значения в ответе — иначе модель отвечает про `[РОЛ_01]` и ответ бесполезен | + +## Категория — по типу документа и роли, не по шаблону + +Шаблоны ПДн дают ложные срабатывания на техническом тексте: номера СП и ГОСТ, ИНН +поставщика в спецификации, кадастровые в выписке. Поэтому решение принимается не по +содержимому, а по типу документа (он известен до чтения) + роли пользователя. +Шаблоны остаются подсказкой для автоклассификации; при сомнении тип подтверждает +человек одним вопросом, ответ живёт в метке сессии — иначе одно и то же спрашивают +десять раз за день и отвечают не глядя. + +Политика — отдельный файл в профиле, правится без пересборки: + +| Категория | Кому | Что входит | Режим | +|---|---|---|---| +| 🟢 зелёный | всем | открытые нормативы, методики, обезличенные тексты | без маски | +| 🟡 жёлтый | всем | ТЗ, реестры замечаний, переписка, локальные сметы, ВОР | маска + журнал | +| 🔴 красный | только выделенная роль (отдельная учётка) | договоры, сводные сметы, акты, ИД, кадры, финансы | остальным — отказ с подсказкой, к кому идти | + +Два ограничения, которые нельзя размывать: + +1. **Роль не даёт права отправить красное в облако.** Красное в облако не идёт никому; + роль разрешает открыть документ агенту (например, чтобы снять структуру и сделать + обезличенную выписку), но не отправку самого документа. Иначе светофор противоречит + сам себе, и первый же вопрос «а почему им можно?» его ломает. +2. **Роль вешается на учётку, а не на флаг в текущем профиле.** Переключатель в том же + профиле сотрудник поставит себе сам, и контроль фиктивный. + +Маскировать красное и отправлять нельзя: у договора маска убивает предмет — стороны, +суммы, особые условия. Единственный корректный ответ для красного — не пускать. + +## Откуда берутся роли + +Роли берутся из таблицы учёток организации (той же, где логины и лимиты), а `policy.yaml` +генерируется из неё скриптом. Руками политику не править: два источника правды +разъедутся на первой же кадровой замене, и узнается об этом на инциденте. + +- **Роль — машинный код** (`engineer` / `contracts` / `admin`), а не перечисление задач. + «инженер ОВиК, ТЗ, ТУ, СТУ, проекты» не парсится и у каждого записано по-своему; + человекочитаемое описание держать отдельной колонкой рядом с кодом. +- **`root` политику не обходит.** Учётка со статусом root проходит те же проверки; + отличие — только пометка `admin` в журнале. Иначе достаточно попросить root, и + контроль отменён. +- После записи перечитывать таблицу и сверять число строк и раскладку по ролям, а не + только одну изменённую ячейку: пропущенная учётка = сотрудник без роли, и он либо + блокирован, либо проходит как зелёный. + +## Порядок внедрения + +1. `tool_request` + политика + журнал в режиме «только запись»: ничего не маскируем, + смотрим реальный поток и настраиваем правила. +2. Включаем маску на жёлтом. +3. Включаем демаскировку ответа (`llm_execution`). + +Обратный порядок (сразу маска без периода наблюдения) даёт ложные маски на нормальном +техническом тексте, и работу уносят на личный телефон — ровно то, от чего защищались. + +## Приёмка + +- Сотрудник без роли просит проверить договор → отказ до чтения, файл не открывается. +- ТЗ у любого сотрудника → уходит маской, в журнале строка, ответ с реальными значениями. +- 20 запросов без ПДн → ни одной маски и ни одной блокировки. +- В журнале: пользователь, тип документа, категория, сколько замен, модель, время. + +## Чего перехват не закрывает + +- Картинки (`vision_analyze`) и вложения не маскируются. +- PDF-сканы без текстового слоя требуют OCR до маски. +- Путь «человек скопировал в браузер мимо агента» закрывается только шлюзом или DLP + на периметре. Плагин закрывает свой участок, а не периметр целиком. diff --git a/skills/engineering/ai-data-perimeter/references/perimeter-rules.md b/skills/engineering/ai-data-perimeter/references/perimeter-rules.md new file mode 100644 index 0000000..9e1a75c --- /dev/null +++ b/skills/engineering/ai-data-perimeter/references/perimeter-rules.md @@ -0,0 +1,133 @@ +# Правила периметра: рабочая выжимка + +Домен: проектная/строительная организация, инженер отправляет фрагменты ПД, смет, +ТЗ, исполнительной документации во внешние ИИ-сервисы. + +## Два класса данных, которые не выходят наружу + +| Класс | Что именно | +|---|---| +| Персональные данные (152-ФЗ) | ФИО и подписи в штампах и основных надписях, номера НРС, телефоны в приказах о назначении, подписанты актов и листов согласования | +| Коммерческая тайна | ТЭП объекта, сметная стоимость и договорные цены, технические решения-наработки, данные заказчика | + +Коммерческая тайна юридически существует только при **введённом режиме** по ст. 10 +ФЗ-98: перечень сведений, порядок обращения, учёт допущенных лиц, гриф +«Коммерческая тайна». Без этого «наша тайна» в споре не защищена. + +## Светофор данных + +**🟢 Зелёный — в любой разрешённый сервис:** общетехнические сведения, методики, +формулы; тексты, полностью очищенные от реквизитов; открытые нормативы в актуальной +редакции; шаблоны и структуры; внутренние материалы без коммерческой тайны; +деперсонифицированная статистика. + +**🟡 Жёлтый — после обезличивания и только в сервисе из утверждённого перечня:** +спецификации, ВОР, локальные сметы без заказчика и точного адреса; текстовые части +ПД/РД фрагментарно; переписка без имён и контактов; ТЗ без реквизитов и предмета +договора; реестры замечаний после очистки; протоколы без ФИО. + +**🔴 Красный — только локальный контур или вручную:** полные комплекты ПД/РД с +реквизитами; договоры с суммами и сторонами; ПДн сотрудников и третьих лиц; +коммерческая тайна заказчика; геолокация, координаты, кадастровые номера; объекты +КИИ и ОПК; торги до подведения итогов; гостайна. + +Сомнение в категории → обрабатывать как красное до разъяснения. + +## Четыре контура + +| | К1 публичное облако | К2 корп. облако (DPA) | К3 локальный сервер | К4 локальная станция | +|---|---|---|---|---| +| Категории | только зелёный | зелёный, жёлтый | зелёный, жёлтый, часть красного | зелёный, жёлтый | +| За периметром | да | да | нет | нет | +| Запуск | часы | дни | недели | часы | +| Минус | обучение на ваших данных, непрозрачное хранение | данные всё равно вне периметра | нужен DevOps/MLOps и железо | слабое железо, качество ниже | + +Локальный контур убирает риск утечки и **добавляет риск качества**: модель 7–14B на +русском техническом тексте заметно слабее облачных. Это размен, а не выигрыш по всем +пунктам — так и говорить. + +## Чек-лист обезличивания (5 групп, 3–7 минут на типовой документ) + +- **Реквизиты:** наименования заказчика/подрядчика/проектной → роли; ИНН, КПП, ОГРН; + адреса; наименование и точный адрес объекта → тип + регион; кадастровые номера и + координаты. +- **Финансы:** точные суммы → порядок суммы или диапазон; ставки и расценки в + привязке к сделкам; смета с привязкой к объекту целиком. +- **Персданные:** ФИО → «специалист отдела X»; должности с привязкой к организации; + телефоны, e-mail, мессенджеры; даты рождения, паспортные данные, доходы. +- **Идентификаторы:** номера договоров и доп. соглашений; номера и даты + приёма-передачи; внутренние шифры проектов. +- **Технические каналы:** метаданные файла (автор, организация, последний редактор, + путь); скриншоты (заголовки и реквизиты по углам); вложенные изображения и угловые + штампы; связанные документы и ссылки в тексте. + +Конвейер: **обезличить → прогнать → подставить**. «Иванов» → `[РОЛЬ_01]`, объект → +`[ОБЪЕКТ_А]`; результат возвращается внутрь периметра и метки меняются обратно +локально. Смысловая структура сохраняется — спецификация остаётся спецификацией, +качество проверки не падает. + +## Ответственность и инциденты + +- Решение, расчёт и подпись — всегда человек. ИИ не переносит ответственность. +- Ссылаться на ИИ как на источник нормативных требований, расчётных значений, + экспертных заключений в документации запрещено. Ссылка модели на пункт — гипотеза + до ручной сверки. +- Уровни: низкий (зелёные данные не в тот сервис) — в течение рабочего дня; + средний (жёлтые без обезличивания, результат без перепроверки) — 4 часа; + высокий (красные во внешний сервис, подозрение на утечку) — немедленно, + руководитель + юрист + ИБ. +- Уведомление РКН об утечке — 24 часа, расследование и повторное уведомление — + 72 часа (ч. 3.1 ст. 21 ФЗ-152). + +## Цифры (проверять по действующей редакции перед публикацией) + +Ст. 13.11 КоАП в редакции ФЗ-420 от 30.11.2024, с 30.05.2025 — для юрлиц: + +| Состав | Штраф | +|---|---| +| Утечка 1 000–10 000 субъектов | 3–5 млн ₽ | +| Утечка 10 000–100 000 | 5–10 млн ₽ | +| Утечка свыше 100 000 | 10–15 млн ₽ | +| Повторная утечка | 1–3% годовой выручки, не менее 20 млн ₽ | +| Несообщение в РКН об утечке | 1–3 млн ₽ | +| Утечка биометрии | 15–20 млн ₽ | + +Отраслевые проценты («23% компаний столкнулись с угрозами из-за нейросетей», «88% +пробуют ИИ») ходят в обзорах со ссылкой на конференции — это **вторичный источник**, +помечать как таковой или не приводить. + +## Минимальный пакет документов организации + +Регламент использования ИИ (приказ) · Перечень разрешённых сервисов с контуром и +категорией (ревизия раз в квартал) · Карта внедрения как методичка · Инструкция по +обезличиванию · Журнал применения ИИ · Лист ознакомления · Пункт в договоры с +заказчиками об использовании ИИ. + +Роли: руководитель (приказ) · ИТ (контур, журналирование) · юрист (DPA, формулировки +в договорах) · ИБ (контроль, инциденты) · ГИП (контроль в проекте) · руководитель +отдела (инструктаж, выборка не менее 10% задач раз в квартал) · специалист +(обезличивание, перепроверка). + +## Семь типовых ошибок внедрения + +1. Запретить ИИ полностью → уходит в тень, без правил и следов. +2. Разрешить всё на свой риск → утечка, 152-ФЗ, расторжение контрактов. +3. Купить корпоративную подписку без DPA → юридически данные всё равно уходят. +4. Развернуть локальный ИИ без обучения → бюджет потрачен, пользуются облаком. +5. Скриншоты в облако без проверки метаданных и полей. +6. Доверить ИИ финальную проверку. +7. Не пересматривать перечень сервисов. + +Пять правил гигиены, годятся слайдом: периметр решает, а не модель · обезличивай до, +подставляй после · чувствительное — российский контур или локально · ссылка ИИ на +норму — гипотеза · подпись человеческая. + +## Внедрение + +Неделя: карта данных → регламент → процесс обезличивания → приказ и ознакомление +под подпись → пилот на одной команде и одной задаче. +90 дней: 1–2 нед. аудит и решение → 3–4 регламент и документы → 5–6 запуск и +инструктаж → 7–8 адаптация → 9–10 выборочная проверка → 11–13 расширение. + +Три уровня зрелости: «запрещено всё» (теневой ИИ) → «разрешено, но молчим» +(большинство) → «регламент + контур» (цель). diff --git a/skills/engineering/ai-data-perimeter/templates/soul-perimeter-block.md b/skills/engineering/ai-data-perimeter/templates/soul-perimeter-block.md new file mode 100644 index 0000000..ac4254b --- /dev/null +++ b/skills/engineering/ai-data-perimeter/templates/soul-perimeter-block.md @@ -0,0 +1,40 @@ +# Блок «Периметр данных» для SOUL.md + +Готовая формулировка. Вставляется в `SOUL.md` **и в сборку, и в рабочий профиль**; +скилл-канон при этом должен уже лежать в сборке, иначе ссылка битая. + +Держит только то, что действует ДО первого чтения файла. Светофор, контуры и чек-лист +здесь не дублируются — они в `references/perimeter-rules.md` этого скилла. При правке +сверять с правилами 0–2 из SKILL.md: аудит вместо согласия, декларация ≠ контроль, +агент сам канал. + +``` +## Периметр данных + +Правила, светофор и процедура — скилл `ai-data-perimeter`. Здесь — то, что +действует до первого чтения файла. + +- Документ заказчика (ТЗ, договор, переписка, форма, ИД) до классификации считается + красным. Инструментами чтения он не открывается, пока не решено, откуда берётся + обезличенная копия: её даёт владелец или агент делает копию и читает только её. +- Оригинал не открывать «чтобы посмотреть объект». Таблица соответствия токенов и + реальных значений не читается никогда — она и есть закрываемые данные. +- Обезличивание — работа человека по его таблице соответствий. Агент делает копию по + технике `docx/anonymization.md`, только если об этом попросили, и не решает за + человека, что закрывать в спорных случаях. +- Проверка полноты: `python scripts/scan_open_pii.py "<файл>" --summary`. Агент читает + счётчики; полный отчёт со значениями — человеку, потому что находки и есть + закрываемые данные. +- «Маркеров нет» — единственный стоп-сигнал: скорее всего, это оригинал. Ноль находок + ≠ «документ чист». Формулировка: «шаблоны нашли N, открыто: …». +- «Документ обезличен» и «у нас это соблюдается» без проверки файлов — запрещены. + На просьбу подтвердить меру проверяются два слоя: организационный (приказ, + регламент, журнал) и технический (SOUL, установленные скиллы, hooks). Ответ по + одному слою — ошибка в обе стороны. +- Если механизма принуждения нет — говорить это прямо: правило в промпте срабатывает + по триггеру, а не по факту. +``` + +Что проверить после вставки: правило есть в обоих файлах; на задаче «прочитай ТЗ и +найди требования» агент сначала спрашивает про обезличенную копию, а не открывает +оригинал. Это остаётся правилом в промпте — в релиз-ноутах так и писать. diff --git a/skills/productivity/docx/references/anonymization.md b/skills/productivity/docx/references/anonymization.md new file mode 100644 index 0000000..ac5b222 --- /dev/null +++ b/skills/productivity/docx/references/anonymization.md @@ -0,0 +1,128 @@ +# Redacting personal / confidential data from a .docx + +Use when the user asks to strip personal data, names, positions, or company +names from a Word file before sharing ("обезличить", "удалить личные данные", +"убрать названия компаний"). Goal: no residual identifier anywhere in the +package, structure and formatting intact. + +## Rule 0 — the body is not the whole document + +Visible text is only part of what leaks. Scan **every** part of the package, +not just `word/document.xml`: + +| Where | What lives there | +| --- | --- | +| `word/document.xml` | body paragraphs, tables, text boxes | +| `word/headerN.xml`, `word/footerN.xml` | running company/title lines (one footer per section — a doc can have 20+ identical footers, all must be edited) | +| `docProps/core.xml` | `dc:creator`, `cp:lastModifiedBy` — often the real author's **email** and full name | +| `docProps/app.xml` | `` — often another email | +| `customXml/*.xml` | metadata items | + +The header/footer and docProps leaks are the ones people forget. A footer +that reads " ООО «»" repeats company branding on every +page; each `footerN.xml` is a separate file carrying its own copy. + +## Procedure + +1. **Baseline scan.** List every part and grep all `.xml` for the terms to + remove (company name in all grammatical forms, well-known surnames, + `Архидинамика`-style unit names). Print each hit with ~120 chars of + context so you can see what is actually there. Also regex the whole + package for `[\w.+-]+@[\w.-]+\.\w+` (emails) — emails hide inside + docProps, not the visible text. +2. **Inspect images separately.** `docx_read.py --images DIR` (or unzip + `word/media/*`) and check each with `vision_analyze` for logos, faces, + signatures, company names. Pure technical/diagram images are clean and + need no edit. EMF/WMF logos will not decode as images — rasterize via + PowerShell `System.Drawing.Image` → Bitmap.Save(png) before viewing. +3. **Replace in-process with lxml, per paragraph.** Walk every `w:t`, + group runs by their owning `w:p`, join the run texts into the paragraph + string, apply exact-match and regex substitutions to that string, then + write the result into the first run and blank the rest. This is the same + collapse as `docx_edit.py replace` but lets you batch many rules across + body, tables, and all headers/footers in one pass. +4. **Scrub docProps** with plain string replacement on `core.xml` / `app.xml` + (empty the creator/manager elements). +5. **Rewrite the zip** preserving every original part name and order; + only the edited parts change bytes. +6. **Verify against the original** (see below). + +## Replacement style (this user) + +- Replace the term with underscores of similar width (`___` / `________`), + not with a fake name — it makes the redaction visible and keeps table + columns from collapsing. Confirmed as the preferred shape for + agreement/approval tables and signature blocks. +- Leave dates, version numbers, and city/year unless the user says to + remove them; offer to strip those too rather than deciding silently. +- Preserve structure: never delete rows/columns of a table to hide a value. + +## Pitfalls + +- **Regex needs the article/ООО pattern.** `ООО «»` contains + guillemets and a space; a bare-company-name regex will not match it. + Handle `ООО\s*«\s*Company\s*»` first, then the bare name. +- **Header/footer text is in a separate part per section.** Editing + `document.xml` alone leaves the company name on every page. +- **Running the validator on the original matters.** Run `docx_validate.py` + on the ORIGINAL first to capture its pre-existing issues as a baseline; + then run it on the redacted file — the issue set should be identical. + A file can be `ok: true` even with unresolved image references present in + the original; do not "fix" pre-existing noise. +- **Maker won't be caught by a name scan if it's only an email.** Grep for + emails explicitly; `cp:lastModifiedBy` and `` frequently hold + the person's corporate address instead of a display name. + +## Checking a redaction you did not do (completeness pre-flight) + +A different job from redacting. The document is already redacted; the question +is **what is still visible** before it goes to an external model or into a +report read outside the team. The deliverable is a line for the report header, +not an edited file. Run `scripts/scan_open_pii.py ` — it prints what +is closed and what is open, by category, with the finds themselves. + +**Two marker conventions exist, and both must be recognised.** + +| Convention | Looks like | Why it is used | +| --- | --- | --- | +| Bracketed tokens | `[АДРЕС-003]`, `[ЗАКАЗЧИК-1]` | token substitution — the value stays recoverable from a lookup table | +| Underscore runs | `«________»`, `______` | in-place overwrite — the structure of forms and tables does not shift | + +A checker that knows only one convention is worse than none: on a file redacted +the other way it reports "no markers found", concludes the copy is the original, +and stops. Recognise both, and treat "no markers at all" as the only real stop +condition — not "no tokens". + +Rules for the check itself: + +- **Scan the whole package** — body, table cells, every `headerN.xml` / + `footerN.xml`, `docProps`. Contract content lives in table cells, and a + paragraphs-only pass returns nothing, which reads as "all clean". +- **Fold case in Python** (`.lower()`), never through the shell: `grep -i` does + not fold Cyrillic in a Windows shell and the count silently comes back zero. +- **Absence of findings is not proof** and the wording must say so: "the patterns + found N", never "the document is clean". A party named descriptively ("the + investor for the site near X station") matches no pattern at all. +- **Separate what a human must judge.** Utilities and grid operators appear in + every municipal brief and identify nothing; the customer and the site do. +- **Never open the token-to-value lookup table** to "clarify" a hit — the check + runs on the redacted copy alone. +- **The agent reads counts, not values.** The full report prints the finds + themselves — and the finds ARE the sensitive data. That output going into an + agent's context sends exactly what is being hidden to the model, defeating the + rule the check exists to enforce. Run `--summary` (counts, no values) whenever + an agent will read the result; the full report is for a human looking at the + document directly. Verified on a sample: the full run printed the company + name, the signatory, the phone and the postal index — i.e. the very values + being redacted. + +## Verification (do all three) + +1. Full-package grep for every term again — expect **0** hits across all + `.xml` parts. +2. Regex the package for surname+initials (`[А-ЯЁ][а-яё]+\s+[А-ЯЁ]\.\s?[А-ЯЁ]\.`), + full-name triples, emails, and phone numbers — expect empty sets. Long + runs of spaces from table layout will false-positive a naive + three-word regex; anchor the name pattern on initials or a known + surname list instead. +3. `docx_validate.py out.docx` → `ok: true` with the same issues as the baseline. diff --git a/skills/productivity/docx/scripts/scan_open_pii.py b/skills/productivity/docx/scripts/scan_open_pii.py new file mode 100644 index 0000000..ad53e9e --- /dev/null +++ b/skills/productivity/docx/scripts/scan_open_pii.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""Completeness pre-flight: what did a redaction leave open? + +Answers "what is still visible in this document" BEFORE it goes to an external +model or into a report read outside the team. It never edits anything — the +redaction itself is a human job (see references/anonymization.md). + + python scan_open_pii.py "file.docx" full report + python scan_open_pii.py "file.docx" --summary counts only, no values + python scan_open_pii.py "file.docx" --header one line for a report header + python scan_open_pii.py "file.docx" --json machine output + +Handles .docx (whole package: body, table cells, every header/footer part, +docProps) and .txt. + +--- Two rules baked into the code --- + +1. Paragraphs AND tables. Contract text lives in table cells, not paragraphs; +a check that walks paragraphs only returns nothing and reads as "all clean". + +2. Case folding in Python (`.lower()`), never via the shell: `grep -i` does not +fold Cyrillic in a Windows shell and the counter silently returns zero. + +--- What it does NOT do --- + +Zero finds means "the patterns did not fire", NOT "the document is clean". A +party named descriptively ("the investor for the site near X station") matches +no pattern. Exit code 0 is "the scan ran"; a non-zero code is returned only +under --fail-on-open. +""" +import argparse +import json +import re +import sys +import zipfile +from collections import Counter, defaultdict +from pathlib import Path +from xml.etree import ElementTree as ET + +W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}" + +# (category, pattern). No case flags: the text is lower-cased up front, which +# folds Cyrillic reliably. +PATTERNS = [ + # The quoted name is captured with the legal form. Without it the report + # shows a bare "LLC" and a human cannot tell the customer apart from a + # utility that appears in every municipal brief and identifies nothing. + ("org. form", r"\b(ооо|оао|зао|пао|ао|ип|гбу|гку|фгбу|фгбоу|фгуп|муп|гуп|нко|сро|тсж|ано)\b" + r"\s*(?:«[^»\n]{1,120}»|\"[^\"\n]{1,120}\")?"), + ("initials", r"\b[а-яё]\.\s?[а-яё]\.\s?[а-яё][а-яё-]{2,}"), + ("surname + initials", r"\b[а-яё][а-яё-]{2,}\s[а-яё]\.\s?[а-яё]\."), + ("cadastral number", r"\b\d{2}:\d{2}:\d{6,7}:\d+"), + ("building permit", r"\bгпзу\b[^\n]{0,60}"), + # \d{1,4}, not \d: a single digit truncated the find to "st. 1". + ("address", r"\b(?:г\.|гор\.|город|ул\.|улица|пер\.|переулок|проезд|шоссе|проспект|д\.|дом|корп\.|корпус|стр\.|строение|владение|помещение)\s*\.?\s*\d{1,4}"), + # [\w-] guards: without them a 6-digit index was found inside a permit + # number like "RU77-123456". + ("postal index", r"(? [(where, found, context)] + for part, kind, num, text in blocks: + low = text.lower() + for m in TOKEN_RE.finditer(low): + closed[f"token {m.group(1).strip().upper()}"] += 1 + n_under = len(UNDERSCORE_RE.findall(text)) + if n_under: + closed["underscores"] += n_under + for category, pattern in PATTERNS: + for m in re.finditer(pattern, low): + found = text[m.start():m.end()].strip() + ctx = text[max(0, m.start() - 40):m.end() + 40].strip() + open_hits[category].append((f"{kind} {part}:{num}", found, ctx)) + return closed, open_hits + + +def is_benign(found: str) -> bool: + return any(h in found.lower() for h in BENIGN_HINT) + + +def main() -> int: + ap = argparse.ArgumentParser(description="Completeness pre-flight for a redacted document") + ap.add_argument("file", help="redacted .docx or .txt copy") + ap.add_argument("--summary", action="store_true", help="counts only, no values") + ap.add_argument("--header", action="store_true", help="one line for a report header") + ap.add_argument("--json", action="store_true", help="machine output") + ap.add_argument("--fail-on-open", action="store_true", help="exit 1 when opens are found") + args = ap.parse_args() + + path = Path(args.file) + if not path.exists(): + print(f"file not found: {path}", file=sys.stderr) + return 2 + + if path.suffix.lower() == ".docx": + blocks = read_docx(path) + elif path.suffix.lower() in (".txt", ".md"): + blocks = read_txt(path) + else: + print(f"unsupported format: {path.suffix} (.docx or .txt)", file=sys.stderr) + return 2 + + closed, open_hits = scan(blocks) + total_closed = sum(closed.values()) + total_open = sum(len(v) for v in open_hits.values()) + benign = sum(1 for items in open_hits.values() for item in items if is_benign(item[1])) + + if not total_closed: + note = ("No redaction markers at all (no underscores, no tokens). If this is " + "supposed to be a redacted copy, it is most likely the original. " + "Stopping here — the file was not checked further.") + if args.json: + print(json.dumps({"file": str(path), "error": "no_anonymization_markers", + "note": note}, ensure_ascii=False, indent=2)) + else: + print(note) + return 1 + + cats = {c: len(v) for c, v in sorted(open_hits.items(), key=lambda x: -len(x[1]))} + kinds = ", ".join(f"{k} — {v}" for k, v in closed.most_common()) + + if args.json: + print(json.dumps({ + "file": str(path), + "blocks": len(blocks), + "closed": {"total": total_closed, "kinds": dict(closed)}, + "open": {"total": total_open, "by_category": cats, "likely_benign": benign}, + "findings": {c: [{"where": w, "found": f} for w, f, _ in v] + for c, v in open_hits.items()}, + }, ensure_ascii=False, indent=2)) + elif args.header: + if total_open: + opened = ", ".join(f"{c} ({n})" for c, n in cats.items()) + print(f"Redaction: closed — {total_closed} replacements ({kinds}). " + f"OPEN: {opened}. Do not quote from passages with open data.") + else: + print(f"Redaction: closed — {total_closed} replacements ({kinds}). No open " + f"values matched the patterns — this is NOT proof of completeness; " + f"descriptive mentions still need a human pass.") + elif args.summary: + print(f"document: {path.name} | text blocks: {len(blocks)}") + print(f"closed: {total_closed} ({kinds})") + print(f"left open: {total_open} matches in {len(cats)} categories") + for c, n in cats.items(): + print(f" {c}: {n}") + if benign: + print(f" of these, likely utilities (usually not a leak): {benign}") + else: + print(f"FILE: {path}") + print(f"text blocks: {len(blocks)}\n") + print(f"CLOSED: {total_closed}") + for k, v in closed.most_common(): + print(f" {k}: {v}") + print(f"\nLEFT OPEN: {total_open} matches\n") + for category, items in sorted(open_hits.items(), key=lambda x: -len(x[1])): + print(f"--- {category}: {len(items)} ---") + for where, found, ctx in items[:12]: + flag = " (looks like a utility)" if is_benign(found) else "" + print(f" [{where}] {found}{flag}") + print(f" ...{ctx}...") + if len(items) > 12: + print(f" ... {len(items) - 12} more") + print() + print("Zero finds means the patterns did not fire, not that the document is clean.") + print("Descriptive mentions of the site and the customer need a human pass.") + + if args.fail_on_open and total_open: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())