Замаскировать персональные данные в тексте перед облачной LLM и развернуть оригиналы в ответе - шлюз pii-mask. Брать для транскрипта, переписки, письма с ПД. Не нужен для обезличенного текста.
Installs into .claude/skills of the current project.
Are you the author of Pii Mask?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/dewil-pii-mask)
---
name: pii-mask
description: Замаскировать персональные данные в тексте перед облачной LLM и развернуть оригиналы в ответе - шлюз pii-mask. Брать для транскрипта, переписки, письма с ПД. Не нужен для обезличенного текста.
---
# Маскировка ПД перед облачной моделью
Шлюз заменяет персданные метками (`{{PERSON_1}}`) и формат-сохраняющими фейками (`+7 000 ...`, `userN@example.com`), а после ответа модели возвращает оригиналы. Соответствие "метка - оригинал" живет в mapping-файле рядом с задачей; сервис не хранит ничего.
Движок: [pii-mask](https://github.com/dewil/pii-mask) - локальный, офлайн, русскоцентричный (ПД-форматы РФ, NER и морфология русского). Не установлен - скилл неприменим, ставится оттуда.
## Главное правило: не читай оригинал
Если облачная модель - это ты сам (сессия агента), то **оригинальный файл открывать нельзя**. Прочитал `Read` - персданные уже в контексте облачной модели, и маскировать дальше нечего. Отсюда порядок:
1. Файл прогоняется через CLI **не глядя** - агент видит только путь.
2. Агент читает и обрабатывает **только** `*.masked.md`.
3. Ответ разворачивается обратно тем же CLI.
Тот же запрет касается mapping-файла: в нем лежат оригиналы списком, это самый концентрированный источник ПД во всей схеме. Не открывать, не цитировать, не пересылать.
## Порядок
### Если исходник - PDF с таблицами
**Как извлечен текст - решает больше, чем настройки шлюза.** Такой PDF превращается в текст двумя способами, и они дают разный результат:
```bash
pdftotext -layout документ.pdf текст.txt # сохраняет колонки - и рвет сущности
pdftotext документ.pdf текст.txt # поток в порядке чтения - сущности целые
```
Постраничный режим сохраняет визуальную верстку, а вместе с ней разрывает то, что маскируется: ФИО разъезжается по колонкам, идентификатор рвется по дефису, ключевое слово реквизита остается на одной строке, а число уходит на другую. Замер на акте проверки (76 страниц): полных ФИО капсом видно 55 против 879, обрубков идентификаторов 978 против 11.
**Победителя на все документы тут нет - прогоните оба режима и сравните.** Поток отменяет физическую раскладку, и на документе, где раскладка и есть граница сущности (две колонки "имя - фамилия", метка реквизита слева и значение справа), он способен размотать колонки и развести то, что было парой. Критерий выбора - не читаемость текста, а целость сущностей: в потоке ФИО собрано целиком, метка стоит рядом со своим значением.
Штатная проверка после маскировки (ниже) этот промах **не поймает**: она считает остаточных кандидатов ПД, а разорванная сущность кандидатом уже не выглядит и дает честный ноль. Сравнивать надо два извлечения между собой, до маскировки.
**`pdftotext` не делает OCR.** У скана он вернет пустой текст, у смешанного документа - только страницы с текстовым слоем, молча. Прежде чем работать с результатом, сверьте объем: пусто или подозрительно мало - документ сканированный, и маскировать в нем нечего, потому что и текста нет.
### Маскировка
```bash
pii-mask mask <исходник> -o <рабочая>/имя.masked.md --mapping <рабочая>/имя.mapping.json
# при необходимости: --audit (второй проход локальной LLM), --types, --no-ner
```
Дальше агент читает `имя.masked.md`, делает свою работу (саммари, разбор, письмо), кладет результат в файл - и разворачивает:
```bash
pii-mask unmask <результат> --mapping <рабочая>/имя.mapping.json -o <результат>.final.md
```
**Проверить, что маскировать сейчас можно,** - двумя командами, и обе нужны:
```bash
pii-mask where # откуда работает код: путь импортированного модуля
D=$(mktemp -d) # приватная папка: mapping пробы не должен лежать по угадываемому пути
echo "Иван Петров, +7 912 345-67-89" | pii-mask mask - --mapping "$D/probe.json"; RC=$?
rm -rf "$D"; [ $RC -eq 0 ] || echo "!!! проба маскирования упала (код $RC)"
```
Первая показывает, какая копия исполняется (форма 4 в `silent-failure.md`): путь должен вести в установленный экземпляр; рабочее дерево репозитория или снесенный с диска каталог - поломка. Вторая - что маскирование работает на заведомо непустом входе: имя в выводе заменено меткой (`{{PERSON_1}}`), телефон - форматным фейком (`+7 000 ...`). Проба упала или имя с телефоном остались как были - маскировать нельзя, текст модели не отдается.
**Живость сервиса проверкой не считается.** `systemctl ... is-active`, открытый порт и `/health/live` с ответом `ok` молчат одинаково при исправном и мертвом маскировщике - прецедент 28.09.2026: HTTP-шлюз месяц работал из удаленного каталога, Linux держал открытый файл, и все три признака были зелеными, а каждый запрос `/mask` возвращал 500. Health-ручка остается для мониторинга самого сервиса.
**Основной путь - CLI.** HTTP-путь (`POST /mask`, `POST /unmask`) - вариант для того, кто поднял сервис осознанно и сам следит, из какой копии он работает: второй канал живет своей жизнью и протухает незаметно, а выигрыш в скорости - секунды (замер: CLI 2,7 с против 0,8 с у сервиса). Сервис запускается из установленного экземпляра. Из рабочего дерева репозитория нельзя: там незакоммиченная правка, переключенная ветка или проверка теста меняют поведение боевого маскировщика.
## Куда класть рабочие файлы
`--mapping` и `-o` задавать **явно**, а не полагаться на дефолт: по умолчанию оба файла ложатся рядом с исходником, а исходник обычно лежит в синкаемой папке или в git-репозитории. Mapping с настоящими ПД, уехавший в облачный синк или в коммит, - ровно та утечка, ради предотвращения которой все и затевалось.
Рабочая папка - локальный временный каталог сессии. Права `600` на mapping шлюз ставит сам.
## Когда включать `--audit`
Второй проход локальной модели ловит косвенное - "моя жена Оля", адрес без формата, ник без собачки. Regex и NER такого не видят.
Цена - время: порядка получаса машинного счета на транскрипт часовой встречи (CPU). Это батч-режим: запускать в фоне и не ждать в диалоге. Для короткого текста, где важна только форматная часть (телефоны, почты, ИНН), аудит не нужен.
Аудит fail-closed: запрошен, а локальная LLM недоступна - будет ошибка, а не тихая маскировка без аудита.
## Что проверить после маскировки
Прежде чем отдавать текст модели - убедиться, что маскировка сработала, **не вынося значения наружу**: посчитать остаточных кандидатов ПД в замаскированном тексте и дословные утечки оригиналов из mapping, напечатав только счетчики и типы.
Ноль - можно работать. Не ноль - **смотреть глазами локально**, а не печатать найденное в лог сессии. Счетчик говорит "что-то не так", но не "что именно": на живом транскрипте расхождение счетчиков означало не пропущенные ПД, а ложную сущность - NER принял за имя обычное слово, и в тексте остались другие его вхождения. Диагноз по одним счетчикам ставить нельзя, и тем более нельзя по ним править код.
## Чего скилл не делает
Скилл закрывает один сценарий: **текст уходит машине**, и кривая маскировка там не страшна - лишняя маска мешает модели чуть-чуть, а пропуск ловится тем, что модель персданных не запоминает.
Второй сценарий - **обезличить документ, который прочитает человек** (кейс для показа, публичный разбор, портфолио) - этим скиллом не решается. Машинный проход годится там только как **детектор**: показать, что вы забыли. Исполнителем он быть не может по двум причинам: оставляет квазиидентификаторы (см. ниже) и портит текст ложными масками, а документ для человека обязан читаться. Такой документ **переписывается вручную**, а не маскируется.
### Квазиидентификаторы: чего движок не поймает по определению
Он ищет прямые идентификаторы - имя, телефон, почту, домен. Человека же опознают их сочетания, каждое из которых по отдельности безобидно:
- работодатель плюс город плюс точные даты работы;
- уникальный или редкий тайтл должности;
- вуз плюс год выпуска плюс специальность;
- узкая предметная область на маленьком рынке.
Реальный случай: "руководитель направления аналитики сервисных функций и взыскания" в конкретном банке - это один человек на рынке, и вычищенное имя не спасает.
Лечится не маской, а **огрублением**: "банк из топ-20" вместо названия, "2019-2024" вместо точных месяцев, "руководитель аналитического направления" вместо точного тайтла. Огрубление - решение автора документа, не агента: степень допустимой потери смысла знает только он.
## Границы
- Имена возвращаются в именительном падеже: "заявка для Иван Петров". Это осознанное ограничение шлюза, не баг.
- Города и страны по умолчанию не маскируются - в рабочих текстах это чаще контекст, чем ПД.
- NER обучен на новостях: спонтанная речь и редкие имена даются хуже, а расшифровка без нормальной пунктуации дает ложные срабатывания на обычных словах. Для чувствительного текста - `--audit`; для расшифровок - проверять результат.
- Разнос сущности по документу ("нашли один раз - замаскировать все вхождения") допустим только от **детерминированного** признака: организационно-правовая форма плюс название в кавычках. От находок NER так делать нельзя - одно ложное срабатывание размножится на весь документ и сделает текст непригодным для модели, ради которой все и делается. Разносятся различающие слова названия, а не родовые: у "ООО Бюро кредитных историй Ромашка" по документу поедет "Ромашка", но не "бюро" и не "кредитных". Короткая аббревиатура - только в своем регистре: "ОКБ" это организация, "окб" внутри слова случайность.
- Цена разноса - лишняя маска, и она никуда не делась: различающее слово может совпасть с обычным (название "Мир" замаскирует слово "Мир" в соседней фразе) или с фамилией человека (название "Иванов" и директор Иванов получат одну ORG-метку). Список родовых слов в движке конечный, за его пределами это не ловится. Название короткое или словарное - проверить, что разнос не задел лишнего, прежде чем отдавать текст модели.
- Mapping удаляется вместе с задачей. Это единственное место, где ПД существуют вне исходника.