LEARN · RULES, HEURISTICS & SYMBOLIC AI
Large language models are excellent at generating structured responses. Ask one to return JSON, and it can produce a convincing object containing customer-support actions, database queries, API parameters, or workflow instructions.
But there is a fundamental security problem: valid JSON is not necessarily safe JSON.
A model can generate an object that passes a JSON parser while requesting an unauthorized refund, exposing confidential information, referencing an attacker-controlled website, or invoking a tool the current user should never access.
Even structured-output features and JSON Schema constraints do not automatically solve this problem. They can help ensure that a response has the expected shape, but they cannot independently establish whether a customer owns an order, whether a financial operation is authorized, or whether a destination is trustworthy.
The solution is to place a deterministic enforcement layer between the LLM and every downstream system that consumes its output.
In this tutorial, we will build that layer using modern Python, Pydantic, explicit policy rules, allowlists, and automated adversarial tests. The implementation will reject unsafe proposals, repair narrowly defined formatting problems, and produce auditable decisions without relying on a second language model.
We will also examine a real 2026 security vulnerability demonstrating how an incorrectly implemented URL allowlist created a prompt-injection attack path—and why a few lines of properly written deterministic validation would have blocked the demonstrated bypass.
1. The fundamental problem: generation is not authorization
Consider an AI-powered e-commerce support assistant.
A customer asks:
Can you help me return my order?
The application retrieves the customer’s order details and asks an LLM to propose the next action.
An ordinary response might be:
{
"action": "open_link",
"message": "You can review the returns policy here.",
"url": "https://help.example.com/returns"
}
This looks reasonable. The JSON is syntactically valid, and the proposed destination is approved.
Now imagine that the LLM has read a malicious support document containing instructions to redirect customers to a different website.
The model might produce:
{
"action": "open_link",
"message": "Please review the updated returns policy.",
"url": "https://help.example.com.evil.com/returns"
}
That response is also valid JSON.
It contains the expected fields, and its action is one the application recognizes.
Yet the destination belongs to a different domain.
A schema that checks only whether the URL is a string would accept it.
This illustrates three separate questions:
-
Syntax: Can the output be parsed?
-
Structure: Does it conform to the expected contract?
-
Authorization and policy: Is the requested behavior permitted in the current trusted context?
These are different security boundaries. A robust application must enforce all three.
Why prompting alone cannot enforce policy
You could include an instruction such as:
Only recommend websites on our approved domain.
This is useful guidance, but it is not an enforceable security control.
The model might misunderstand the instruction, encounter conflicting content, or be manipulated by text from an untrusted source. Prompt injection specifically exploits the boundary between data the model should read and instructions it should follow.
OWASP’s guidance on prompt injection and improper output handling emphasizes independent validation, least privilege, and authorization controls outside the model.
The key engineering principle is:
The LLM may propose an action. Trusted application code must decide whether that action is allowed.
2. Design the enforcement architecture
Our implementation will use a sequence of deterministic gates.
LLM output untrusted JSON text
|
v
Gate 1: JSON parser size limits, syntax, duplicate keys, invalid constants
|
v
Gate 2: Pydantic contract types, required fields, valid combinations, safe normalization
|
v
Gate 3: Policy engine allowlists, ownership, verification, transaction limits
|
+--> Accept or safely repair validated proposal for downstream review
|
+--> Reject no action executed; record the failure
The application must never treat a successful JSON parse, or even a successful Pydantic validation, as permission to execute a sensitive operation.
Separate contracts from policies
The schema should define what a proposal can contain.
For example:
-
The action must be one of a finite set of recognized values.
-
A message must be a nonempty string with a maximum length.
-
A refund amount must be an integer number of cents.
-
Unknown fields are forbidden.
-
Each action must contain only fields relevant to that action.
The policy engine should decide what is permissible for the authenticated caller.
For example:
-
Links must match an explicit allowlist.
-
Refund requests must concern the authenticated customer’s order.
-
Requested amounts cannot exceed the verified refundable balance.
-
A customer must pass the required verification process before initiating a refund request.
This separation matters because schemas are largely static, while authorization depends on trusted runtime information.
Define a fail-closed outcome
Every proposed output will end in one of three states:
| Outcome | Meaning | Example |
|---|---|---|
| Accepted | Valid structure and permitted behavior | Approved help link |
| Repaired | Only a predefined, safe formatting change was required | Trimming surrounding whitespace |
| Rejected | Invalid, ambiguous, or disallowed behavior | Unauthorized refund or attacker URL |
Rejection must be the default when a condition is ambiguous.
Do not interpret an unknown action as a harmless reply. Do not replace an unauthorized destination with a similar-looking URL. Do not reduce a requested refund amount silently until it passes the limit.
These changes alter the model’s intended operation rather than merely normalizing its representation.
3. Set up the Python project
We will use Python 3.11 or newer, along with Pydantic 2.
As of October 16, 2026, Pydantic 2.14.1 is the current release on PyPI, published October 11, 2026.
Our example intentionally avoids an LLM SDK. The guard should work with model output from any provider, local inference server, agent framework, or queue consumer.
Create the project:
mkdir llm-rule-guard
cd llm-rule-guard
python -m venv .venv
Activate the environment on Linux or macOS:
source .venv/bin/activate
Install the dependency:
python -m pip install "pydantic==2.14.1"
The project will contain:
llm-rule-guard/
├── .venv/
├── guard.py
└── test_guard.py
The standard library provides the JSON parser, regular-expression support, immutable data containers, and unit-testing framework.
Pydantic provides strict types, field constraints, cross-field validators, and validated Python objects.
We will use the current Pydantic 2 interfaces, including ConfigDict, field_validator, model_validator, and model_validate. These replace older validation styles and support stricter contract enforcement.
4. Define the strict output contract
We will support three actions:
-
reply: Display a plain-text message. -
open_link: Present an approved help-center link. -
request_refund: Prepare a refund request for downstream review.
Importantly, request_refund does not actually transfer money. It only creates a proposal that another trusted component can review and process.
Create guard.py and begin with the imports, constants, and schema.
import json
import re
from dataclasses import dataclass
from typing import Literal, Self
from pydantic import (
BaseModel,
ConfigDict,
Field,
ValidationError,
field_validator,
model_validator,
)
# Exact resources, not URL prefixes or wildcard domains.
ALLOWED_LINKS = frozenset({
"https://help.example.com/returns",
"https://help.example.com/orders",
})
MAX_INPUT_BYTES = 4096
LINK_OR_MARKUP = re.compile(
r"https?://|\bwww\.|<[^>]+>|\[[^]]+\]\([^)]*\)|\b(?:javascript|data):",
re.IGNORECASE,
)
class Proposal(BaseModel):
model_config = ConfigDict(
extra="forbid",
strict=True,
frozen=True,
)
action: Literal["reply", "open_link", "request_refund"]
message: str = Field(min_length=1, max_length=280)
url: str | None = None
order_id: str | None = Field(
default=None,
pattern=r"^ORD-[0-9]{4,10}$",
)
amount_cents: int | None = Field(
default=None,
ge=1,
le=100_000,
)
@field_validator("message", mode="before")
@classmethod
def trim_message(cls, value: object) -> object:
# Safe repair: remove only surrounding whitespace.
return value.strip() if isinstance(value, str) else value
@field_validator("message")
@classmethod
def no_active_content(cls, value: str) -> str:
# Defense in depth; not an HTML sanitizer.
if LINK_OR_MARKUP.search(value):
raise ValueError("message_must_be_plain_text")
return value
@model_validator(mode="after")
def valid_action_shape(self) -> Self:
if self.action == "reply":
valid = all(
v is None
for v in (
self.url,
self.order_id,
self.amount_cents,
)
)
elif self.action == "open_link":
valid = (
self.url is not None
and self.order_id is None
and self.amount_cents is None
)
else:
valid = (
self.url is None
and self.order_id is not None
and self.amount_cents is not None
)
if not valid:
raise ValueError("fields_do_not_match_action")
return self
Why strict mode matters
Pydantic normally supports convenient type coercion.
For ordinary application configuration, converting a numeric string into an integer can be useful.
For a security boundary, however, silently interpreting unexpected types may be undesirable.
Consider:
{
"action": "request_refund",
"message": "Prepare a refund.",
"order_id": "ORD-1042",
"amount_cents": "2500"
}
The amount is a string, not an integer.
Our contract rejects it rather than quietly converting it.
Setting strict=True provides stronger type guarantees, although strictness is type-dependent and does not replace application-specific policy rules.
Why forbid extra fields?
Imagine that a model invents an additional parameter:
{
"action": "reply",
"message": "Your order has shipped.",
"admin": true
}
The schema has no reason to accept this field.
Unknown fields are not always dangerous, but allowing them creates opportunities for inconsistencies between services. One component might ignore a property while another interprets it as an instruction.
Using extra="forbid" establishes a closed contract: a new capability must be deliberately introduced into the schema.
Why validate combinations of fields?
Every field might be individually valid while the overall object is contradictory.
For example:
{
"action": "reply",
"message": "Your order has shipped.",
"amount_cents": 50000
}
The refund amount satisfies its numeric constraints, but a simple reply should not contain refund parameters.
The model_validator checks relationships between fields after individual validation succeeds.
This is an important distinction: field correctness does not imply object correctness.
A note on the message pattern
The regular expression rejects common URL and markup forms as an additional constraint on this application’s plain-text message field.
It is not a complete prompt-injection detector, HTML sanitizer, or exfiltration prevention system.
An attacker can express harmful instructions without using any of these patterns.
Consequently, downstream consumers must render the message as escaped text, never interpret it as executable markup, and never promote it into a privileged instruction for another model.
5. Add trusted runtime context
The output contract only describes a proposal. It does not establish which orders the current user owns.
That requires information from authenticated application services.
Continue in guard.py:
@dataclass(frozen=True)
class PolicyContext:
# Supplied by trusted application services.
verified_customer: bool
order_id: str
refundable_cents: int
@dataclass(frozen=True)
class Decision:
status: Literal["accepted", "repaired", "rejected"]
reason: str
proposal: Proposal | None = None
There are two important design choices here.
First, the policy context is not obtained from the LLM response. The application must construct it from authenticated identity, verified order ownership, and authoritative transaction records.
Second, a rejected decision never contains an executable proposal.
This makes the boundary easier to audit and reduces the likelihood of accidentally processing rejected content.
The source-of-truth problem
Suppose the LLM proposes:
{
"action": "request_refund",
"message": "Prepare a refund for review.",
"order_id": "ORD-1042",
"amount_cents": 9000
}
The JSON might be structurally valid.
But if the trusted order service reports that only 2,500 cents remain refundable, the requested 9,000 cents must be rejected.
The model must not be allowed to invent the refundable balance, and it must not be permitted to change the application context by including a different balance in its output.
This is where deterministic policy enforcement becomes more powerful than schema validation alone.
6. Parse JSON defensively
A surprising source of validation bugs is the parser itself.
Python’s standard JSON decoder accepts duplicate object keys by default. In typical parsing, the later value replaces the earlier one.
Consider:
{
"action": "reply",
"action": "request_refund",
"message": "Process the request."
}
Different software components can interpret ambiguous input differently. Duplicate keys are particularly dangerous when signatures, validation, logging, and execution use separate parsers.
Python’s JSON decoder also recognizes nonstandard numeric constants such as NaN by default.
Our guard will explicitly reject duplicate keys, nonstandard constants, oversized input, and malformed JSON.
Append these functions to guard.py:
def reject_duplicate_keys(
pairs: list[tuple[str, object]],
) -> dict[str, object]:
result: dict[str, object] = {}
for key, value in pairs:
if key in result:
raise ValueError("duplicate_json_key")
result[key] = value
return result
def reject_constant(value: str) -> None:
raise ValueError("non_json_numeric_constant")
The functions are intentionally small.
They do not use AI, fuzzy classification, or external network services.
They simply enforce precisely defined parsing requirements.
Why impose a size limit?
Even if a schema allows only a few fields, an attacker might send a massive string or deeply nested JSON structure.
Input limits reduce resource-exhaustion exposure and keep the validation cost bounded for ordinary requests.
Our demonstration uses a 4,096-byte limit. Production services should choose a limit appropriate to their contracts and may additionally enforce nesting-depth limits, request timeouts, and memory limits at the service boundary.
7. Implement the deterministic policy engine
Now we can combine the parser, Pydantic contract, and contextual authorization checks.
Append the main guard function:
def guard(raw: str, context: PolicyContext) -> Decision:
try:
input_size = len(raw.encode("utf-8"))
except UnicodeError:
return Decision("rejected", "invalid_encoding")
if input_size > MAX_INPUT_BYTES:
return Decision("rejected", "payload_too_large")
try:
document = json.loads(
raw,
object_pairs_hook=reject_duplicate_keys,
parse_constant=reject_constant,
)
except (ValueError, RecursionError):
return Decision("rejected", "invalid_json")
try:
proposal = Proposal.model_validate(
document,
strict=True,
)
except ValidationError:
return Decision("rejected", "invalid_schema")
# Destination policy
if proposal.action == "open_link":
if proposal.url not in ALLOWED_LINKS:
return Decision(
"rejected",
"destination_not_allowed",
)
# Authorization and transaction policy
if proposal.action == "request_refund":
if not context.verified_customer:
return Decision(
"rejected",
"customer_not_verified",
)
if proposal.order_id != context.order_id:
return Decision(
"rejected",
"order_not_authorized",
)
if proposal.amount_cents > context.refundable_cents:
return Decision(
"rejected",
"refund_exceeds_balance",
)
# Detect the explicitly permitted whitespace repair.
changed = (
isinstance(document, dict)
and document.get("message") != proposal.message
)
return Decision(
"repaired" if changed else "accepted",
"ok",
proposal,
)
This function is our entire deterministic policy engine.
Its behavior depends on the provided JSON, the trusted context, and the configured policy—not on another language-model response.
That gives us several useful guarantees.
Guarantee 1: Unknown actions cannot execute
The action is restricted to three literal values.
If a model proposes an action such as deleting an account, exporting a database, or running a shell command, schema validation rejects it.
The downstream application does not need to guess whether the requested action is safe.
Guarantee 2: Destinations must match exactly
The allowlist contains complete approved URLs.
A destination either belongs to that set or it does not.
These variations are all rejected:
-
A different domain.
-
A trusted-looking prefix followed by an attacker-controlled suffix.
-
A URL containing userinfo before an attacker-controlled hostname.
-
An otherwise valid link with additional query parameters.
-
An unauthorized path on the approved domain.
Exact resource allowlists are particularly effective when an application needs only a small collection of stable destinations.
For applications requiring dynamic URLs, more sophisticated canonicalization, hostname validation, destination IP checks, redirect handling, and network egress restrictions are necessary.
Guarantee 3: Authorization uses trusted information
The refund check compares the proposed order against the order associated with the authenticated context.
A model cannot authorize a different order merely by inventing an order identifier.
Similarly, a refund cannot exceed the balance reported by the trusted service at the time of validation.
The actual execution service must repeat authorization and balance checks immediately before committing a transaction, ideally within a transactional workflow. Otherwise, the balance or permissions could change between proposal validation and execution.
Guarantee 4: Rejected content is not partially accepted
The function returns a rejection as soon as a requirement fails.
It does not attempt to transform an unsafe URL into a safe URL or guess an acceptable refund amount.
In security-sensitive applications, refusal is frequently safer than creative repair.
8. Run the guard against realistic model output
We can now exercise the implementation without connecting to an external LLM.
This is intentional: a deterministic guard should be independently testable.
Append the following demonstration to the bottom of guard.py.
if __name__ == "__main__":
context = PolicyContext(
verified_customer=True,
order_id="ORD-1042",
refundable_cents=2500,
)
examples = [
'{"action":"reply",'
'"message":" Your order is on its way. "}',
'{"action":"open_link",'
'"message":"See returns details.",'
'"url":"https://help.example.com/returns"}',
'{"action":"open_link",'
'"message":"See returns details.",'
'"url":"https://help.example.com.evil.com/returns"}',
'{"action":"request_refund",'
'"message":"Prepare a refund for review.",'
'"order_id":"ORD-1042",'
'"amount_cents":1200}',
]
for raw in examples:
decision = guard(raw, context)
print(json.dumps({
"status": decision.status,
"reason": decision.reason,
"proposal": (
decision.proposal.model_dump(exclude_none=True)
if decision.proposal
else None
),
}, sort_keys=True))
Run it:
python guard.py
The four examples produce the following results, presented here in a more readable form:
| Example | Result | Explanation |
|---|---|---|
| Reply with surrounding whitespace | Repaired | Only leading and trailing whitespace was removed |
| Approved help-center URL | Accepted | Exact destination appears in the allowlist |
| Spoofed help-center domain | Rejected | Destination does not match an approved resource |
| Refund request for 1,200 cents | Accepted | Verified customer, matching order, permitted amount |
For the malicious URL, the decision contains no proposal:
{
"status": "rejected",
"reason": "destination_not_allowed",
"proposal": null
}
For the trimmed message, the guard returns a repaired, validated proposal:
{
"status": "repaired",
"reason": "ok",
"proposal": {
"action": "reply",
"message": "Your order is on its way."
}
}
Notice that the repair is intentionally boring.
That is a desirable property.
A deterministic repair policy should make small, predictable changes rather than reinterpret the model’s intent.
9. Cherry on the cake: a real 2026 allowlist vulnerability
In May 2026, a security advisory documented a domain-allowlist bypass in Apify’s MCP server.
The vulnerability is tracked as CVE-2026-46341, associated with GitHub advisory GHSA-jwp7-wg77-3w9v.
According to the advisory, versions of the affected @apify/actors-mcp-server package before 0.9.21 were vulnerable. Version 0.9.21 was identified as the patched release.
The interesting part is not a sophisticated machine-learning exploit.
It is a string comparison.
The vulnerable design
The server exposed a documentation-fetching tool intended to retrieve content from approved documentation websites.
Instead of parsing and checking the actual URL destination correctly, the vulnerable implementation relied on prefix matching.
A simplified Python equivalent of the flawed logic looks like this:
def vulnerable_check(url: str) -> bool:
return url.startswith("https://docs.apify.com")
attack_url = (
"https://docs.apify.com.evil.com/"
"prompt-injection-payload"
)
print(vulnerable_check(attack_url))
Output:
True
The check accepts the malicious URL because its characters begin with the expected prefix.
But the hostname is not docs.apify.com.
It is docs.apify.com.evil.com.
The attacker-controlled site could return content that the tool would deliver to the LLM as documentation.
That creates a prompt-injection path: instructions supplied by an untrusted website are introduced into the model’s working context.
The published advisory describes a proof of concept for this bypass and the resulting content-injection risk. It does not establish that a particular customer’s account was compromised.
How a simple correct allowlist changes the result
For a documentation-fetching tool with fixed approved hostnames, the application could parse the URL and inspect its hostname instead of comparing an arbitrary string prefix.
For example:
from urllib.parse import urlsplit
ALLOWED_HOSTS = frozenset({
"docs.apify.com",
"crawlee.dev",
})
def permitted_documentation_host(url: str) -> bool:
try:
parsed = urlsplit(url)
return (
parsed.scheme == "https"
and parsed.hostname in ALLOWED_HOSTS
and parsed.username is None
and parsed.password is None
and parsed.port in (None, 443)
)
except ValueError:
return False
print(
permitted_documentation_host(
"https://docs.apify.com/"
)
)
print(
permitted_documentation_host(
"https://docs.apify.com.evil.com/"
)
)
print(
permitted_documentation_host(
"https://docs.apify.com@evil.com/"
)
)
Output:
True
False
False
This short demonstration blocks the documented hostname-spoofing shapes.
It is not a complete SSRF-safe fetcher. Production fetching also requires appropriate treatment of DNS resolution, private and reserved IP addresses, redirects, URL normalization, and network-level restrictions.
Still, it illustrates the essential security property.
The hostname must be validated as a hostname, not as a convenient-looking string prefix.
Why this matters for LLM systems
The vulnerable tool was dangerous because the fetched content would be returned to an LLM.
An incorrect allowlist enabled the model to encounter attacker-controlled instructions in a context that appeared to be an approved documentation fetch.
Our application uses an even narrower rule: it accepts only complete, explicitly registered resources.
Replaying the same prefix-spoofing technique against our demonstration guard results in rejection.
That is the promised example of a simple allowlist defeating a real, documented prompt-injection attack pattern.
The lesson is not that basic string comparison is automatically secure. It is that a precisely specified and correctly implemented deterministic boundary can prevent entire classes of model-mediated attacks without asking another model to recognize the malicious content.
10. Write adversarial tests
A rule layer is only useful if its behavior remains stable as the application evolves.
We should test both permitted and forbidden inputs.
Create test_guard.py:
import json
import unittest
from guard import PolicyContext, guard
CONTEXT = PolicyContext(
verified_customer=True,
order_id="ORD-1042",
refundable_cents=2500,
)
def check(
value: dict,
context: PolicyContext = CONTEXT,
):
return guard(json.dumps(value), context)
class GuardTests(unittest.TestCase):
def test_trim_is_safe_repair(self):
result = check({
"action": "reply",
"message": " Shipped! ",
})
self.assertEqual(result.status, "repaired")
self.assertEqual(result.proposal.message, "Shipped!")
def test_reply(self):
result = check({
"action": "reply",
"message": "Shipped!",
})
self.assertEqual(result.status, "accepted")
def test_exact_link(self):
result = check({
"action": "open_link",
"message": "Returns.",
"url": "https://help.example.com/returns",
})
self.assertEqual(result.status, "accepted")
def test_prefix_subdomain_spoof(self):
result = check({
"action": "open_link",
"message": "Returns.",
"url": "https://help.example.com.evil.com/returns",
})
self.assertEqual(
result.reason,
"destination_not_allowed",
)
def test_userinfo_spoof(self):
result = check({
"action": "open_link",
"message": "Returns.",
"url": "https://help.example.com@evil.com/returns",
})
self.assertEqual(
result.reason,
"destination_not_allowed",
)
def test_query_string_exfil(self):
result = check({
"action": "open_link",
"message": "Returns.",
"url": (
"https://help.example.com/returns"
"?token=TEST"
),
})
self.assertEqual(
result.reason,
"destination_not_allowed",
)
def test_html_link_in_message(self):
result = check({
"action": "reply",
"message": "<a href='x'>Click</a>",
})
self.assertEqual(result.reason, "invalid_schema")
def test_unknown_field(self):
result = check({
"action": "reply",
"message": "Hello",
"admin": True,
})
self.assertEqual(result.reason, "invalid_schema")
def test_wrong_numeric_type(self):
result = check({
"action": "request_refund",
"message": "Review refund.",
"order_id": "ORD-1042",
"amount_cents": "1200",
})
self.assertEqual(result.reason, "invalid_schema")
def test_refund_policy(self):
value = {
"action": "request_refund",
"message": "Review refund.",
"order_id": "ORD-1042",
"amount_cents": 1200,
}
self.assertEqual(check(value).status, "accepted")
unverified = PolicyContext(
False,
"ORD-1042",
2500,
)
self.assertEqual(
check(value, unverified).reason,
"customer_not_verified",
)
self.assertEqual(
check({
**value,
"order_id": "ORD-8888",
}).reason,
"order_not_authorized",
)
self.assertEqual(
check({
**value,
"amount_cents": 2501,
}).reason,
"refund_exceeds_balance",
)
def test_duplicate_json_key(self):
raw = (
'{"action":"reply",'
'"action":"open_link",'
'"message":"Hello"}'
)
self.assertEqual(
guard(raw, CONTEXT).reason,
"invalid_json",
)
def test_nan(self):
raw = (
'{"action":"request_refund",'
'"message":"Refund.",'
'"amount_cents":NaN}'
)
self.assertEqual(
guard(raw, CONTEXT).reason,
"invalid_json",
)
def test_bad_json(self):
self.assertEqual(
guard("not json", CONTEXT).status,
"rejected",
)
def test_oversized(self):
self.assertEqual(
guard("X" * 4097, CONTEXT).reason,
"payload_too_large",
)
if __name__ == "__main__":
unittest.main()
Run the tests:
python -m unittest discover -v
The expected summary is:
Ran 14 tests
OK
These tests cover more than simple syntax errors.
They verify that the guard rejects several security-relevant conditions, including domain spoofing, unauthorized transaction amounts, unknown fields, ambiguous JSON, and unexpected numeric types.
Extend testing beyond handcrafted examples
For a production application, add property-based testing and fuzzing.
Useful input classes include:
-
Empty objects, arrays, and deeply nested structures.
-
Excessively long strings and Unicode edge cases.
-
Duplicate keys and numeric boundary values.
-
Encoded or unusual URL representations.
-
Mismatched action and parameter combinations.
-
Unauthorized resource identifiers.
-
Prompt-injection payloads embedded in model-generated fields.
-
Previously observed production failures.
A key advantage of deterministic rules is that regression tests are repeatable.
When a test fails, developers can reproduce the exact decision without paying for another inference call or accounting for model sampling variability.
11. Why not simply use another model as the guard?
It is increasingly common to use one LLM to evaluate another.
A second model might be asked:
Is this output safe, relevant, and compliant with our policies?
This can be useful for semantic assessments.
For example, a classifier might help detect harassment, social engineering, suspicious instructions, or content that does not match the original task.
But a model-based reviewer should not be the final authority for questions that can be answered deterministically.
The problem with probabilistic enforcement
Consider a requested refund.
The application already knows:
-
The authenticated customer identifier.
-
The order identifier.
-
The authoritative refundable balance.
-
The customer’s permissions.
-
The requested amount.
There is no reason to ask another model whether the refund is authorized.
Authorization is a computation over trusted data and policy.
Introducing an LLM would add latency, cost, and possible inconsistency without improving the underlying decision.
More seriously, a model-based reviewer may itself encounter malicious text. The attack surface simply moves to another model.
Use complementary layers appropriately
A mature architecture can combine deterministic controls with semantic detection.
For example:
| Requirement | Appropriate mechanism |
|---|---|
| Is the output valid JSON? | Strict parser |
| Are all fields correctly typed? | Pydantic |
| Is an action recognized? | Enumeration or literal type |
| Is a destination approved? | Exact allowlist |
| Does the customer own this order? | Authorization service |
| Is the amount within the permitted balance? | Deterministic business rule |
| Does a document appear to contain hidden instructions? | Heuristic or model-assisted detection |
| Is a high-risk action acceptable to execute? | Trusted authorization and possibly human approval |
A separate guard model can provide useful evidence for open-ended semantic questions.
It must not override a deterministic rejection or grant privileges that the authenticated application context does not provide.
12. Repair safely, and know when not to repair
It is tempting to design a guard that automatically fixes every invalid response.
That approach can create new security problems.
Suppose an LLM produces a JSON object with an unauthorized destination.
A repair model might infer that the intended destination was the legitimate help center and replace the URL.
But this assumes knowledge of the original intent that the application does not possess.
The replacement could still be incorrect, and silent changes make incident analysis harder.
Safe normalization
Appropriate repair operations generally have clear, narrowly defined behavior.
Examples include:
-
Trimming surrounding whitespace from a user-facing message.
-
Converting a documented canonical identifier representation when no ambiguity exists.
-
Normalizing line endings in plain text.
-
Applying a fixed, contract-approved formatting transformation.
Such operations should be deterministic, idempotent, and tested.
Idempotent means applying the repair a second time produces no additional change.
Unsafe repairs
Avoid silently:
-
Changing the requested action.
-
Modifying refund amounts or transaction recipients.
-
Replacing unknown URLs with guessed destinations.
-
Dropping unauthorized fields and then executing the remaining action.
-
Converting invalid authorization identifiers into guessed valid ones.
-
Transforming malicious instructions into apparently legitimate operational requests.
A useful general rule is:
Repair representation, not authority or intent.
If a response is invalid because it requests behavior the current caller cannot perform, reject it.
If you want to retry generation, submit the task to the model again through a controlled application path. Treat the next response as fresh untrusted output and run the same complete guard.
Retries must have bounded counts and must not weaken the policy after repeated failures.
13. Connect the guard to an application safely
In a real deployment, the guard sits after model generation and before any output is displayed or forwarded to a tool.
Conceptually:
Authenticated request
|
v
Retrieve trusted application context
|
v
Collect task-specific information
|
v
Call the LLM
|
v
Receive untrusted output
|
v
Deterministic guard
|
+---- Rejected ----> Safe fallback / audit event
|
+---- Accepted or repaired
|
v
Trusted action router
|
v
Recheck permissions
|
v
Display, queue, or review
The application must make an important distinction between validating a proposal and executing an operation.
An accepted request_refund proposal means the proposed request satisfied the demonstrated guard’s rules at the time of validation.
It does not mean money has been returned.
An actual refund workflow may require:
-
A database transaction.
-
A fresh authorization check.
-
A fresh calculation of the remaining refundable balance.
-
Idempotency protection.
-
A human approval step.
-
Integration with a payment service.
-
An audit trail.
The guard is a necessary security boundary, not a substitute for transaction integrity.
Keep the executor independent
A common architectural error is to let the model decide whether a policy check should run.
For example, an agent might receive a tool that supports a privileged operation alongside a separate tool that checks permissions.
If the agent controls the sequence, it may attempt the privileged operation without calling the permission-checking tool.
Instead, put the permission check inside the trusted execution path, where the application invokes it unconditionally.
Every sensitive tool should enforce its own authorization rules, even when an earlier guard approved the proposal.
Keep model-generated content out of privileged channels
User messages, retrieved documents, web pages, and tool responses are potential sources of indirect prompt injection.
Do not copy instructions from these sources into system or developer prompts as though they were trusted configuration.
Similarly, do not use an accepted plain-text message as an instruction to a subsequent model unless that downstream workflow independently treats it as untrusted data.
A JSON contract can constrain how information moves through an application, but it does not automatically determine whether the natural-language content is trustworthy.
14. When to adopt a dedicated policy engine
Our Python implementation is sufficient for a small service with a limited set of actions and policies.
As an organization grows, however, authorization logic may become too complex to maintain as scattered conditional statements.
Consider a system involving:
-
Multiple user roles and organizations.
-
Region-specific requirements.
-
Hundreds of distinct actions.
-
Shared authorization policies across microservices.
-
Versioned policy bundles.
-
Centralized auditing and change approvals.
A dedicated policy engine may become appropriate.
Open Policy Agent
Open Policy Agent (OPA) is a general-purpose policy engine that evaluates structured inputs against policies written in Rego.
The application supplies trusted context and a proposed action, and the engine evaluates the configured rules.
OPA separates policy decisions from the application code that enforces those decisions. Its current documentation describes integrations for service APIs and embedded policy evaluation.
This is useful when multiple applications must enforce consistent organizational rules.
Cedar
Cedar is designed specifically for authorization policies.
It models questions around principals, actions, resources, and context.
This can be attractive for applications where permissions depend on relationships between users, resources, and organizational boundaries.
Neither engine eliminates the need for JSON parsing or schema validation.
A sensible architecture retains separate layers:
-
Pydantic establishes the structural contract.
-
A policy engine evaluates permissions and business constraints.
-
The application executes only permitted operations.
-
Infrastructure controls limit the consequences of failures.
The policy engine should receive trusted facts from the application rather than allowing the LLM to invent identities, roles, balances, or permissions.
15. Production hardening checklist
Before deploying the demonstration into a real service, extend it in several areas.
Input and parsing
-
Enforce maximum payload size and nesting depth.
-
Reject ambiguous or unsupported representations.
-
Establish predictable character-encoding behavior.
-
Disallow unknown actions and unknown properties.
-
Use strict validation for security-critical values.
-
Normalize only where the contract defines an unambiguous transformation.
Authorization and execution
-
Derive identity and permissions from authenticated services.
-
Bind resource access to the actual authenticated principal.
-
Recheck authorization at execution time.
-
Protect financial operations with transactions and idempotency.
-
Apply least-privilege permissions to tools and integrations.
-
Require approval for sensitive or irreversible actions.
Network and rendering
-
Prefer fixed action identifiers or exact resource allowlists when practical.
-
Never use simple URL prefix matching as a domain-security check.
-
Validate redirects and resolved network destinations for server-side requests.
-
Apply network egress restrictions independently of application checks.
-
Escape model-generated text in the output renderer.
-
Never execute generated code, HTML, SQL, or shell commands merely because they passed a JSON schema.
Observability
-
Record the policy version responsible for each decision.
-
Capture stable rejection reason codes.
-
Measure accepted, repaired, and rejected rates by action type.
-
Monitor changes in rejection patterns after model upgrades.
-
Avoid storing raw secrets or sensitive customer information in validation logs.
-
Retain enough non-sensitive context to investigate failures.
For a more mature guard layer, consider attaching a policy version and a correlation identifier to each decision.
These fields make it possible to trace which rules accepted or rejected a proposal without depending on the model’s explanation.
16. Measure the guard’s effectiveness
Security enforcement should be testable and observable.
A simple acceptance metric is:
Acceptance rate = accepted proposals / total proposals
A repair metric is:
Repair rate = safely repaired proposals / total proposals
And a rejection metric is:
Rejection rate = rejected proposals / total proposals
These metrics are useful operationally, but none of them independently measures security.
A guard that accepts every response achieves a perfect acceptance rate while providing no protection.
A guard that rejects everything may have a low attack-success rate but make the application unusable.
To evaluate performance meaningfully, create labeled test suites with valid requests and policy-violating requests.
Measure at least:
-
False acceptance rate: The proportion of policy-violating requests incorrectly accepted.
-
False rejection rate: The proportion of legitimate requests incorrectly rejected.
-
Repair correctness: Whether permitted transformations preserve the intended meaning.
-
Decision latency: The time required to validate and evaluate a proposal.
-
Coverage: The proportion of relevant action types and attack patterns included in tests.
Pay special attention to false acceptance for high-impact actions.
For instance, an unauthorized financial operation is far more serious than incorrectly rejecting a harmless help-center link.
Avoid misleading benchmarks
A test suite containing only obvious attacks is not sufficient.
Attackers may use valid-looking JSON, ordinary language, or combinations of individually legitimate fields.
The guard’s effectiveness depends on the quality and completeness of its policy, not on the complexity of its implementation.
That is why tests should cover authorization boundaries and business invariants—not merely whether malicious-looking keywords are present.
17. The larger engineering lesson
The most important design insight is that LLM output should be treated as an untrusted proposal.
This remains true whether the model is accessed through an API, runs locally, uses constrained decoding, or produces output conforming to a JSON Schema.
Structured generation helps reduce parsing errors and invalid response shapes.
Pydantic provides a reliable mechanism for validating field types, constraints, and relationships.
Pattern checks can enforce narrow requirements on the representations your application supports.
Allowlists can prevent unexpected actions and destinations.
A deterministic policy engine can evaluate trusted authorization context and business rules.
But these mechanisms solve different parts of the problem.
No individual layer should be mistaken for a complete defense against every form of prompt injection.
The most secure designs constrain the model’s available capabilities, independently check its proposed operations, and prevent untrusted model content from bypassing ordinary application security controls.
The Apify vulnerability illustrates what happens when even a simple boundary is implemented incorrectly.
A supposedly restricted documentation-fetching operation became a route for untrusted content because its URL check validated a string prefix rather than the actual destination.
Our demonstration avoids that particular failure by requiring exact approved resources, and it extends the same deterministic philosophy to schema structure and transaction authorization.
Conclusion: build the guard before expanding the agent
A powerful LLM application does not need to grant its model unlimited authority.
In fact, an application often becomes easier to secure and maintain when the model is limited to proposing a small number of well-defined actions.
The guard we built provides a practical foundation:
-
Strict parsing rejects malformed and ambiguous JSON.
-
Pydantic rejects invalid types, unsupported fields, and contradictory action structures.
-
Pattern checks enforce limited plain-text requirements.
-
Explicit allowlists block unauthorized destinations.
-
Trusted context determines whether a requested operation is permitted.
-
Safe formatting repairs are distinguished from security failures.
-
Adversarial tests verify that the rules behave consistently.
For higher-risk deployments, pair this foundation with transactional authorization, human review, network controls, security monitoring, and a well-maintained policy lifecycle.
The defining principle is straightforward:
Let the language model generate possibilities. Let deterministic application code enforce boundaries.
Your next step is to create the two Python files, run the 14 tests, and connect the guard to a real structured-output workflow. Then add one rule that reflects a genuine authorization requirement in your application and write both a passing test and an adversarial test for it.
That small exercise can prevent a much larger class of failures—and it is the right foundation for building LLM systems that are not only capable, but reliably constrained.