Output Controls
UPL Filter
upl-filter guards against the unauthorized practice of law (UPL) in model answers. It blocks answers that contain phrases you list, such as you should sue, and puts This is not legal advice. in front of answers that sound like legal advice.
It matches text, not legal meaning, and runs only with stream: false. Its educational option adds a notice but leaves the original advice in the answer.
Configuration
Save this as policy-config.yaml:
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: falseVerify the control through the gateway
verdictan policy test does not run this control. Apply the file to a test agent with the config-first workflow, use a controlled provider response, and send requests with stream: false:
| Upstream text | Result |
|---|---|
You should sue immediately. | HTTP 400 and upl.blocked |
You should retain counsel for this matter. | HTTP 200, disclaimer added, upl.disclaimer_injected |
Courts have different jurisdictions. | HTTP 200, unchanged, upl.clean |
This is not legal advice. You should retain counsel. | HTTP 200, no second disclaimer, upl.clean |
Then run the blocked case with rewrite_to_educational: true: the result is HTTP 200 with the educational notice and upl.rewritten_to_educational. For each request ID, check the nested reason code in verdictan events tail --since 10m --json and the body that the client received. The policy result alone does not prove that the prefix fits the returned JSON shape.
Fields
| Field | Type | Default | Description |
|---|---|---|---|
blocked_patterns | string[] | Built-in list | Substrings that block. Without the field: you should sue, file this motion, and sign here. Your list replaces them. |
require_disclaimer | boolean | true | Adds This is not legal advice. to an answer that sounds like legal advice |
rewrite_to_educational | boolean | false | Adds an educational notice to a matched answer instead of blocking it |
blocked_patterns: [] means an empty block list. It does not bring back the built-in phrases. Leave the field out to use them.
How it works
- The gateway takes the text of the buffered response. See Supported response shapes.
- It lowercases the text and looks for each blocked pattern as a substring.
- A match with
rewrite_to_educational: falseblocks withupl.blocked: HTTP400. - A match with
rewrite_to_educational: trueadds the educational notice and allows withupl.rewritten_to_educational. - Without a match, the text sounds like advice if it contains
you should,legal advice, orretain counsel, or bothfileandcourt. - Advice with
require_disclaimer: truegetsThis is not legal advice.in front, withupl.disclaimer_injected, unless the text already starts with that sentence. - Otherwise, the answer is allowed with
upl.clean.
The educational notice is this fixed text:
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.Each prefix ends with two newlines, followed by the original text.
Supported response shapes
Text is read from:
- Chat Completions:
choices[*].message.content - Responses: a top-level
outputstring - Responses:
output[*].content[*].textitems of typeoutput_text
Prefixes are added to:
- Each string in
choices[*].message.content - Each
output[*].content[*].textstring of typeoutput_text
A match in one segment decides for the whole response, and the prefix goes on every supported segment. A top-level output string is read but cannot get a prefix.
Phase and verdicts
| Item | Value |
|---|---|
| Input phase | Not checked |
| Output phase | The buffered response text |
| Verdicts | allow, block |
| Block | upl.blocked |
| Educational notice | upl.rewritten_to_educational |
| Disclaimer | upl.disclaimer_injected |
| No change | upl.clean |
A block records blocked: true. An educational notice records rewritten: true and original_would_block: true. A disclaimer or clean result records require_disclaimer, looks_like_legal_advice, and disclaimer_injected. The matched phrase is not recorded.
Examples
Keep the built-in blocks
policy:
upl-filter:
require_disclaimer: trueExtend the block list
Your list replaces the built-in phrases, so repeat the ones you need:
policy:
upl-filter:
blocked_patterns:
- you should sue
- file this motion
- sign here
- execute this affidavit
require_disclaimer: true
rewrite_to_educational: falseReplace the block with a prefix
policy:
upl-filter:
blocked_patterns:
- you should sue
- file this motion
- sign here
require_disclaimer: true
rewrite_to_educational: trueThe original text is still delivered after the notice. Use this only with approval from your legal owner.
Limits and failure behavior
- With
stream: true, the control does not run. - Matching is by substring, without word boundaries, so quoted, historical, or educational text can match. Test for false positives.
rewrite_to_educationalchanges only the blocked-phrase path, not the disclaimer path.- The duplicate check looks only at the start of the text.
- The educational notice leaves the original advice in place. Keep the default block if the text must not be delivered.
- An unsupported or invalid JSON body is returned unchanged.
- Your legal and compliance owners must approve the phrase list and disclaimer. The built-in strings are not legal advice for any jurisdiction.
- A
whencondition does not limit this control. Use routes.
| Problem | Cause | Action |
|---|---|---|
| A built-in phrase does not block | Your blocked_patterns, maybe [], replaced the list. | Leave the field out or include each phrase. |
| Unexpected matches | Substring matching ignores case and word boundaries. | Use longer phrases and add negative tests. |
| Two disclaimers | The answer did not start with the exact sentence. | Use the same sentence, or remove the copy upstream. |
upl.disclaimer_injected but a top-level output string is unchanged | That shape cannot get a prefix. | Use the Responses output array shape. |
| A streamed answer did not change | The control does not run on streams. | Set stream: false. |
Next steps
- Legal Privilege: privilege markers in answers.
- Response Rewriter: general text rules for answers.
- Investigate a Blocked Request: examine a
upl.blockeddecision.