Хуки — безопасность и качество

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);

Псевдонимизация анонимизация: данные остаются персональными и ре-идентифицируемыми; 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.yamlsecurity.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): этот хук срабатывает на каждом финальном ответе.

Точки:

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).