Model content policies¶
Editions: OSS, Cloud, Enterprise. Unless stated otherwise, everything on this page ships in OSS.
For detection limits, storage modes, reference-only verification and complete patient/payment examples, see Sensitive-data handling.
Model I/O rules extend the existing policy engine so instance policies can
inspect model prompts and completions. Actions are the same as tools
(allow, deny, and require_approval) plus notify, which is only
available on model I/O rules (see Notify-only rules).
This is policy on Preloop-governed traffic. It is not a standalone guardrail product.
Targets¶
Use these stable names:
model.request: evaluated before the provider is calledmodel.response: evaluated after the provider returns, before bytes reach the client
When no model I/O rules exist, traffic is allowed. That matches tool
evaluation when no access rule matches (No access rules defined /
No rules matched (default allow) in
backend/preloop/services/policy_evaluator.py).
Console¶
/console/policies is the instance-wide authoring page (sidebar item
next to Tools), available by default to users with view_policies permission.
Operators can hide the page with PRELOOP_POLICIES_CONSOLE=false; instance
admins retain access and backend permission checks remain in force.
The per-tool approval widget on Tools links here through Manage in Policies.
In the OSS edition, /console/governance redirects here. With the Enterprise
weekly review enabled, that route opens the report under Audit > Weekly review.
Create, edit, disable, and delete rules from the Rules list. The primary action is Describe a change: the account default model proposes an edited YAML document. The UI shows a unified YAML diff against the current export. Save applies it; Discard does not. Generation never auto-applies.
The guided Add rule form includes:
- target (
model.requestormodel.response) - action (
allow,deny,require_approval,notify; tool rules do not offernotify) - the existing approval-workflow picker (not needed for
notify) - a condition field
- detector toggles for PII, injection, and moderation
YAML import and export on the same page round-trip these rules. Omitting
model_io (or setting it to null) preserves the existing rules. An explicit
model_io list replaces the section; use model_io: [] to remove all Model I/O
rules. The preview reflects these import semantics, so omitted rules are not
listed as removals. To remove only one rule through YAML, supply the complete
list you want to keep.
Condition attributes¶
Canonical text fields:
request.text: concatenated message contents (and Responsesinputwhen present)response.text: assembled assistant text plus returned reasoning/thinking text (including DeepSeek reasoning content, Anthropic thinking, and Responses reasoning summaries). Opaque encrypted reasoning and signatures are not text.
Also available:
model.id,model.provider,model.namesession.idwhen a runtime session is presentpii.found(bool),pii.types_found(list),pii.count(int)injection.score(0-1),injection.matched_patterns(list)moderation.flagged(bool),moderation.categories(list)
Simple and CEL expressions work the same way as tool conditions.
Example: pii.found == true, injection.score > 0.7.
Injection scoring is best-effort. It reuses the deterministic
security_screen prompt-injection heuristics. It is not a guarantee.
Detectors¶
Detectors run only when a rule enables them, or when a condition
references their attributes (pii., injection., moderation.).
| Detector | Default | Result attributes |
|---|---|---|
| PII | email, phone, credit-card (Luhn) | pii.found, pii.types_found, pii.count |
| Injection | security_screen regex |
injection.score, injection.matched_patterns |
| Moderation | local keyword ruleset (local) |
moderation.flagged, moderation.categories |
Sensitive data types¶
pii.types accepts every built-in type and the name of a custom entry
declared under sensitive_data.detectors. A rule that lists no types scans
the account default (sensitive_data.detectors.types), and without one the
original three.
| Type | What it matches |
|---|---|
email |
mailbox addresses |
phone |
E.164 plus US and European national formats |
credit_card |
13 to 19 digits that pass the Luhn check |
iban |
IBANs with a valid mod-97 check |
ip_address |
IPv4 and IPv6 addresses that parse |
date_of_birth |
the nearest date to a birth keyword (DOB, born, Geburtsdatum, date de naissance, ...) |
national_id |
US SSN shape, DE tax id (ISO 7064 check), UK NINO, FR NIR (key), NL BSN (11-proef, keyword anchored); narrow with locales: [us, de, uk, fr, nl] |
medical_record_number |
an identifier after MRN or patient-id keywords; medical_record_number_pattern replaces the identifier shape |
person_name |
honorific or label anchored names (Dr. X, patient: X); low recall by design |
Account-defined entries:
sensitive_data:
detectors:
types: [email, iban, employee_id] # default scan set (optional)
locales: [de, nl] # national_id locales (optional)
custom_patterns:
- name: employee_id
regex: 'EMP-\d{6}'
flags: [i]
keywords:
- name: codenames
terms: ["Project Phoenix"]
case_sensitive: false
An account may configure at most 50 custom patterns and 50 keyword lists.
Custom regexes are capped at 512 characters, must not nest quantifiers
((a+)+) or use backreferences, and each pattern has a 0.25 second match
timeout. All account patterns on one text share a one-second budget;
exceeding either limit counts as a detector timeout for the rule. Keyword
lists match whole words only.
GET /api/v1/policies/sensitive-data/types lists every selectable type
with a label, description, example and locales. POST
/api/v1/policies/sensitive-data/test runs the detectors on sample text
and returns the match spans; the text is never logged or stored.
Sensitive data rules on tool calls¶
sensitive_data.rules run the same detectors on MCP tool calls and, for
model targets, compile into ordinary model I/O rules so each path keeps one
evaluator.
sensitive_data:
rules:
- id: block-cards-in-tools
on: [tool.args, tool.result, model.request, model.response]
scope: {agents: [], tools: [], servers: []} # empty means all
types: [credit_card, iban] # default: the detectors block types
action: deny # notify | deny | require_approval
- id: ask-before-sharing-records
on: [tool.result]
scope: {tools: [get_patient_record], servers: [ehr]}
action: require_approval
approval_workflow: humans
scope.agentsholds managed agent ids,scope.toolsclient-visible tool names,scope.serversMCP server names (case-insensitive; builtin tools belong topreloop-mcp). Model targets honouragentsonly.tool.argsruns before the call.denyrefuses with the types found (never the values);require_approvalcreates an approval through the rule's workflow or the account default and fails closed without one;notifyrecords a notice and lets the call through. Tool access-rule conditions can readpii.found,pii.types_found,pii.countandpii.pathsfrom the scan next toargs.tool.resultruns after the call.denyreplaces the result with a refusal;require_approvalholds the result until a human decides.- Audit rows carry the rule id, target, types found, argument paths and a SHA-256 of the scanned text. With no rule in scope no detector runs.
Tool metadata and argument predicates¶
For server allowlists, write-tool patterns and bulk-export checks, a
sensitive_data.rules entry may use a CEL condition instead of detector
types. It binds args, tool.name and tool.server, runs only on
tool.args, and supports notify, deny and require_approval. Evaluation
errors skip notify rules; deny and approval rules fail closed. A condition
rule cannot use detector types or redact.
See the downloadable pack in Governing back-office AI use.
Reference-only logging¶
sensitive_data.reference_only names tools, servers or agents whose calls
must leave no payload behind. Every store that would hold the arguments or
the result of an in-scope call (audit rows, policy decision rows, approval
rows, activity summaries, the tool-call search index) holds a reference
record instead: tool, server, principal, rule id, the values named by
keep_fields, args_hmac and result_hmac, byte sizes, key names, timing
and cost.
sensitive_data:
reference_only:
- id: patient-tools
scope: {agents: [], tools: [get_patient_record], servers: [ehr]}
keep_fields: ["$.consent_id", "$.call.id"] # dotted keys, [n], [*]
approver_view: redacted # redacted | original_until_decided
A path that starts with $result instead of $ reads the tool result.
For a tool that returns the consent it acted under:
sensitive_data:
reference_only:
- id: owner-data-reference-only
scope: {tools: [read_scope], servers: [lifecare-sandbox-source]}
keep_fields:
- "$.scope" # from the arguments, stored under kept
- "$result.consent_id" # from the result, stored under kept_result
The value is read from the result's structuredContent; when that is
absent or lacks the path, from the first text content block parsed as
JSON (a tool that returns a JSON string arrives that way). A missing path,
or a result that is not JSON, keeps nothing. The result itself is
never stored: the audit tool_call row gets kept_result (here
{"$result.consent_id": "cons-42"}) and result_hmac, the salted
fingerprint of the result, next to kept and args_hmac. Kept values,
argument or result, go through the redact rules in scope first. $.result.x
still means an argument named result.
The tool_call row's rule_matched holds the access rule the policy
evaluator matched for the call (the same text as rule_description on
the policy_* row with the same correlation_id). The reference-only
rule id stays in the record's rule_id.
New fingerprints use scrypt over canonical JSON with a per-account salt
stored encrypted; historical HMAC-SHA256 records remain verifiable. The
legacy field names args_hmac and result_hmac hold either format. Each
record carries its salt_id, so a rotation leaves old records verifiable. POST /api/v1/policies/sensitive-data/hash-check
(manage_policies) answers whether a candidate payload matches a stored
fingerprint for the caller's account without storing it. The audit chain
export lists the account's salt ids and never a salt.
Approvals: with approver_view: redacted the approver sees the reference
record. With original_until_decided the raw arguments stay encrypted on
the pending row, are returned by
GET /api/v1/approval-requests/{id}/original-args (console, decide
permission) while pending, and are deleted in the same transaction as the
decision. Emails, webhooks and push payloads never carry them. An
asynchronous post-approval replay of a reference-only call cannot
reconstruct the arguments and reports that; the in-process approval wait
keeps the original in memory and is unaffected.
Retention and legal holds: reference records follow the existing retention of their store; a legal hold keeps the records but cannot resurrect payloads that were never stored.
Each rule has detector_timeout_ms (default 500) and
on_detector_timeout (default deny, fail closed). Set
on_detector_timeout: allow to skip that rule on timeout. A rule whose conditions are all notify never blocks on
timeout: the rule is skipped.
Application logs never include full prompts. Audit and approval tickets store the rule id, detector summary, and a SHA-256 of the scanned text. They do not store a raw preview (the preview can be the PII the rule blocked).
Streaming¶
Response rules use buffer-until-assembled: the gateway holds SSE events
until response.text is complete, evaluates policy, then either replays
the buffered events or returns a deny error. The blocked payload is
never sent to the client. When no model.response rules exist, streams
pass through without buffering.
Buffering is required for deny and require_approval. Tokens already
sent to the client cannot be retracted, so a rolling window cannot
enforce those actions (it can miss PII from earlier chunks and cannot
unsend them). The tradeoff: time-to-first-token becomes time-to-last-token
when any model.response rule is enabled. Issue #313 allowed either
buffer-until-assembled or a rolling window; this implementation keeps
buffering for correctness of deny.
When every enabled model.response rule is notify-only, the stream is
not buffered. Events reach the client as they arrive and the rules are
evaluated once the stream ends (or the client disconnects). If any
enabled response rule can deny or require approval, the stream is
buffered as above and notify conditions are evaluated on the same
assembled text.
Deny errors¶
Denied requests return HTTP 403 with:
code:content_policy_deniedmessage:Blocked by content policy (rule <id>)
OpenAI-compatible clients can surface that message.
Approvals¶
require_approval uses the existing tool-approval workflow
(require_approval / approval_service). The hold appears in the
existing approvals inbox. The ticket includes rule id, detector
summary, and a SHA-256 of the scanned text. It does not store the full
prompt or a raw preview. HTTP workers and background optimization jobs
run the hold on the application event loop. Callers with no loop fail
closed instead of creating a temporary loop. A required approval pauses
a background optimization worker for the approval window (default 5
minutes, configurable up to 30 days).
Notify-only rules¶
A condition with action: notify lets the call through exactly like
allow: no approval, no buffering, no error to the client. It records
that the rule matched and tells the policy owners.
Evaluation order:
- A matching notify condition records one hit for its rule and evaluation continues with the next rule. A rule records at most one hit per evaluation, even when several of its notify conditions match.
denyandrequire_approvalstill use first match wins, and that terminal match is stored as a hit with its action.allowis not stored. A notify rule listed before the terminal match still records its hit (so a denied call can also produce a notice). Rules after the terminal match are not evaluated.- A notify condition whose expression fails to evaluate is skipped.
- When only notify rules matched, the decision is
notify, which proceeds likeallow.
Each notify, deny, and require_approval match is stored in the
policy_notice_hit table with the account, user (when known), target,
rule id and description, time, the SHA-256 of the scanned text, an
excerpt of at most 280 characters around the match, the action
(notify, deny, or require_approval), the model alias, the runtime
principal (agent_ref), the runtime session id, and the audit event id.
The excerpt goes through the same secret redaction as the gateway logs.
If redaction fails, only the hash and rule id are kept. The full text
is never stored. redact is reserved on the column. A redact match is
audited as counts by type and is not stored as a hit, because an excerpt
would keep the value the rule exists to remove.
The core audit service records an audit row for each stored hit in every
edition. Notify rows use action notify. Deny and require_approval
rows use those actions. The hit's audit_event_id is that audit row's
id. The audit service nests tool arguments under details.tool_args and then
copies extra_details onto the top level of details, which is where
audit_event_id has to sit.
Policy owners are told about a notify hit through:
- email, for owners whose notification preferences leave email on
- mobile push, for owners with
enable_mobile_pushon - the rule's approval workflow (or the account default) when that workflow is of type Slack, Mattermost or webhook
Owners are active users who are the account's primary user, a
superuser, or hold the manage_policies permission. The message names the rule, includes the
redacted excerpt, and has no approve or deny link. Messages are
debounced to one per rule, per user, per hour. Hits are still recorded
every time.
Where hits show up:
- Attention: one "Policy notice" card per rule with hits in the last 7 days. Dismissing it hides it until the rule matches again.
- Optimization digest: a "Policy notices" section with each rule's hit
count over one window, the last user and the last excerpt. The window is
the 7 days ending at
nowby default, or exactly thestartandendthe caller passes; both bounds are always applied, and the section reports the window it covered aswindow_startandwindow_end. The digest is sent by the EE optimization plugin; without it, nothing is sent. GET /api/v1/policies/notices/summary?days=7(requiresview_policies).group_byisrule(the default),user, oraction. The default rule list is notify hits only, which is what the Attention cards use.group_by=userandgroup_by=actioncount deny and require_approval hits as well. The response also includes model and agent breakdowns of every stored action. The rule list, the user list, and each breakdown are capped at 100 rows. Within that cap, the per-user counts add up to the rule totals. Past it the lists are truncated separately, so those two sums can diverge.
Example:
model_io:
- id: notify-codename
description: Mentions of the internal codename
target: model.request
conditions:
- expression: "request.text.contains('project-x')"
action: notify
notify is rejected on tool rules.
YAML example¶
version: "1.0"
metadata:
name: Content policies
approval_workflows:
- name: high-risk
timeout_seconds: 300
model_io:
- id: deny-pii-in-prompts
target: model.request
detectors:
pii:
types: [email, phone, credit_card]
conditions:
- expression: "pii.found == true"
action: deny
- id: approve-flagged-output
target: model.response
approval_workflow: high-risk
detectors:
moderation: true
conditions:
- expression: "moderation.flagged == true"
action: require_approval
- id: deny-injection
target: model.request
detectors:
injection: true
conditions:
- expression: "injection.score > 0.7"
action: deny
Optional sensitive_data, access_rules, access_rule_mode, resource_access, and
resource_shares sections also preserve their current configuration when omitted
or set to null. Explicit values replace the corresponding section; use []
for list sections or {} for configuration maps to reset them. The preview
uses these same import rules.
The mcp_servers, approval_workflows, and tools sections instead add or
update named entries. Omitted/null sections, empty lists, and entries absent
from a supplied list preserve the existing entries. Importing [] does not
clear these sections, and the preview does not report those entries as removed.