Policy Authoring
Write declarative YAML policies that control how OGuardAI handles each entity type
Policies are declarative YAML documents that define how OGuardAI handles each entity type. They control detection, transformation, and restoration behavior.
Policy Evaluation Flow
Policy YAML Structure
name: "my-policy"
version: "1.0.0"
description: "What this policy does and when to use it"
rules:
- entity_type: "person"
protection_level: 2
action: "tokenize"
conditions: []
- entity_type: "email"
protection_level: 2
action: "tokenize"
conditions: []
- entity_type: "ssn"
protection_level: 1
action: "redact"
conditions: []
defaults:
protection_level: 2
action: "tokenize"
restore_mode: "full"
on_redact: "continue"
metadata_policy:
expose_gender: true
expose_formality: true
expose_language: true
expose_role: trueTop-Level Fields
| Field | Required | Description |
|---|---|---|
name | Yes | Unique policy identifier (referenced in API requests) |
version | Yes | Semantic version for change tracking |
description | No | Human-readable purpose description |
rules | Yes | List of entity-type rules |
defaults | Yes | Fallback values for entity types without explicit rules |
metadata_policy | Yes | Controls which metadata fields are exposed to the LLM (each flag defaults to false) |
custom_patterns | No | Policy-scoped regex detectors (additive; never suppress a built-in) |
detection | No | Detection controls: require NER for some entity types (fail closed), set a confidence floor |
Per-Policy Detection (custom_patterns, detection)
A policy can add its own detectors and tighten detection, for any tenant, domain, or locale, without code. These controls are additive and fail closed: a policy can only add detection or require more, never silently weaken it.
custom_patterns:
- entity_type: "employee_id" # a custom:<type> entity; use a built-in name to extend that type
pattern: '\bEMP-\d{6}\b'
confidence: 0.9
context_words: ["employee", "staff"]
detection:
required_for: ["person", "company", "location"] # fail closed if NER did not run for these
ner_min_confidence: 0.7 # NER-only precision floor; must be >= 0.65A custom_patterns regex must have a bounded match length and must not match arbitrary text.
\bEMP-\d{6}\b loads; \bEMP-\d+\b is refused for the first rule (bound it as \bEMP-\d{1,10}\b),
and a bare \d{1,10} with no literal around it is refused for the second. The bound is a
trust-boundary requirement rather than a tuning knob: when a response is streamed, the PII guard can
withhold only a bounded number of bytes (8192) while an entity assembles, so a match that can exceed
that would put the leading bytes of a real value on the wire before anything could mask them. The
full rules, including the whitespace exception and the budget a context_required pattern spends,
are in Extending Entities.
required_for makes the policy refuse a request rather than fall back to weaker regex-only
detection when the NER detector did not actually run for an NER-dependent type, so "the model
never sees a real name" holds even on a transient NER outage. ner_min_confidence raises the
precision floor for NER spans.
Renamed:
detection.min_confidenceis nowdetection.ner_min_confidence. The old key applied to every span, including built-in regex hits, so a policy that raised it could drop a hard identifier the regex detector had matched exactly. The floor now applies to NER spans only, where a confidence score is a model output rather than a fixed per-pattern constant. It is validated to[0.65, 1.0]. The rename is a hard load error, not a silent reinterpretation: a policy still usingmin_confidenceis rejected at load with an unknown-field error rather than being quietly mapped onto a key with different semantics. Rename the key and check the value against the new range.
ner_min_confidence is validated to [0.65, 1.0] and may only tighten: 0.65 is the built-in
NER floor and a policy cannot weaken it. A value outside that range is rejected at load, not
clamped. A required_for type is exempt from it entirely, so tightening one control can never
silently drop a type another control demands.
It never filters a fixed-confidence regex detection, wherever that detection was produced. This is decided per span from the producer the detector declared, not from which component the span arrived through: the NER sidecar is not only a model, it also runs its own regex patterns (for example German street suffixes the built-in patterns do not carry) and returns both kinds in one response. Those pattern spans carry a constant confidence their author chose, so a floor cannot read it as a quality signal, and applying one would delete the only detection of a real value. A sidecar that declares no producer for a span is treated the same way: the floor is skipped rather than guessed at, because the cost of skipping it is at worst masking something that did not need masking, while the cost of guessing wrong is leaving a real value raw.
The floor is a precision knob, not a calibrated probability. Confidence is per-backend and the
scales are not comparable: GLiNER returns raw model scores and spaCy stamps a default, so one value
means different things per backend. Tune it empirically against your own data rather than treating
it as a probability. To trade recall against precision at the source, adjust the sidecar's
GLINER_CONFIDENCE_THRESHOLD. To raise the floor for the whole deployment rather than one policy,
set GUARDAI_NER_MIN_CONFIDENCE; the effective floor is always the strictest of the three.
This is how a deployment supports its own domain with no code change. The shipped policies show it:
the opt-in healthcare-hipaa template (under policies/templates/, requires NER) defines mrn/npi/dea, financial defines bank_account/routing_number,
hr defines employee_id (full only for hr_manager), government defines
clearance_id/facility_code, and legal-privilege defines matter_id.
Every entity_type a policy references, in a rule, a channel override, a restore_strategies
key, or detection.required_for, must be a built-in type or a custom type the same policy
declares in custom_patterns. An unknown label aborts startup instead of silently becoming a rule
that never matches, so a mistyped type (e.g. sssn for ssn, or persn in required_for) cannot
quietly drop protection. To target a custom entity detected by a deployment-level detector rather
than this policy, write it as custom:<name> to confirm intent and opt out of the typo check.
Entity Rules
Each rule defines how one entity type is handled:
rules:
- entity_type: "person" # Which entity type this rule applies to
protection_level: 2 # 1 = hard mask/redact, 2 = reversible tokenization, 3 = semantic abstraction
action: "tokenize" # What action to take (redact | abstract | tokenize)
conditions: [] # Optional conditional overridesProtection Levels
| Level | Applies To | Description |
|---|---|---|
| 1 | passport, vat_id, iban, credit_card, health_id, ssn | Hard masking or redaction. Raw values never stored in reversible form. |
| 2 | person, email, phone, company, customer_id, order, address, location, date_of_birth, ip, url, custom | Reversible tokenization. Values can be fully restored during rehydration. |
| 3 | Metadata-only attributes | Semantic abstraction. No raw value stored. |
protection_level is a declarative tier annotation: it records intent and is surfaced in the
policy-evaluation trace and policy diffs, but it does not enforce on its own. The effective handling
of an entity is driven by action and restore_mode; keep protection_level consistent with them
(e.g. a level-1 entity should use redact or a non-restorable restore_mode, not tokenize with
restore_mode: full).
Policy Actions
The action field in each rule controls what happens to the entity during transformation. Valid values come from the PolicyAction enum:
| Action | Behavior |
|---|---|
redact | Value is replaced with the sentinel [REDACTED:type] (e.g. [REDACTED:ssn]), purged from the session, and non-restorable. By default the request still returns 200; set on_redact: reject to fail the request with 403 instead. |
tokenize | Replaced with a semantic token {{type:id:cap}} (reversible via rehydration). To mask on rehydration, set the rule's restore_mode: masked. |
abstract | Replaced with category label (e.g., [IBAN]) |
To send a specific value to the model unchanged, list it in the policy whitelist. The authorable actions are exactly redact, tokenize, and abstract: a policy that writes action: allow is rejected at load. allow exists only as an internal backstop, and the protection lattice clamps it to tokenize, so whitelist is the only pass-through path.
Redaction Disposition (on_redact)
redact removes the value but, by default, the request still succeeds with the
value redacted in place. The on_redact field controls whether a redaction also
fails the whole request:
| Value | Behavior |
|---|---|
continue (default) | Redact the value to [REDACTED:type], purge it, return 200. |
reject | Return 403 with error code GUARDAI_POLICY_DENIED. |
Set it as a policy-level default under defaults, or per rule. A per-rule value
overrides the policy default. It only affects redact actions.
rules:
- entity_type: "ssn"
protection_level: 1
action: "redact"
on_redact: "reject" # this entity hard-fails the request
conditions: []
defaults:
protection_level: 2
action: "tokenize"
restore_mode: "full"
on_redact: "continue" # other redactions redact-and-continueRestore Modes
The restore_mode field (set in defaults or in channel overrides) controls how tokenized entities are restored during rehydration:
| Restore Mode | Behavior |
|---|---|
full | Complete original value restored |
partial | Deterministic subset (e.g., E. Mustermann) |
masked | Character masking preserving length |
formatted | Original + contextual formatting (e.g., "Frau Erika Mustermann") |
abstract | Semantic description (e.g., "(female customer)") |
none | Value removed, shows [REDACTED] |
Protect without restoring
Rehydration is optional. If your deployment should mask or tokenize but never bring the original value back, pick the pattern that fits:
- Tokenize only. Call
POST /v1/transform, send the tokenizedsafe_textto the model, and never call/v1/rehydrate. The{{type:id:cap}}tokens stay in the output and nothing is restored. - Irreversible redaction. Set
action: redacton the rule. The value becomes[REDACTED:type], is purged from the session, and never reaches the model even as a token. Addon_redact: rejectto fail the request instead. This supports PCI-DSS 3.4 (render PAN unreadable); it does not satisfy all of Requirement 3 on its own. - Tokenize but never reveal. Keep
action: tokenizefor model context but setrestore_mode: none,masked, orabstract, so a rehydrate call can never return the raw value. - Detect only. Call
POST /v1/detectto find entities and spans without transforming anything.
action decides reversibility at transform time; restore_mode bounds what a rehydrate may reveal.
Language Coverage
formatted and abstract restoration are localized. The runtime ships built-in person titles and company/address/location/person abstract labels for 30 languages, and a deployment can override or add any language through restore_templates in oguardai.yaml (or GUARDAI_RESTORE_TEMPLATES for the proxy); the configured entries merge over the built-ins at startup. A language with no built-in or configured label falls back to a safe generic label; it is never the raw value and never an error. Coverage is a convenience floor, not a hard limit.
Custom Restore Strategies
When the six built-in modes are not enough (PCI card last-4, MRN last-3, an account number's last digits, a salary band instead of an exact figure), a restore_strategies block customizes the partial algorithm per entity type, with no code change. It is keyed by entity type, including a custom:<type> entity from custom_patterns. See the Extending Entities guide for how custom types are defined.
There are five strategy kinds:
| Kind | Parameters | What it reveals |
|---|---|---|
reveal_last_n | n (1 to 64), mask_char (optional, default "*") | The last n characters, masking the rest. Whitespace grouping is preserved. |
reveal_first_n | n (1 to 64), mask_char (optional) | The first n characters, masking the rest. For a fixed prefix such as an IBAN country code or a postal-code prefix. |
reveal_range | start (0 to 64), len (1 to 64), mask_char (optional) | A len-character window starting at 0-based character start, masking the rest. For an interior span such as a phone area code. |
mask | none | Nothing. Every character is masked. This is the fail-closed partial. |
bucket | ranges (a non-empty list of { max?, label }) | A coarse range label only (e.g. a salary band). The raw number is never shown. |
restore_strategies:
credit_card:
kind: reveal_last_n # show only the last n characters, mask the rest
n: 4
mask_char: "*" # optional, default "*"
mrn:
kind: reveal_last_n
n: 3
iban:
kind: reveal_first_n # show only the leading country code, mask the rest
n: 2
phone:
kind: reveal_range # reveal only characters start..start+len
start: 4
len: 3
account_token: # masks fully even at partial
kind: mask
salary:
kind: bucket # numeric range generalization, not the exact value
ranges:
- { max: 50000, label: "<50k" }
- { max: 100000, label: "50k-100k" }
- { label: "100k+" } # omit max on the last range to catch everything aboveA bucket maps a numeric value into the first range whose max it does not exceed; the final range may omit max to catch everything above. A value that is not a single number, or that matches no range, masks fully rather than revealing anything.
The mrn, account_token, and salary keys above are custom types: the same policy must declare them in custom_patterns (or write them as custom:<name> to target a deployment-level detector), otherwise the load aborts with an unknown entity-type error.
Channel-ceiling gating
A strategy applies only when the channel-resolved restore mode for that entity is exactly partial. The rehydrate engine clamps the restore mode to the channel ceiling first, so a channel whose resolved mode is masked, abstract, or none never reaches partial, and the strategy does not run there. A strategy can therefore add a bounded reveal within partial, never beyond what the channel already permits.
If the strategy cannot produce a bounded result it masks the whole value, never the raw value: reveal_last_n and reveal_first_n mask fully when the value has n or fewer characters (revealing n would disclose all of it), reveal_range masks fully when the window is anchored at the start and reaches the end (any start above 0 already masks the prefix, so it cannot disclose everything), and bucket masks an unparseable or unmatched value. Parameters are validated at policy load, so an invalid value (for example n or len outside 1 to 64, or a bucket with an empty ranges) aborts startup. The line is the same as everywhere in the policy: config may only add or tighten, and invalid config fails closed.
With restore_mode: partial, 4111 1111 1111 4821 restores as **** **** **** 4821 under the credit_card strategy above.
Fail-closed partial defaults
When an entity restores at partial and no restore_strategies entry covers it, the engine applies a built-in default:
- High-sensitivity identifiers (
iban,ssn,credit_card,passport,health_id) and everycustom:<type>entity reveal nothing inpartial: they mask fully unless arestore_strategiesentry opts that type into a bounded reveal. This is why an unconfigured custom identifier is safe atpartialout of the box. person,email,phone,company, andaddresshave built-in partial shapes (for exampleE. Mustermann). Person partials mask instead of revealingF. Surnamefor CJK-script and family-first names (zh, ja, ko, vi, hu), where that initial would be a misidentified name part.- Any other entity type with no built-in shape and no strategy masks fully. The default is always to reveal less, never more.
Strategies follow the same inheritance rule as entity and channel rules: a child policy overrides the parent's strategy for an entity type it lists, but omitting the type does not remove an inherited strategy (re-declare it to change it).
Conditional Rules
Rules support conditions for context-dependent behavior. Conditions are evaluated
against detection confidence and the context object in the API request.
Three condition types are fully enforced at runtime:
confidence: numeric threshold conditions on detection confidence scorescaller_role: role-based conditions evaluated against the caller's authenticated rolecaller_purpose: purpose-based conditions evaluated against the caller's authenticated purpose (e.g., "support", "analytics", "compliance")
Production: bind caller_role to API key config or JWT claims. In non-dev auth modes, request-supplied context.caller_role is ignored unless the auth credential has a bound role. This prevents callers from self-asserting privileged roles.
When a condition includes caller_role and no role is available (neither auth-bound nor dev-mode request), the condition does not match and the base rule action applies.
rules:
- entity_type: "person"
protection_level: 2
action: "tokenize"
conditions:
# Matching overrides are applied in list order and the LAST match wins, so
# list the broad rule first and the narrow one last.
- field: "caller_role"
operator: "in"
value: ["admin", "supervisor"]
override_restore_mode: "full"
- field: "caller_role"
operator: "eq"
value: "support_agent"
override_restore_mode: "partial"
- field: "caller_role"
operator: "eq"
value: "guest"
override_restore_mode: "none"override_restore_mode takes a restore mode (full, partial, masked, formatted,
abstract, none), never an action. An action name there is rejected at load with an
unknown-restore-mode error rather than being reinterpreted.
Production: bind caller_role to your API key in the server config:
auth:
api_keys:
- key: "${GUARDAI_API_KEY}"
identity: "admin-service"
caller_role: "admin"
scopes: ["transform", "rehydrate"]Dev/testing only: pass caller_role in the transform request context:
curl -X POST http://localhost:3000/v1/transform \
-H "Content-Type: application/json" \
-d '{
"input": "Contact Erika Mustermann at erika.mustermann@example.com",
"policy": "enterprise",
"context": {
"caller_role": "admin",
"caller_purpose": "customer escalation review"
}
}'Condition Operators
| Operator | Description | Example | Status |
|---|---|---|---|
eq | Equals | field: "caller_role", value: "admin" | Enforced |
neq | Not equals | field: "caller_role", value: "guest" | Enforced |
in | In list | field: "caller_role", value: ["admin", "support"] | Enforced |
gte | Greater than or equal | field: "confidence", value: 0.85 | Enforced |
lte | Less than or equal | field: "confidence", value: 0.5 | Enforced |
gt | Greater than | field: "confidence", value: 0.7 | Enforced |
lt | Less than | field: "confidence", value: 0.3 | Enforced |
not_in | Not in list | field: "caller_role", value: ["guest", "public"] | Planned |
exists | Field is present | field: "vip_flag" | Planned |
How Role-Based Conditions Work
- A policy rule defines conditions with
field: "caller_role". - The caller's role is determined from auth credentials (
caller_rolein API key config or JWT claims). In dev mode,context.caller_rolein the request is also accepted. - The policy engine evaluates each condition against the authenticated role.
- If a condition matches, its
override_restore_modeoverrides the restore mode (not the action, entities are still tokenized). - If no role is available, role-based conditions are skipped and the base action applies.
Re-authorized at rehydrate. A caller-gated override_restore_mode is sealed into the session as provenance, not as a fixed mode, and re-resolved at rehydrate time against the authenticated rehydrate caller (API key or JWT, never a request-supplied field). Holding a session_state produced under a privileged role therefore does not grant elevated restoration: a rehydrate caller who lacks the role degrades to the policy floor (the base restore mode without any caller elevation), and a restrictive override (for example caller_role neq admin mapping to none) still tightens for an unauthorized or roleless caller. The output channel ceiling is then applied on top, so a safe sink (log_safe, export) clamps the result regardless of role.
This enables enterprise policies where different roles see different levels of protection for the same entity types, enforced at the moment of restoration rather than only at transform time.
Channel Overrides
Output channels control how entities are restored for different audiences. The examples below
assume a base restore_mode: "full" on person, email, customer_id, and order, because a
channel rule can only tighten (see below).
channel_rules:
customer_email:
person: "formatted" # "Frau Erika Mustermann"
email: "none" # [REDACTED]
customer_id: "full" # 948221
order: "full" # ORD-2026-4892
internal_summary:
person: "full" # Erika Mustermann
email: "full" # erika.mustermann@example.com
customer_id: "full" # 948221
order: "full" # ORD-2026-4892
export:
person: "abstract" # (female customer)
email: "none" # [REDACTED]
customer_id: "abstract" # (customer ID)
order: "abstract" # (order reference)A channel rule can only tighten
At rehydrate the runtime resolves the entity's base restore mode (the rule's restore_mode, or the
policy defaults), then applies the channel rule and keeps whichever of the two exposes less.
A channel rule therefore narrows restoration and never widens it: listing iban: "full" under
internal_summary for an entity whose base is masked leaves it masked, and an entity whose base
is none stays [REDACTED] on every channel. To let one channel restore an entity in full, stamp
the base full on the rule and tighten it back down on the other channels.
The two safe sinks have a hard ceiling
log_safe and export are clamped before the channel rule is even consulted, so naming one of
them can never reveal raw values just because the policy default was permissive:
| Channel | Ceiling | Effect |
|---|---|---|
log_safe | none | Every entity restores to [REDACTED], whatever the base or the channel rule says |
export | masked | An entity restores masked at most; a channel rule may tighten further (abstract, none) |
A log_safe entry under channel_rules is therefore never observable in the output. Keep the
channel out of the policy, or leave the entries only as documentation of intent.
Six channel names are built in: user_output, customer_email, internal_summary,
tool_payload, export, log_safe. They carry no restore modes of their own. A built-in
channel with no entry under channel_rules applies the entity's base restore mode unchanged
(after the safe-sink ceiling above), so the modes come from what you write plus that base, not
from the channel name. You can also define your own channels for any workflow: any name
matching [a-z0-9_-]+ becomes a custom channel.
channel_rules:
regulator_export: # bank: deliver to a regulator
person: "full"
iban: "masked"
warehouse_label: # ecommerce: shipping label
person: "full"
address: "full"
email: "none"A custom channel is fail-closed: with no rule it restores nothing, so a typo in the request's
output_channel can never over-expose. Defining the rule is necessary but not sufficient: a custom
channel is a machine sink outside the trust boundary until the deployment also names it in
GUARDAI_TRUSTED_OUTPUT_CHANNELS, and until then the runtime clamps it to none whatever the rule
says (see Extensibility).
The rule still only tightens: person: "full" on regulator_export restores in full only if
the person rule already stamps a base of full.
Metadata Policy
Controls which metadata fields are included in the entity_context sent to the LLM:
metadata_policy:
expose_gender: true # Needed for gendered salutations (Herr/Frau)
expose_formality: true # Needed for formal/informal register
expose_language: true # Needed for language-correct output
expose_role: true # Emits the policy-defined entity role in entity_contextexpose_gender, expose_formality, expose_language, and expose_role each gate one metadata
field on the entity_context: when false, that field is withheld. expose_role surfaces the
policy-defined entity role produced by the roles: block (see the
Extensibility guide). With no roles: declared, no role is produced,
so the field is simply absent; declaring a role taxonomy and setting expose_role: true makes it
appear, gated exactly like the other three.
For the rest of the per-vertical dynamic surface not covered here, the roles: taxonomy, the RAG
classification lattice, egress destination_permissions, detector vocab overlays, and language
packs, see the Extensibility guide.
Example: Healthcare Policy
The shipped healthcare policy, abridged to its entity rules. The full file also redacts age and
declares HIPAA Safe Harbor custom types under custom_patterns (age for 90 and above, mrn,
account_number, certificate_number, service_date). Read policies/healthcare/policy.yaml for
the authoritative version.
name: "healthcare"
version: "1.0.0"
description: "HIPAA-aligned healthcare policy: redacts PHI identifiers, tokenizes names, abstracts addresses"
rules:
- entity_type: "health_id"
protection_level: 1
action: "redact"
conditions: []
- entity_type: "ssn"
protection_level: 1
action: "redact"
conditions: []
- entity_type: "date_of_birth"
protection_level: 1
action: "redact"
conditions: []
- entity_type: "iban"
protection_level: 1
action: "redact"
conditions: []
- entity_type: "credit_card"
protection_level: 1
action: "redact"
conditions: []
- entity_type: "passport"
protection_level: 1
action: "redact"
conditions: []
- entity_type: "person"
protection_level: 2
action: "tokenize"
conditions: []
- entity_type: "email"
protection_level: 2
action: "tokenize"
conditions: []
- entity_type: "phone"
protection_level: 2
action: "tokenize"
conditions: []
- entity_type: "address"
protection_level: 3
action: "abstract"
conditions: []
- entity_type: "location"
protection_level: 2
action: "abstract"
conditions: []
- entity_type: "company"
protection_level: 2
action: "tokenize"
conditions: []
- entity_type: "customer_id"
protection_level: 2
action: "tokenize"
conditions: []
defaults:
protection_level: 2
action: "tokenize"
restore_mode: "partial"
detection:
required_for:
- "person"
metadata_policy:
expose_gender: false
expose_formality: false
expose_language: true
expose_role: falseKey decisions:
- Health IDs, SSNs, dates of birth, IBANs, credit cards, and passports are redacted to
[REDACTED:type], purged from the session, and never restorable - Person names, emails, and phones are tokenized (reversible during rehydration)
- Addresses are abstracted at protection level 3 (no raw value stored)
- Default restore mode is
partial: only subsets of values are restored - Person detection requires NER (
detection.required_for: [person]), so the policy fails closed rather than falling back to regex-only if the NER detector is unavailable
Example: Financial Policy
The shipped financial policy, abridged to its entity rules. The full file also declares
bank_account and routing_number as label-anchored custom_patterns (tokenized, restored
masked), sets on_redact: continue under defaults, and lists person and company under
detection.required_for. Read policies/financial/policy.yaml for the authoritative version.
name: "financial"
version: "1.0.0"
description: "Banking and financial services policy: redacts all financial identifiers, tokenizes names, no raw values in logs"
rules:
- entity_type: "iban"
protection_level: 1
action: "redact"
conditions: []
- entity_type: "credit_card"
protection_level: 1
action: "redact"
conditions: []
- entity_type: "ssn"
protection_level: 1
action: "redact"
conditions: []
- entity_type: "passport"
protection_level: 1
action: "redact"
conditions: []
- entity_type: "health_id"
protection_level: 1
action: "redact"
conditions: []
- entity_type: "person"
protection_level: 2
action: "tokenize"
conditions: []
- entity_type: "company"
protection_level: 2
action: "tokenize"
conditions: []
- entity_type: "email"
protection_level: 2
action: "tokenize"
conditions: []
- entity_type: "phone"
protection_level: 2
action: "tokenize"
conditions: []
- entity_type: "address"
protection_level: 2
action: "tokenize"
conditions: []
- entity_type: "location"
protection_level: 2
action: "tokenize"
conditions: []
- entity_type: "customer_id"
protection_level: 2
action: "tokenize"
conditions: []
- entity_type: "order"
protection_level: 2
action: "tokenize"
conditions: []
- entity_type: "date_of_birth"
protection_level: 1
action: "tokenize"
restore_mode: "masked"
conditions: []
- entity_type: "ip"
protection_level: 2
action: "tokenize"
conditions: []
defaults:
protection_level: 2
action: "tokenize"
restore_mode: "masked"
metadata_policy:
expose_gender: false
expose_formality: false
expose_language: true
expose_role: trueKey decisions:
- IBAN, credit card, SSN, passport, and health ID are redacted, so they never reach the LLM, not even as tokens
- Person names, companies, emails, and other identifiers are tokenized
- Date of birth uses
tokenizewithrestore_mode: masked: reversible per policy, shown masked at rehydration, not destroyed - Default restore mode is
masked: character masking preserving length
Worked example: reject, redact, tokenize on one intake note
A law firm paralegal desk drafts matter summaries with an LLM. Some values may be tokenized and later
restored (party names, case number), some must never reach the model at all (bank IBAN, card number,
tax/SSN). The right behavior is not one blanket rule but a policy decision, and this example runs the
same fictional intake note through three shipped policies to show the three dispositions policy
authoring gives you: refuse the request, drop a value irreversibly, or protect it reversibly. It then
rehydrates the reversible paths to prove what can and cannot come back. The example is
examples/policy-block-python/. It uses only the Python standard library (urllib), so nothing needs
installing beyond the runtime, and the model step is a deterministic stub.
| Policy | on_redact | A level-1 identifier (IBAN, card, SSN) | Party names / case number |
|---|---|---|---|
strict-pii | reject | The whole request fails with 403 GUARDAI_POLICY_DENIED. Nothing is processed. | not reached |
legal | continue | Becomes [REDACTED:type]; the request succeeds. It can never be restored. | tokenized, restorable |
default | tokenize | IBAN/card tokenized but pinned to a masked restore; SSN redacted. | tokenized, full restore |
On every succeeding path a raw financial value never reaches the model. The only difference is whether the request is refused, the value is dropped irreversibly, or the value is protected reversibly.
Prerequisites and run command
Start the runtime, then run the client. Person detection needs the NER detector sidecar
(apps/detector-py): the {{person:...}} token for the party names only appears when NER is active.
The structured identifiers (IBAN, card, SSN, email, phone) and the case_ref custom pattern are
caught by the built-in regex detectors even without NER. The bundled Docker stack runs the detector in
both mode. Two of the three steps use policies that declare person detection as required
(strict-pii and legal), so on a detector outage those steps fail closed (HTTP 503) rather than
continue with missing names; only the default step, which does not require NER, falls back to the
built-in detector. Run the full stack with the detector active.
# Start the OGuardAI runtime. Person detection needs the NER sidecar (see the note below). The bundled
# stack requires these four secrets, and its client key ships least-privilege, so widen it to the
# policies and channels this example uses.
export GUARDAI_SESSION_SECRET=$(openssl rand -base64 32)
export GUARDAI_API_KEY_1=$(openssl rand -hex 32)
export GUARDAI_DETECTOR_API_KEY=$(openssl rand -hex 32)
export GUARDAI_REDIS_PASSWORD=$(openssl rand -hex 32)
export GUARDAI_API_KEY_1_ALLOWED_POLICIES=default,default-ner-required,german-support,strict-pii,legal
export GUARDAI_API_KEY_1_ALLOWED_OUTPUT_CHANNELS=user_output,internal_summary
docker compose -f deploy/docker/docker-compose.yml up -d
export GUARDAI_URL=http://localhost:3000
export GUARDAI_API_KEY="$GUARDAI_API_KEY_1" # the bundled stack runs api_key auth
python main.pyCheck detection is wired with curl localhost:3000/v1/health: the detector component should report
builtin_and_ner.
The intake fixture
examples/policy-block-python/legal_intake.txt. All values are fictional.
Matter intake, case 3 O 271/25, contract dispute.
Client: Ms. Erika Mustermann (email erika.mustermann@example.com, phone +49 89 55598877).
Opposing party: Max Mustermann. A retainer was received on the client account
IBAN DE97 9900 0000 0123 4567 89. The client also sent a card number
4111 1111 1111 1111 and, for the engagement letter, her tax/SSN 123-45-6789.
Please summarize the matter for the supervising attorney.Configuration and transport
The client reads the runtime URL and optional API key from the environment and lists the seven raw
values that must never appear in safe_text. From examples/policy-block-python/main.py:
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
from pathlib import Path
BASE = (os.environ.get("GUARDAI_URL") or os.environ.get("GUARDAI_BASE_URL") or "http://localhost:3000").rstrip("/")
API_KEY = os.environ.get("GUARDAI_API_KEY", "")
HERE = Path(__file__).resolve().parent
INTAKE_FILE = HERE / "legal_intake.txt"
# Raw values that must never survive into safe_text on any succeeding path. The structured
# identifiers are caught by the built-in regex detectors, so a structured leak is a hard failure in
# every mode. The names need the NER detector, so a name leak is a hard failure when the runtime
# reports NER active, and a printed note otherwise, since running without NER is a valid deployment.
NER_SENSITIVE = ["Erika Mustermann", "Max Mustermann"]
STRUCTURED_SENSITIVE = [
"erika.mustermann@example.com",
"+49 89 55598877",
"DE97 9900 0000 0123 4567 89",
"4111 1111 1111 1111",
"123-45-6789",
]
SENSITIVE = NER_SENSITIVE + STRUCTURED_SENSITIVE
RULE = "=" * 78The HTTP helpers return 4xx/5xx bodies instead of raising, so the reject path (403) is observable, and
the boundary guard raises SystemExit if a raw value ever crosses into safe_text. From
examples/policy-block-python/main.py:
def _headers() -> dict[str, str]:
headers = {"Content-Type": "application/json"}
if API_KEY:
headers["X-API-Key"] = API_KEY
return headers
def _post(path: str, payload: dict) -> tuple[int, dict]:
"""POST JSON and return (status, body). 4xx/5xx are returned, not raised, so the reject path
(403) is observable; a genuine connection failure raises urllib.error.URLError to main."""
request = urllib.request.Request(
BASE + path, data=json.dumps(payload).encode(), headers=_headers(), method="POST"
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
return response.status, json.loads(response.read().decode())
except urllib.error.HTTPError as exc:
raw = exc.read().decode()
try:
return exc.code, json.loads(raw)
except json.JSONDecodeError:
return exc.code, {"error": raw}
def _get(path: str) -> tuple[int, dict]:
"""GET JSON and return (status, body). 4xx/5xx are returned, not raised; a genuine connection
failure raises urllib.error.URLError to main."""
request = urllib.request.Request(BASE + path, headers=_headers(), method="GET")
try:
with urllib.request.urlopen(request, timeout=30) as response:
return response.status, json.loads(response.read().decode())
except urllib.error.HTTPError as exc:
raw = exc.read().decode()
try:
return exc.code, json.loads(raw)
except json.JSONDecodeError:
return exc.code, {"error": raw}
def fetch_ner_active() -> bool:
"""The one leak-exemption signal: /v1/capabilities.ner_active reports whether this runtime is
configured to detect person names. If the field is absent or the endpoint errors, treat NER as
available so a name leak still fails closed: a protection example must never print an assurance
it cannot justify."""
code, body = _get("/v1/capabilities")
if code != 200 or not isinstance(body, dict):
return True
value = body.get("ner_active")
return value if isinstance(value, bool) else True
def transform(text: str, policy: str) -> tuple[int, dict]:
return _post("/v1/transform", {"input": text, "policy": policy})
def rehydrate(output: str, session_state: str, *, channel: str) -> tuple[int, dict]:
return _post(
"/v1/rehydrate",
{"output": output, "session_state": session_state, "output_channel": channel},
)
def check_boundary(safe_text: str, ner_active: bool) -> list[str]:
"""Guard the text the model is about to see. A structured identifier crossing is a hard failure
on every lane. A person name crossing is a hard failure when NER is active, and a printed note
when the runtime runs without NER, a valid deployment. Returns the name notes so the closing
summary stays honest."""
structured_leak = [value for value in STRUCTURED_SENSITIVE if value in safe_text]
if structured_leak:
raise SystemExit(f"FAIL: structured PII reached the model: {structured_leak}")
name_leak = [value for value in NER_SENSITIVE if value in safe_text]
if not name_leak:
print(f" leak check: PASS, none of the {len(SENSITIVE)} raw values present")
return []
if ner_active:
raise SystemExit(f"FAIL: a person name reached the model while NER is active: {name_leak}")
for value in name_leak:
print(f" NOTE: the name {value} reached the model because this runtime runs without NER")
return name_leak
def token_of(safe_text: str, type_prefix: str) -> str | None:
marker = "{{" + type_prefix
start = safe_text.find(marker)
if start == -1:
return None
end = safe_text.find("}}", start)
return safe_text[start : end + 2] if end != -1 else NoneThe three dispositions
Each disposition is one POST /v1/transform with a different policy, followed on the reversible
paths by a POST /v1/rehydrate that names an output_channel. internal_summary restores the
attorney view; user_output restores the client view where IBAN stays masked by policy. The final
check corrupts the session blob and confirms the rehydrate fails closed rather than half-restoring.
From examples/policy-block-python/main.py:
def section(title: str) -> None:
print("\n" + RULE)
print(title)
print(RULE)
def run_strict(intake: str) -> None:
section("1) strict-pii (on_redact: reject) -> the whole request fails closed")
code, body = transform(intake, "strict-pii")
if code == 403:
print(f" HTTP {code} code={body.get('code')} error={body.get('error')}")
print(" A level-1 identifier (IBAN/card/SSN) was present, so nothing was processed and the")
print(" model was never called. This is the fail-closed disposition for regulated inputs.")
else:
raise SystemExit(f"strict-pii did not reject a level-1 identifier: HTTP {code} {json.dumps(body)[:200]}")
def run_legal(intake: str, ner_active: bool) -> list[str]:
section("2) legal (on_redact: continue) -> financials redacted, names tokenized + restorable")
code, body = transform(intake, "legal")
if code != 200:
raise SystemExit(f"legal transform failed unexpectedly: HTTP {code} {json.dumps(body)[:200]}")
safe_text = body.get("safe_text", "")
print(" safe_text (what the model sees):\n")
print(" " + safe_text.replace("\n", "\n "))
print(f"\n entities_blocked (redacted, never a token): {body.get('stats', {}).get('entities_blocked')}")
notes = check_boundary(safe_text, ner_active)
session = body.get("session_state") or ""
if not session:
print(" no session_state; skipping restore")
return notes
# Draft a summary reusing only the tokens the model saw, then restore it. Party names come back;
# the redaction markers cannot be un-redacted, because a redacted value never entered the session.
case = token_of(safe_text, "case_ref:") or "the matter"
client_tok = token_of(safe_text, "person:") or "the client"
summary = (
f"Summary for {case}: the client {client_tok} has retained the firm. Financial identifiers "
f"(IBAN, card, tax id) were redacted at intake and are not reproduced here."
)
rcode, restored = rehydrate(summary, session, channel="internal_summary")
print("\n restored on internal_summary (attorney view):")
print(" " + restored.get("restored_text", "").replace("\n", "\n "))
print(
f" tokens_resolved={restored.get('tokens_resolved')} unresolved={restored.get('tokens_unresolved')}"
if rcode == 200
else f" restore failed HTTP {rcode}: {restored.get('code')}"
)
return notes
def run_default(intake: str, ner_active: bool) -> tuple[str, list[str]]:
section("3) default (tokenize) -> reversible protection, IBAN/card restore masked")
code, body = transform(intake, "default")
if code != 200:
raise SystemExit(f"default transform failed unexpectedly: HTTP {code} {json.dumps(body)[:200]}")
safe_text = body.get("safe_text", "")
print(" safe_text (what the model sees):\n")
print(" " + safe_text.replace("\n", "\n "))
print()
notes = check_boundary(safe_text, ner_active)
session = body.get("session_state") or ""
if not session:
print(" no session_state; skipping restore")
return "", notes
person = token_of(safe_text, "person:") or "the client"
iban = token_of(safe_text, "iban:")
reply = (
f"Confirmation for {person}: the retainer on account {iban} is recorded."
if iban
else f"Confirmation for {person}: the retainer is recorded."
)
rcode, restored = rehydrate(reply, session, channel="user_output")
print("\n restored on user_output (name full, IBAN masked by policy):")
print(" " + restored.get("restored_text", "").replace("\n", "\n "))
return session, notes
def run_fail_closed(session: str) -> None:
section("4) fail-closed: a tampered session is rejected, not half-restored")
if not session:
print(" (no session available to tamper with; skipped)")
return
tampered = session[:-8] + "deadbeef"
code, body = rehydrate("Confirmation for {{person:p_001:deadbeef0000}}.", tampered, channel="user_output")
if code >= 400:
print(f" HTTP {code} code={body.get('code')} (rejected; no raw value returned)")
else:
raise SystemExit(f"fail-closed check failed: a tampered session was accepted (HTTP {code})")
def main() -> int:
intake = INTAKE_FILE.read_text(encoding="utf-8").strip()
print(f"OGuardAI policy dispositions runtime: {BASE}")
notes: list[str] = []
try:
ner_active = fetch_ner_active()
run_strict(intake)
notes += run_legal(intake, ner_active)
session, default_notes = run_default(intake, ner_active)
notes += default_notes
run_fail_closed(session)
except urllib.error.URLError as exc:
print(f"\ncannot reach the OGuardAI server at {BASE}: {exc.reason}")
print("start it first with docker compose (see the README).")
return 1
print("\n" + RULE)
print("Same intake, three dispositions: rejected outright, redacted-and-continued, or reversibly")
print("tokenized. Redacted values never returned; tokens restored only where policy allows.")
if notes:
leaked_names = sorted(set(notes))
print("Structured PII stayed inside the runtime; the name(s) "
f"{leaked_names} reached the model because this runtime runs without NER.")
print(RULE)
return 0
if __name__ == "__main__":
raise SystemExit(main())Expected output
Token ids and fingerprints vary per run.
1) strict-pii (on_redact: reject) -> the whole request fails closed
HTTP 403 code=GUARDAI_POLICY_DENIED error=policy denied: blocked entities detected
2) legal (on_redact: continue) -> financials redacted, names tokenized + restorable
safe_text (what the model sees):
Matter intake, case {{case_ref:cr_001:...}}, contract dispute.
Client: {{person:p_001:...}} (email {{email:e_001:...}}, phone {{phone:ph_001:...}}).
Opposing party: {{person:p_002:...}}. A retainer was received on the client account
IBAN [REDACTED:iban]. The client also sent a card number [REDACTED:credit_card] and,
for the engagement letter, her tax/SSN [REDACTED:ssn].
entities_blocked (redacted, never a token): 3
leak check: PASS, none of the 7 raw values present
restored on internal_summary (attorney view):
Summary for 3 O 271/25: the client Erika Mustermann has retained the firm. ...
tokens_resolved=2 unresolved=[]
3) default (tokenize) -> reversible protection, IBAN/card restore masked
...
restored on user_output (name full, IBAN masked by policy):
Confirmation for Erika Mustermann: the retainer on account IBAN **** (masked) is recorded.
4) fail-closed: a tampered session is rejected, not half-restored
HTTP 400 code=GUARDAI_INVALID_INPUT (rejected; no raw value returned)strict-pii never calls the model. legal redacts the three financial identifiers to
[REDACTED:...] (absent from the session, so a later rehydrate restores the names but can never bring
those values back) and tokenizes the names for restore. default tokenizes names for a full restore
and keeps IBAN and card as tokens pinned to masked, so they show as a mask even when the reply is
rehydrated. The tampered session is rejected with 400 GUARDAI_INVALID_INPUT, never half-restored.
Policy File Location
Policies are stored as YAML files in the configured policy directory. The shipped set below is representative, not exhaustive; read your deployment's policy directory for the authoritative list:
policies/
customer-support-de/policy.yaml
customer-support-en/policy.yaml
default/policy.yaml
enterprise/policy.yaml
examples/
financial/policy.yaml
gdpr-strict/policy.yaml
german-support/policy.yaml
government/policy.yaml
healthcare/policy.yaml
hr/policy.yaml
legal/policy.yaml
legal-privilege/policy.yaml
minimal/policy.yaml
strict-pii/policy.yaml
templates/
healthcare-hipaa/policy.yaml # opt-in, NER-required templatePolicy changes are picked up without a server restart: on demand via POST /v1/admin/policy/reload (requires the admin scope; a reload that would load zero policies or fails validation is rejected and the running policy is kept), or automatically by enabling the policy-directory watcher with policy.watch: true. Use POST /v1/admin/policy/validate for a dry-run check before reloading.