Hooks — security and quality

Output guardrails and DLP: validating the final answer and protecting data. The list of all hooks is in 06-hooks-reference.


6.9 link_check

Output guardrail: checks links in the final answer and flags those that are not confirmed by sources (grounded) or are dead (live). Grounding is done against the RAG agent's archive (the same archive::open_cached as rag/archive_tool); structural checks (absolute http(s), public host) are always performed. Catches URLs hallucinated by the model — the main failure mode of an agent that the RAG directive asks to cite sources.

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 (тело не читается)
Field Description
mode grounded — against the archive; well_formed — structure only; live — structure + HTTP liveness; both = grounded + live
archive_path archive directory; empty/missing → grounded check disabled (neutral)
check_bare_urls / check_html_anchors which link forms to scan
max_links more links — the hook is neutral (does not check)
on_invalid notice — notification (default); annotate — suffix to the link text; unlink — link → text; rework — reject the answer and force the turn to continue (see §6.1 rework)
notice_template / annotate_template / rework_template {{count}} / {{title}} / {{links}}
rework_clear_stream with rework, clear the already-delivered answer (replace). false — keep the stream (visible self-correction or a client without replace)
allow_hosts host allowlist for live (empty = any public); a host outside the list is not probed (not flagged)
timeout_ms / cache_ttl_secs / max_body_bytes timeout, liveness cache TTL, method (HEAD/GET)

