Хуки — безопасность и качество
Output-guardrails и DLP: проверка финального ответа и защита данных. Список всех хуков — 06-hooks-reference.
6.9 link_check
Output-guardrail: проверяет ссылки в финальном ответе и помечает те, что не
подтверждаются источниками (grounded) или мертвы (live). Заземление — по
архиву RAG-агента (тот же archive::open_cached, что у rag/archive_tool);
структурные проверки (абсолютный http(s), публичный хост) выполняются
всегда. Ловит выдуманные моделью URL — основной провал агента, которого
директива RAG просит цитировать источники.
kind: link_check
name: link_guard
mode: grounded # grounded | well_formed | live | both
archive_path: data/ngu # источник заземления (пусто = только структура)
check_bare_urls: true # «голые» http(s):// вне markdown
check_html_anchors: false # HTML <a href>
max_links: 20 # больше — нейтрально
on_invalid: notice # notice | annotate | unlink | rework
notice_template: "⚠ {{count}} ссылк(и) не подтверждены источниками"
annotate_template: "{{title}} ⚠"
rework_template: "" # для on_invalid: rework; {{links}}, {{count}}
rework_clear_stream: true # false = не очищать уже отданный стрим (видимая самоправка / клиент без replace)
# live-режим (обычно только на администраторском глобальном хуке):
allow_hosts: [] # пусто = любой; иначе leaf/example.com + поддомены
timeout_ms: 2000
cache_ttl_secs: 86400 # 0 = без кэша
max_body_bytes: 0 # 0 = HEAD; >0 = GET (тело не читается)
| Поле | Описание |
|---|---|
mode |
grounded — по архиву; well_formed — только структура; live — структура + HTTP-живость; both = grounded + live |
archive_path |
каталог архива; пусто/отсутствует → grounded-проверка выключена (нейтрально) |
check_bare_urls / check_html_anchors |
какие формы ссылок сканировать |
max_links |
больше ссылок — хук нейтрален (не проверяет) |
on_invalid |
notice — уведомление (по умолчанию); annotate — суффикс к тексту ссылки; unlink — ссылка → текст; rework — отклонить ответ и заставить ход продолжиться (см. §6.1 rework) |
notice_template / annotate_template / rework_template |
{{count}} / {{title}} / {{links}} |
rework_clear_stream |
при rework очищать уже отданный ответ (replace). false — оставить стрим (видимая самоправка или клиент без replace) |
allow_hosts |
allowlist хостов для live (пусто = любой публичный); хост вне списка не опрашивается (не флагается) |
timeout_ms / cache_ttl_secs / max_body_bytes |
таймаут, TTL кэша живости, метод (HEAD/GET) |
«Валидная» = заземлённая (URL есть в архиве; допускается хвостовой / и
#…), структурно безопасная и, в live, живaя. live не следует редиректам
(3xx = живая) — так избегается SSRF через редирект; 401/403/405/406/429
считаются достижимыми (антибот), таймаут — «неизвестно» (не флагается), а
404/410 и сетевые ошибки — битыми. Проверки идут параллельно и кэшируются в
памяти по URL. При любой ошибке (нет архива, нет совпадений, слишком много
ссылок) хук нейтрален — ход не ломается.
annotate / unlink меняют ответ и опираются на контракт пере-эмита
(replace); безопасны только на клиентах, которые умеют заменять показанный
ответ. По умолчанию — notice.
rework ответ не меняет, а отклоняет: ядро очищает уже отданный стрим
(best-effort, replace: true) и впрыскивает correction-сообщение со списком
неподтверждённых ссылок, после чего модель отвечает заново — агент правит
ссылки в этом же ходу. Кап MAX_REWORK_ROUNDS (2) гарантирует
завершение: если модель не справляется, ход закрывается на последнем ответе
(деградация, без зацикливания).
6.12 format_guard
Детерминированно приводит финальный ответ в порядок на
after_assistant_message (без LLM): делает ссылки кликабельными и убирает
«голые» URL. Хук мутирует ответ на месте, ядро пере-эмитит исправленный
вариант; если менять нечего — нейтрален.
kind: format_guard
name: format_guard
linkify_bare_urls: true # https://… → [https://…](https://…)
dedupe_links: true # дубли ссылок (по target) схлопываются
one_link_per_line: true # каждая ссылка с новой строки
stream: true # форматировать и поток токенов (on_token)
notice: "" # уведомление в UI при изменении (пусто = тихо)
| Поле | Описание |
|---|---|
linkify_bare_urls |
оборачивать голые http(s)-URL в markdown-ссылки (уже оформленные не трогает) |
dedupe_links |
повторы по target заменяются их текстом |
one_link_per_line |
если в строке несколько ссылок — разносит по строкам |
stream |
форматировать on_token: URL буферизуется и оборачивается, клиент видит поправленный текст сразу |
notice |
UI-уведомление, если ответ изменён |
Стрим. after_assistant_message меняет уже сохранённый/отданный ответ, и
ядро заново шлёт полный final_answer (ре-эмит) — для стриминговых клиентов
это лишний кадр. При stream: true ссылки форматируются прямо в потоке
(on_token): безопасные символы идут сразу, а URL буферизуется до
терминатора и вставляется уже как [url](url). after_assistant_message
остаётся авторитетным финальным проходом (дедуп, one-per-line, не-стриминговые
каналы); повторное применение идемпотентно.
6.13 guard
Единая мембрана безопасности без состояния. Сканирует текст на четырёх границах модели общим движком
agent_os_core::safety::scanner и применяет действие на класс сущности:
| Граница | Точка | Что покрывает |
|---|---|---|
| Ingress (в модель) | before_inference |
messages + tool-схемы (+ инъекции rag/memory) |
| Egress (к пользователю) | after_assistant_message |
финальный ответ |
| Tool-args (из модели) | before_tool_call |
аргументы вызова |
| Tool-result (в модель) | after_tool_result |
результат инструмента |
Действия: audit_only · notice · redact (необратимая маска [REDACTED])
· deny (отказ) · suppress · rework (переписать ответ). tokenize в
guard недоступен — используйте pseudonymize. При смешении классов
побеждает наиболее строгое действие.
kind: guard
name: dlp
direction: both # input | output | tool | both (input+output) | all
on_input: redact # audit_only | notice | redact | deny
on_output: rework # audit_only | notice | redact | suppress | deny | rework
# on_tool_args: redact # по умолчанию = on_input
# on_tool_result: redact
entities: [email, phone, card, iban, passport, inn, snils, api_key, pem]
actions: # переопределение действия на класс
api_key: deny
allow: ["support@company.com"]
patterns: # свой regex на класс (напр. person)
person: '\b[А-ЯЁ][а-яё]+\s[А-ЯЁ][а-яё]+\b'
detector: deterministic # deterministic | llm (принимается; в v1 = deterministic)
audit_only: false # true = только считать, не менять
notice_template: "⚠ Скрыто {{count}} чувствительных данных"
rework_template: "Убери эти данные и ответь заново: {{kinds}}"
rework_clear_stream: true
| Поле | Описание |
|---|---|
direction |
какие границы активны |
on_input / on_output / on_tool_args / on_tool_result |
действие по умолчанию для границы |
entities |
классы сущностей; пусто = все встроенные |
actions |
переопределение действия на конкретный класс |
allow |
значения/подстроки, которые не флагаются |
patterns |
дополнительные regex на класс (person, org, …) |
ner_model |
id ONNX-NER-модели для этого хука (per-agent); пусто = селектор по шаблону агента → default |
audit_only |
считать, но не менять текст |
notice_template |
{{count}} — число срабатываний |
rework_template |
{{kinds}}, {{count}} — текст коррекции для модели |
Fail-open. Ошибка сканирования оставляет текст как есть. Значения не логируются — только типы и счётчики.
NER. detector: ner / hybrid дополняет правила локальной ONNX-моделью
(классы в ner_classes, напр. [person, org, address]) через общий
ONNX-сервис ядра; без сервиса хук остаётся на
правилах. Выбор модели — ner_model (per-agent hook YAML), иначе селектор по
шаблону агента (templates), затем default.
6.14 pseudonymize
Реверсивная подстановка суррогатов поверх того же сканера.
Модель видит только [TYPE_n], реальные
значения хранятся локально в DuckDB-vault и разворачиваются на выходе.
kind: pseudonymize
name: pii_vault
scope: session # session | user | template
entities: [person, email, phone, address, org, passport]
detokenize_output: true # разворачивать ответ пользователю (on_token)
detokenize_tool_args: true # side-effect'ы идут с реальными значениями
tokenize_tool_results: true
fail_closed: true # не отправлять, если суррогат не удалось поставить
vault_path: data/pii_vault.db
placeholder_format: "[{kind}_{n}]"
output_dlp: true # страховка: маскировать сырое PII в ответе
patterns: {} # свой regex на класс (person/org требуют его)
allow: []
Таблица vault (data/pii_vault.db):
CREATE TABLE pii_map (
scope_key TEXT NOT NULL,
placeholder TEXT NOT NULL,
kind TEXT NOT NULL,
value TEXT NOT NULL,
created_at BIGINT NOT NULL,
last_used BIGINT NOT NULL,
PRIMARY KEY (scope_key, placeholder)
);
CREATE INDEX idx_pii_value ON pii_map(scope_key, kind, value);
- Одна сущность → один плейсхолдер (по
(scope, kind, value)); нумерация глобально уникальна, поэтому плейсхолдер чужого скоупа «неизвестен». scope: session(по умолчанию) — ключи умирают с сессией;user— консистентность между сессиями;template— общий на шаблон.on_tokenдетокенизирует поток с буферизацией (плейсхолдер, разорванный между чанками, всё равно разворачивается). Контекст/транскрипт остаётся в суррогатах — сырое PII в модель и в сохранённый ответ не попадает.fail_closed: недоступен vault/детектор → инференс отклоняется (deny), а не отправляется сырьё.detector: ner/hybrid+ner_classes— дополнить правила локальной ONNX-моделью через ONNX-сервис ядра.ner_model— выбрать конкретную ONNX-NER-модель (per-agent); иначе модель по шаблону агента (templates), затем default.ttl_secs/max_entries— ретенция vault (purge поlast_used, eviction).scope: session— пурж при закрытии сессии (on_session_closed);scope: user— миграция ключей при объединении пользователей (merge_user).vault_key_env/vault_key— ключ шифрования vault: при заданном ключе колонкаvalueшифруется AES-256-GCM, а поиск/дедуп идёт по HMAC-хэшу (value_hash); без ключа — plaintext (с предупреждением).GET /v1/pii/vault/:scope(admin) — subject-access экспорт расшифрованных записей vault для скоупа.
Псевдонимизация ≠ анонимизация: данные остаются персональными и ре-идентифицируемыми; vault не покидает контур.
6.16 groundedness
Output-guardrail на after_assistant_message: разбивает финальный ответ на
утверждения (предложения) и проверяет каждое по доступному evidence — доля
содержательных токенов утверждения, найденных в evidence, должна быть не ниже
min_support. Неподтверждённые утверждения показываются уведомлением; ответ
не меняется и не подавляется. Ловит то, что link_check пропускает:
настоящая ссылка, но выдуманное утверждение. Детерминированно, без LLM и сети.
Evidence (evidence) — архив RAG-агента (BM25-поиск на каждое утверждение) и
успешные результаты инструментов сессии (ngu_search, http_fetch, ... — то,
что агент реально получил).
kind: groundedness
name: grounding_guard
archive_path: data/ngu # источник evidence (общий archive::open_cached)
evidence: auto # auto | archive | tools | both (both == auto)
tool_results_limit: 32 # последних tool-результатов сессии в пул
max_evidence_chars: 200000 # потолок текста evidence из инструментов
min_support: 0.5 # доля токенов claim, найденных в evidence
min_claim_chars: 24 # короче — не claim
min_claim_tokens: 3 # меньше содержательных токенов — не claim
max_claims: 30 # больше — не проверять (нейтрально)
top_chunks: 3 # сколько чанков архива брать на claim
skip_questions: true # пропускать вопросительные предложения
on_invalid: notice # notice | rework
rework_template: "Эти утверждения не подтверждаются источниками: {{claims}}. Убери их или подтверди и ответь заново."
rework_clear_stream: true # для rework: очистить уже отданный стрим (replace)
notice_template: "⚠ {{count}} утверждени(й) не подтверждается источниками: {{claims}}"
max_notice_claims: 3 # сколько claim показать в notice
| Поле | Описание |
|---|---|
archive_path |
каталог архива (evidence); пусто → архивный источник выключен |
evidence |
auto/both — архив + инструменты; archive — только архив; tools — только инструменты |
tool_results_limit |
сколько последних результатов инструментов сессии учитывать |
max_evidence_chars |
потолок суммарного текста evidence из инструментов |
min_support |
порог покрытия токенов claim evidence (0..1) |
min_claim_chars / min_claim_tokens |
отсев коротких/нефактических предложений |
max_claims |
больше — хук не проверяет (нейтрально) |
top_chunks |
сколько чанков архива брать на одно утверждение |
skip_questions |
не считать вопросами-claim |
on_invalid |
notice — уведомление (по умолчанию); rework — отклонить ответ и продолжить ход ({{count}}, {{claims}}, см. §6.1) |
rework_template / rework_clear_stream |
шаблон correction-сообщения и очистка стрима для rework |
notice_template |
{{count}} (число) и {{claims}} (первые неподтверждённые) |
max_notice_claims |
лимит утверждений в тексте уведомления |
URL и markdown-разметка вырезаются из утверждения до токенизации (markdown-
ссылка [текст](url) → текст). Архив открывается один раз и кэшируется
(общий archive::open_cached с rag/link_check/archive_tool); результаты
инструментов берутся из HookContext.tool_results
(ToolResultLookup::recent_all, in-memory per-session, только успешные).
Нейтрально, если ни один источник недоступен. Перефраз и синтез могут
помечаться как неподтверждённые — поэтому по умолчанию notice; rework
включайте после калибровки порога на своём корпусе. Стоит вместе с link_check
(хуки независимы).
6.18 language_guard
Гарантирует, что финальный ответ написан на ожидаемом языке — языке
пользователя (target: auto) или заданном. Гибрид из трёх точек: захват
языка на сообщении пользователя (before_user_message), опциональная
превентивная директива (before_inference), и guard финального
ответа (after_assistant_message).
Ключевой момент: after_assistant_message срабатывает только на финальном
(tool-free) ответе, и сообщения пользователя там уже нет — поэтому язык
пользователя надо поймать заранее (in-memory на процесс, чистится на
on_terminate/on_session_closed).
Детект. Скриптовый, детерминированный, без LLM: буквы классифицируются по
Unicode-диапазонам (Latin, Cyrillic, Greek, Arabic, Hebrew, Devanagari, Han,
Hangul, Thai), берётся доминирующая группа. Различает ru↔en, но не en/es/de —
для языков одного скрипта включается уточняющий tier (detector):
detector |
Что делает | Цена |
|---|---|---|
script |
только скрипт (по умолчанию) | ноль |
ngram |
whatlang trigram-детектор, in-process |
ноль, без моделей/сети |
onnx |
крошечная language-id модель через ONNX-сервис ядра (onnx_model, по умолчанию langid; авто-загрузка) |
локальный инференс |
llm |
дочерний процесс-судья | токены |
При min_ratio ниже порога ответ считается смешанным и не флагается;
короткие ответы (min_chars) не проверяются.
Модель langid (мини-BERT, ~25 МБ int8, 200 языков) уже зарегистрирована в
config/server.yaml → security.onnx.models и скачивается автоматически
при первом использовании (download: auto, репозиторий
onnx-community/language_detection-ONNX, закреплённая ревизия). Нужен билд
сервера с фичей onnx. Если
ONNX-сервис или модель недоступны, хук нейтрален — ход не ломается.
Какие языки «ок». allowed (список кодов/скриптов) сильнее target
(auto = язык пользователя). Различать языки внутри одного скрипта
(en/es, ru/uk) можно только с detector: ngram/onnx/llm; при
detector: script допустим любой ответ того же семейства письменности.
kind: language_guard
name: language_guard
target: auto # auto | <код: ru, en, …> | <скрипт: cyrillic, latin, …>
allowed: [] # непусто = ответ обязан быть в одном из списка
min_chars: 40 # короче — не проверяем
min_ratio: 0.5 # доля доминирующего скрипта; ниже — смешанный текст
sample_chars: 400 # образец сообщения пользователя для tier'а
on_mismatch: notice # notice | rework
notice_template: "⚠️ Ответ не на языке запроса (ожидался {{expected}}, получен {{detected}})"
rework_template: "" # пусто = встроенный текст; {{expected}}, {{detected}}
rework_clear_stream: true
detector: script # script | ngram | onnx | llm
onnx_model: langid # id модели для detector: onnx (см. security.onnx.models)
model: "" # дешёвая модель для detector: llm
min_confidence: 0.6
judge_instructions: "" # свой prompt судьи (JSON-ответ)
inject_directive: false # превентивно на before_inference
directive_template: "Always answer in {{language}}."
| Поле | Описание |
|---|---|
target |
auto — следовать за пользователем; иначе код (ru) или скрипт (cyrillic) |
allowed |
allowlist языков/скриптов; сильнее target |
min_chars / min_ratio |
порог длины ответа и уверенности скриптового детекта |
sample_chars |
сколько символов сообщения пользователя отдать tier'у |
on_mismatch |
notice (по умолчанию) или rework (переписать в этом же ходу) |
notice_template / rework_template |
{{expected}}, {{detected}} |
rework_clear_stream |
очищать ли уже отданный стрим при rework |
detector |
script / ngram / onnx / llm (см. таблицу) |
onnx_model |
id модели language-id для detector: onnx (по умолчанию langid) |
model / min_confidence / judge_instructions |
LLM-судья (detector: llm) |
inject_directive / directive_template |
директива «отвечай на …» ({{language}}) |
Исходы. Совпадение / нечего проверять → нейтрально. Расхождение →
notice либо rework (ядро очищает стрим и впрыскивает system-коррекцию, кап
MAX_REWORK_ROUNDS). replace/suppress не используются: перевести текст
детерминированно нельзя (см. §6.1 rework). Директива идемпотентна (маркер).
6.28 schema_guard
Валидирует структурированный вывод по JSON Schema и при несоответствии либо
просит модель исправиться (rework), либо помечает (notice). Дополняет
output_schema процесса (тот проверяет только job_done-payload): этот хук
срабатывает на каждом финальном ответе.
Точки:
after_assistant_message(target: answer) — валидация ответа;on_invalid: notice | rework.before_tool_call(target: tool_args) — валидация аргументов инструмента;on_invalid: deny | notice(denyвозвращает failed-результат, модель видит ошибки и может повторить вызов).
kind: schema_guard
name: schema_guard
target: answer # answer | tool_args
on_invalid: rework # answer: notice | rework ; tool_args: deny | notice
schema:
type: object
required: [answer]
properties:
answer: { type: string }
# notice_template: "⚠ Схема не соблюдена: {{errors}}"
# rework_template: "Ответ не соответствует схеме: {{errors}}"
# tools: [structured_search] # tool_args: ограничить список
# exempt_tools: [] # tool_args: исключить
| Поле | Описание |
|---|---|
target |
answer (по умолч.) или tool_args |
schema |
JSON Schema для валидации |
on_invalid |
answer: notice (по умолч.) / rework; tool_args: deny (по умолч.) / notice |
notice_template / rework_template |
{{errors}} (и {{tool}} для notice) |
tools / exempt_tools |
tool_args: allowlist / исключения инструментов |
Для answer JSON извлекается даже из fenced-блока (```json … ```) или
текста с JSON-объектом/массивом. Валидатор — тот же, что у output_schema
(agent_os_core::json_schema).