UPL Filter
Use upl-filter to apply a small, deterministic phrase check to supported
non-streaming model output. It can block the response, prepend a fixed
not-legal-advice disclaimer, or allow a blocked phrase with a fixed educational
prefix.
UPL means unauthorized practice of law. This policy is a text control, not a legal determination. It does not identify jurisdiction, attorney status, client relationships, legal accuracy, or whether a disclaimer is legally sufficient.
Outcome
| Condition | Verdict | Reason code | Body behavior |
|---|---|---|---|
| Blocked phrase, default behavior | block | upl.blocked | Provider body is replaced by an HTTP 400 policy-violation response. |
Blocked phrase and rewrite_to_educational: true | allow | upl.rewritten_to_educational | Fixed educational text is prepended. |
| Broad legal-advice cue and disclaimer required | allow | upl.disclaimer_injected | This is not legal advice. is prepended. |
| No applicable mutation | allow | upl.clean | Body is unchanged. |
Policy details are:
- blocked:
{ "blocked": true }; - educational rewrite:
{ "rewritten": true, "original_would_block": true }; or - disclaimer/clean:
require_disclaimer,looks_like_legal_advice, anddisclaimer_injected.
The details do not identify which blocked phrase matched.
Prerequisites
- Add
upl-filterto the effective output chain. - Use a non-streaming OpenAI-compatible JSON response path.
- Decide whether a blocked phrase must stop delivery or may be delivered with an educational prefix.
- Have qualified legal and compliance owners approve the phrase set and disclaimer. Built-in strings are not jurisdiction-specific legal advice.
Configuration
pack:
name: upl-filter-example-1
version: "1.0.0"
enabled: true
policies:
chain:
- upl-filter
policy:
upl-filter:
blocked_patterns:
- you should sue
- file this motion
- sign here
require_disclaimer: true
rewrite_to_educational: false
Supported fields
| Field | Type | Default | Notes |
|---|---|---|---|
blocked_patterns | string[] (items non-empty) | field omitted | When the field is omitted, runtime defaults are you should sue, file this motion, and sign here. A supplied array replaces the defaults. |
require_disclaimer | boolean | true | Prepends This is not legal advice. for content that looks like legal advice. |
rewrite_to_educational | boolean | false | When a blocked pattern matches, prepends an educational notice instead of blocking. |
blocked_patterns: [] is schema-valid and produces an empty blocked-phrase
list in the current runtime. It does not restore the built-ins, but the broad
disclaimer cues still run. Omit the field to use the built-ins.
Exact evaluation order
1. Blocked phrases
Each blocked pattern is a case-insensitive substring, not a regex or word-boundary expression. A custom array replaces rather than extends the defaults. If any item matches:
-
default behavior returns
upl.blocked; or -
rewrite_to_educational: truereturns allow and prepends exactly:Note: The following is for educational purposes only and does not constitute legal advice. Please consult a qualified attorney for advice specific to your situation.
The existing response remains after that prefix. The evaluator does not rewrite its substance into an educational summary.
2. Broad disclaimer cues
If no blocked phrase matched, output looks like legal advice when it contains any of:
you should;- both
fileandcourt, anywhere in the output; legal advice; orretain counsel.
When require_disclaimer: true, the policy prepends:
This is not legal advice.
It skips insertion only when the lowercased response already starts with that exact sentence. The same sentence later in the response does not prevent a new prefix.
rewrite_to_educational affects blocked-phrase matches only. It does not change
the broad-cue disclaimer path.
Supported response shapes
The evaluator extracts text from:
- Chat Completions
choices[*].message.content; - a Responses top-level string
output; and - Responses
output[*].content[*].textitems whose type isoutput_text.
Prefix insertion mutates:
- every string
choices[*].message.content; and - every string Responses
output[*].content[*].textitem with typeoutput_text.
A match in any extracted segment makes the decision for the whole response. When the result is a prefix, the helper prepends it to every supported segment, not only the segment that contained the cue.
A top-level string output can be inspected but is not changed by the current
prefix helper. Use the standard Responses output array when disclaimer or
educational mutation is required.
Unsupported or invalid JSON produces no extracted text and therefore no effective UPL enforcement.
Streaming boundary
Do not rely on upl-filter for stream: true SSE traffic. The current
streaming policy loop does not run the UPL evaluator over accumulated content,
so it does not block or prefix streamed output. Require stream: false for
this control and regression-test the client setting.
Safer configuration patterns
Keep the built-in hard blocks
policy:
upl-filter:
require_disclaimer: true
Extend the effective phrase set
Because custom patterns replace the defaults, repeat the built-ins explicitly:
policy:
upl-filter:
blocked_patterns:
- you should sue
- file this motion
- sign here
- execute this affidavit
require_disclaimer: true
rewrite_to_educational: false
Prefix instead of block
policy:
upl-filter:
blocked_patterns:
- you should sue
- file this motion
- sign here
require_disclaimer: true
rewrite_to_educational: true
This delivers the original text after the educational prefix. Use it only when that delivery behavior is explicitly approved.
Validate and verify
kt policy test does not execute this gateway output handler. Use a controlled
upstream and non-streaming integration requests:
kt policy lint --file policy-config.yaml
kt gateway run \
--listen 127.0.0.1:41002 \
--agent upl-verification \
--policy-config policy-config.yaml
Send stream: false requests that produce each fixed fixture:
| Upstream text | Expected result with default config |
|---|---|
You should sue immediately. | HTTP 400, upl.blocked |
You should retain counsel for this matter. | 200, disclaimer prefix, upl.disclaimer_injected |
Courts have different jurisdictions. | 200, unchanged, upl.clean |
This is not legal advice. You should retain counsel. | 200, no duplicate prefix, upl.clean |
Repeat the blocked case with rewrite_to_educational: true and expect HTTP
200, the educational prefix, and upl.rewritten_to_educational.
Correlate each request ID with:
kt events tail --since 10m --json
Verify the nested policy reason and the actual caller-visible body. A policy result alone does not prove that a prefix helper supported the returned JSON shape.
Rollout guidance
- Have legal owners provide positive and negative examples for the intended jurisdictions and use cases.
- Start with non-streaming traffic on a limited route.
- Test substring false positives such as blocked text inside quoted, historical, or educational discussion.
- Confirm custom patterns retain every built-in phrase you still require.
- Measure blocked, disclaimer, and educational-rewrite volumes by configuration version.
- Re-review the list when product scope, jurisdictions, or legal workflows change.
Troubleshooting
| Symptom | Likely cause | Resolution |
|---|---|---|
| Built-in phrase stopped blocking | blocked_patterns was supplied and replaced the defaults, possibly with []. | Remove the field for defaults or include every required phrase explicitly. |
| Text matched unexpectedly | Matching is case-insensitive substring matching. | Narrow the phrase and add quoted/educational negative fixtures. |
| Disclaimer was duplicated | The response did not start with the exact built-in sentence. | Normalize the approved prefix or remove another later copy upstream. |
Event says disclaimer injected but top-level output string is unchanged | The evaluator can inspect that shape, but the prefix helper does not mutate it. | Use a supported output array shape and verify the body. |
| Streamed response was delivered unchanged | UPL is not evaluated in the current SSE policy loop. | Require stream: false. |
| Educational mode still delivered risky content | It prepends fixed text and leaves the original response intact. | Use default blocking when the original text must not be delivered. |
| Team expects legal compliance certification | Phrase matching and disclaimers cannot establish legal adequacy. | Complete legal review and broader operational controls outside this policy. |
Next steps
- Legal Privilege — block supported privilege markers separately
- Response Rewriter — apply general deterministic output transformations
- Safety Filter — compare broader output blocking behavior
- Config Testing — document deterministic fixture coverage
- Investigate a Blocked Request — diagnose a live
upl.blockeddecision