"Valid" = grounded (the URL is in the archive; a trailing / and #… are allowed), structurally safe, and, in live, live. live does not follow redirects (3xx = live) — this avoids SSRF via redirect; 401/403/405/406/429 are considered reachable (anti-bot), a timeout is "unknown" (not flagged), and 404/410 and network errors are broken. Checks run in parallel and are cached in memory by URL. On any error (no archive, no matches, too many links) the hook is neutral — the turn is not broken.

annotate / unlink modify the answer and rely on the re-emit contract (replace); they are safe only on clients that can replace the displayed answer. The default is notice.

rework does not modify but rejects the answer: the core clears the already-delivered stream (best-effort, replace: true) and injects a correction message with a list of unconfirmed links, after which the model answers again — the agent fixes the links within the same turn. The cap of MAX_REWORK_ROUNDS (2) guarantees completion: if the model fails, the turn closes on the last answer (degradation, without looping).


6.12 format_guard

Deterministically tidies up the final answer on after_assistant_message (without an LLM): makes links clickable and removes "bare" URLs. The hook mutates the answer in place, the core re-emits the corrected version; if there is nothing to change — it is neutral.

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 при изменении (пусто = тихо)
Field Description
linkify_bare_urls wrap bare http(s) URLs in markdown links (does not touch already formatted ones)
dedupe_links duplicates by target are replaced with their text
one_link_per_line if a line has several links — splits them across lines
stream format on_token: the URL is buffered and wrapped, the client sees the corrected text immediately
notice UI notification if the answer was changed

Stream. after_assistant_message modifies the already saved/delivered answer, and the core re-sends the full final_answer (re-emit) — for streaming clients this is an extra frame. With stream: true, links are formatted right in the stream (on_token): safe characters go through immediately, while the URL is buffered until the terminator and inserted already as [url](url). after_assistant_message remains the authoritative final pass (dedup, one-per-line, non-streaming channels); re-applying is idempotent.


6.13 guard

A single stateless security membrane. Scans text at four model boundaries with a common engine agent_os_core::safety::scanner and applies an action per entity class:

Boundary Point What it covers
Ingress (into the model) before_inference messages + tool schemas (+ rag/memory injections)
Egress (to the user) after_assistant_message final answer
Tool-args (from the model) before_tool_call call arguments
Tool-result (into the model) after_tool_result tool result

Actions: audit_only · notice · redact (irreversible mask [REDACTED]) · deny (refusal) · suppress · rework (rewrite the answer). tokenize is unavailable in guard — use pseudonymize. When classes are mixed, the strictest action wins.

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
Field Description
direction which boundaries are active
on_input / on_output / on_tool_args / on_tool_result default action for a boundary
entities entity classes; empty = all built-in
actions action override for a specific class
allow values/substrings that are not flagged
patterns additional regexes per class (person, org, …)
ner_model id of the ONNX NER model for this hook (per-agent); empty = selector by agent pattern → default
audit_only count but do not modify the text
notice_template {{count}} — number of triggers
rework_template {{kinds}}, {{count}} — correction text for the model
Fail-open. A scan error leaves the text as is. Values are not
logged — only types and counters.

NER. detector: ner / hybrid supplements the rules with a local ONNX model (classes in ner_classes, e.g. [person, org, address]) via the core's shared ONNX service; without the service, the hook stays on the rules. Model selection — ner_model (per-agent hook YAML), otherwise the selector by agent template (templates), then default.


6.14 pseudonymize

Reversible surrogate substitution on top of the same scanner. The model sees only [TYPE_n]; the real values are stored locally in a DuckDB vault and expanded on output.

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

Pseudonymization ≠ anonymization: the data remains personal and re-identifiable; the vault never leaves the perimeter.


6.16 groundedness

Output guardrail on after_assistant_message: splits the final response into claims (sentences) and checks each against the available evidence — the share of content tokens of the claim found in the evidence must be at least min_support. Unsupported claims are shown as a notification; the response is not changed and not suppressed. Catches what link_check misses: a real link, but a fabricated claim. Deterministic, no LLM and no network.

Evidence (evidence) — the RAG agent's archive (BM25 search for each claim) and the session's successful tool results (ngu_search, http_fetch, ... — what the agent actually received).

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
Field Description
archive_path archive directory (evidence); empty → archive source disabled
evidence auto/both — archive + tools; archive — archive only; tools — tools only
tool_results_limit how many of the latest session tool results to consider
max_evidence_chars cap on the total evidence text from tools
min_support claim token coverage threshold against evidence (0..1)
min_claim_chars / min_claim_tokens filtering out short/non-factual sentences
max_claims more — the hook does not check (neutral)
top_chunks how many archive chunks to take per claim
skip_questions do not count as claims
on_invalid notice — notification (default); rework — reject the response and continue the turn ({{count}}, {{claims}}, see §6.1)
rework_template / rework_clear_stream correction message template and stream cleanup for rework
notice_template {{count}} (number) and {{claims}} (first unsupported)
max_notice_claims limit on claims in the notification text

URLs and markdown markup are stripped from the claim before tokenization (markdown link [текст](url) → текст). The archive is opened once and cached (shared archive::open_cached with rag/link_check/archive_tool); tool results are taken from HookContext.tool_results (ToolResultLookup::recent_all, in-memory per-session, successful only). Neutral if no source is available. Paraphrase and synthesis may be flagged as unsupported — hence by default notice; rework should be enabled after calibrating the threshold on your own corpus. Works alongside link_check (the hooks are independent).


6.18 language_guard

Ensures the final response is written in the expected language — the user's language (target: auto) or a specified one. A hybrid of three points: capture of the language on the user message (before_user_message), an optional preventive directive (before_inference), and a guard of the final response (after_assistant_message).

The key point: after_assistant_message triggers only on the final (tool-free) response, and the user message is no longer there — so the user's language must be captured in advance (in-memory per process, cleared on on_terminate/on_session_closed).

Detection. Script-based, deterministic, no LLM: letters are classified by Unicode ranges (Latin, Cyrillic, Greek, Arabic, Hebrew, Devanagari, Han, Hangul, Thai), and the dominant group is taken. Distinguishes ru↔en, but not en/es/de — for languages of the same script, a refinement tier is enabled (detector):

detector What it does Cost
script script only (default) zero
ngram whatlang trigram detector, in-process zero, no models/network
onnx tiny language-id model via the core's ONNX service (onnx_model, default langid; auto-download) local inference
llm child judge process tokens

When min_ratio is below the threshold, the response is considered mixed and is not flagged; short responses (min_chars) are not checked.

The langid model (mini-BERT, ~25 MB int8, 200 languages) is already registered in config/server.yaml → security.onnx.models and is downloaded automatically on first use (download: auto, repository onnx-community/language_detection-ONNX, pinned revision). A server build with the onnx feature is required. If the ONNX service or model is unavailable, the hook is neutral — the turn is not broken.

Which languages are "ok". allowed (list of codes/scripts) is stronger than target (auto = the user's language). Distinguishing languages within a single script (en/es, ru/uk) is only possible with detector: ngram/onnx/llm; with detector: script, any response in the same writing-system family is acceptable.

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}}."
Field Description
target auto — follow the user; otherwise code (ru) or script (cyrillic)
allowed allowlist of languages/scripts; stronger than target
min_chars / min_ratio response length threshold and script-detection confidence threshold
sample_chars how many characters of the user message to pass to the tier
on_mismatch notice (default) or rework (rewrite in the same turn)
notice_template / rework_template {{expected}}, {{detected}}
rework_clear_stream whether to clear the already-emitted stream on rework
detector script / ngram / onnx / llm (see the table)
onnx_model language-id model id for detector: onnx (default langid)
model / min_confidence / judge_instructions LLM judge (detector: llm)
inject_directive / directive_template the "reply in …" directive ({{language}})

Outcomes. Match / nothing to check → neutral. Mismatch → notice or rework (the core clears the stream and injects a system correction, cap MAX_REWORK_ROUNDS). replace/suppress are not used: text cannot be translated deterministically (see §6.1 rework). The directive is idempotent (marker).


6.28 schema_guard

Validates structured output against a JSON Schema and, on mismatch, either asks the model to correct itself (rework) or flags it (notice). Complements the output_schema of the process (which only checks the job_done payload): this hook fires on every final response.

Points:

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: исключить
Field Description
target answer (default) or tool_args
schema JSON Schema for validation
on_invalid answer: notice (default) / rework; tool_args: deny (default) / notice
notice_template / rework_template {{errors}} (and {{tool}} for notice)
tools / exempt_tools tool_args: allowlist / tool exceptions

For answer, JSON is extracted even from a fenced block (```json … ```) or text containing a JSON object/array. The validator is the same as for output_schema (agent_os_core::json_schema).