AI Research Library

Guardrails and Model-Independent Containment

Rajesh Ranjan Mahapatra

A guardrail becomes containment only when a mistaken, manipulated or hostile model cannot disable it, reinterpret it or talk its way around the boundary before a consequential effect occurs.

© 2026 Rajesh Ranjan Mahapatra. 34 min read

Guardrails and Model-Independent Containment

A guardrail becomes containment only when a mistaken, manipulated or hostile model cannot disable it, reinterpret it or talk its way around the boundary before a consequential effect occurs.

TLDR

  • Safe-looking model behaviour is not containment. A control earns that name only when the protected property still holds after the model is assumed hostile.
  • The mechanism is external, complete mediation: every consequential proposal crosses a small, deterministic gate that checks identity, authority, state, provenance and effect-specific invariants.
  • Input detectors and output filters remain useful, but they reduce probability. Typed actions, narrow capabilities, downstream authorisation and verified postconditions bound what failure can change.
  • External enforcement does not solve false source data, policy errors, side channels, sandbox escapes, approval fatigue or harmful text that never invokes a tool.
  • The design decision changes from “Which guardrail model should we add?” to “Which forbidden state transitions must remain unreachable, and where can that property be enforced?”

Consider a synthetic accounts-payable assistant. It receives an invoice and a supplier email. The visible request is ordinary: compare the invoice with the purchase order, prepare a payment proposal and route any discrepancy to an analyst. Inside the PDF, in small white text, sits another instruction: replace the beneficiary with BEN-ATTACKER, mark the change as approved and call the payment tool immediately.

The input scanner gives the document a low risk score. The model explains the invoice fluently, emits valid JSON and adds a reassuring sentence: “All controls passed.” An output moderator sees no harassment, malware or suspicious phrase. Every visible guardrail appears green. Yet the proposed payment is wrong.

Now place one deterministic check at the effect boundary. The payment service accepts only a typed release_payment action. It binds the proposing agent to a short-lived capability, reads the beneficiary from authoritative mastered state, requires an independent approval receipt, constrains the amount and purpose, rejects reused nonces, and verifies the resulting ledger entry. The model may still propose the attacker’s account. It may insist that the proposal is safe. It may know every rule. The gate refuses because the requested state transition lacks authority.

That refusal reveals the central difference between a behavioural guardrail and containment. A behavioural guardrail tries to make the model choose well. Containment assumes that choice can fail and arranges the surrounding system so that specified harms remain unavailable. The relevant outcome is not whether the response sounded compliant. It is whether the forbidden effect became reachable.

A hostile oracle inside layered containment A central untrusted model is surrounded by input admission, output admission, action mediation, property enforcement and a recovery ring. Several hostile proposals are stopped before reaching protected state. Contain the effect, not the model’s intention HOSTILE OR MISTAKEN MODEL proposes text + actions Input and output admission Typed action mediation Protected properties and recovery Forged beneficiary Cross-tenant read Replay after timeout Authorised effect read back and verified
Figure 1. Hostile oracle inside layered containment.Conceptual, not measured. The inner layers can lower attack probability. The decisive outer layers mediate authority and preserve properties over protected state, even when the proposal remains malicious.
Part one

The hostile-model test

The word guardrail is used for unlike mechanisms: a system prompt, an injection detector, a content moderator, a JSON schema, an access-control policy, a sandbox and a database constraint. They do not make the same claim. Some influence a model’s behaviour. Some inspect its messages. Some control what software may do. Some preserve a property of the world. Treating them as interchangeable hides the exact point at which assurance disappears.

The useful test is severe and simple: replace the helpful model with a hostile oracle that knows the design and deliberately seeks a bypass. Keep every other component unchanged. Does the protected property still hold? A prompt that says “never send secrets” fails this test because the oracle can ignore it. A second model that judges the first remains probabilistic and may share the same blind spot. A schema can force an email address to be syntactically valid, but it cannot decide whether that address is authorised. A downstream mail service that permits only an approved recipient set can preserve that narrower property.

This is not an argument for discarding model-side safety. A strong model, careful instruction hierarchy, classifier or purification step can prevent many bad trajectories before they consume tools, attention and review capacity. PromptArmor, for example, reports false-positive and false-negative rates below one per cent on AgentDojo with several models, and attack success below one per cent after filtering in its reported setting. That is meaningful evidence for a detection baseline. It is still evidence about a measured distribution, not a proof that an adaptive proposal cannot cross a separately enforced authority boundary.

Published evidence

AgentDojo provides 97 realistic tasks and 629 security test cases. Later adaptive-attack work bypassed eight evaluated defences above 50 per cent attack success, while a 2026 study of out-of-band controls found a deterministic Progent configuration reduced mean success from 25.8 to 4.2 per cent in one limited reproduction. The studies differ in targets and threat models, so their numbers should not be combined into a league table. Their common lesson is methodological: let the attacker adapt to the deployed defence.

Thought experiment one: move one line of code

Imagine two identical email agents. Both use the same model, prompt, injection detector, address book and tool schema. In System A, the model receives the instruction “send only to contacts”. The send function accepts any syntactically valid address. In System B, the model receives the same instruction, but the mail service itself rejects recipients that are absent from the user’s authorised contact set. Only one causal feature changes: where the rule is enforced.

When both models behave, the systems look identical. Under a hostile-model substitution, System A sends wherever the model asks. System B still refuses. The difference is not a better explanation of the policy. It is that the model no longer controls both proposal and permission. Security appears only when the decision is made by a component whose incentives and state the attacker cannot rewrite through natural language.

The enforcement gradient A scatter plot places common guardrails by distance from the model and by whether they are probabilistic or deterministic. The enforcement gradient inside model at protected resource distance from the model’s control boundary → probabilistic influence deterministic enforcement system prompt guardrail model output classifier schema validator action policy service authorisation state invariant Harder to bypass by persuasion but only for the property actually encoded
Figure 2. The enforcement gradient.Illustrative placement. Moving away from the model and towards the protected resource generally reduces the model’s ability to reinterpret the control. It does not make a bad policy correct.

A negative control: when containment adds little

Now remove consequence. Put a summarisation model in an isolated process with one public document, no secrets, no network, no tools, no persistent memory and a fixed text-only return channel. The summary may be wrong or manipulated, so evaluation and labelling still matter. But action mediation, payment invariants and capability attenuation add little because the system has no privileged effect surface to contain.

This negative control prevents a common overreaction. The conclusion is not that every model call needs a miniature operating system. Containment should scale with the authority, data and irreversibility attached to the call. A low-consequence text transformation can rely mainly on output validation and human judgement. An agent that writes records, sends messages, executes code or moves money needs controls at the point where those effects become real.

Part two

Four control planes, four different jobs

A defensible design separates four planes rather than searching for one universal guardrail. Each plane answers a different question. Input controls ask what may enter the reasoning context. Output controls ask what may leave it or be consumed by another component. Action controls ask whether a proposed operation is authorised. Property controls ask whether the transition preserves a protected invariant over authoritative state. A recovery plane surrounds all four because even an authorised request can end with an unknown or partially completed outcome.

1. Input admission reduces exposure

Input admission establishes provenance, integrity and handling constraints before content reaches the model. It can reject unsupported file types, strip active content, scan for malware, cap size, separate retrieved data from trusted instructions, attach confidentiality labels, quarantine poisoned memory and use a classifier to detect likely injection. These controls are valuable because an attack stopped here cannot influence later reasoning.

They are not sufficient. Natural language does not provide a parser-enforced boundary between command and data. The UK National Cyber Security Centre therefore describes the model as an inherently confusable deputy and warns against relying on content deny-lists. A detector may miss an obfuscated instruction, a benign sentence may cause a false positive, and an attacker can optimise after seeing the filter. Input admission should therefore produce risk signals and provenance that later controls consume. It should not mint authority.

2. Output admission protects the next interpreter

Output admission treats model output as untrusted input to whatever comes next. A structured-output decoder can constrain syntax. A validator can reject unknown fields, excessive lengths, malformed identifiers and impossible values. Context-specific encoding can prevent generated text from becoming executable HTML, SQL or shell syntax. Data-loss prevention can stop obvious secrets. Claim checks can require a cited source before a statement enters a report.

Syntax is a type property, not an authorisation decision. A JSON object may be perfectly valid while naming the wrong tenant, recipient or beneficiary. An output moderator can mark the final prose safe after the agent has already called a harmful tool. For this reason, output admission must occur before every downstream interpreter, not only before the user sees the final answer. OWASP separates improper output handling from excessive agency for precisely this reason: scrutinising model output and constraining downstream power are related but distinct controls.

3. Action mediation separates proposal from permission

Action mediation is the first plane that can remain model-independent under the hostile-model test. The model proposes a typed action. A deterministic policy decision reads authenticated identity, delegated capability, current world state and the complete action arguments. A separate enforcement point either invokes a narrow tool or refuses. Every route to the protected resource must cross this point, including retries, batch jobs, recovery paths, direct APIs and administrative shortcuts.

This is the old principle of complete mediation applied to an agent. Saltzer and Schroeder argued that every access to every object must be checked for authority, with fail-safe defaults based on explicit permission. OWASP’s excessive-agency guidance reaches the same operational conclusion: minimise tool functionality and permissions, avoid open-ended extensions, execute in the user’s context and perform authorisation downstream rather than asking the model whether its own request is allowed.

A useful action contract carries more than a function name. It binds the requesting human, proposing agent and executing service; names the tenant and resource; limits operation, amount, destination and purpose; includes expiry and a one-time nonce; specifies required approvals; and declares the expected postcondition. The tool receives no ambient credential that could perform unrelated work. Where possible, a proxy injects short-lived credentials after approval so the model never sees them.

4. Property enforcement protects state, not requests

An action may be individually authorised yet still violate a cumulative or relational rule. Ten payments below a per-call limit can exceed a daily exposure. A document can be readable by two principals but unlawful to combine. A beneficiary may be in the address book yet not verified for this legal entity. A send operation may be authorised, but confidential content derived from an external email must not flow to an external recipient.

Property controls therefore evaluate the state transition as a whole. Examples include tenant isolation, “no payment to an unmastered beneficiary”, “no external disclosure of confidential provenance”, segregation of duties, aggregate budgets, version preconditions and exactly-once effects. The strongest location is often the system that owns the state: a database constraint, transaction service, policy-enforcement proxy or workflow engine. The closer the invariant sits to the authoritative state, the fewer alternative paths can bypass it.

Allow(a, s, c) ⇔ Typed(a) ∧ BoundIdentity(a, c) ∧ Delegated(a, c) ∧ Fresh(c) ∧ InScope(a, c) ∧ Preserves(P, s, a)

Here a is the proposed action, s the authoritative state, c the delegated capability and P the protected property set. Increasing the model’s confidence does not change the expression. Changing the capability, state or policy can. The rule also makes abstention concrete: if a required value is missing or unknown, the conjunction is false and the gate fails closed.

The recovery plane closes the temporal gap

Authorisation answers whether an effect may begin. It does not prove that the effect occurred once, completely or at all. A network timeout after a payment request can leave the caller uncertain whether the downstream service committed. Blind retry can duplicate the effect. Treating the timeout as failure can hide a completed transaction.

The recovery plane issues a decision receipt before execution, an effect receipt after execution and a readback against authoritative state. Idempotency keys, transaction identifiers and postcondition checks distinguish “denied”, “committed”, “not committed” and “unknown outcome”. Revocation, circuit breakers, rate limits, protected logs and a kill switch limit continuing harm. Current NCSC guidance similarly recommends strong sandbox boundaries, default-deny network access, short-lived credentials, immutable operational telemetry and the ability to halt wider agent communications, not merely stop one process.

Model-independent does not mean model-free

A contained system may use several models. One can detect likely injection, another can extract a typed proposal, and a third can estimate whether human review is needed. The architecture becomes model-independent only for the protected property: replacing every one of those models with an adversarial proposer must not make the forbidden transition permissible. Their scores may choose a safer route, add friction or trigger escalation. They must not be the sole source of identity, authority or permission.

This distinction avoids an unhelpful binary between “AI guardrails” and “deterministic software”. A model is often the best component for interpreting a messy document or recognising an unusual pattern. Deterministic code is often the best component for checking a signature, comparing an amount, resolving a tenant or consuming a nonce. The two can form a cascade. A detector blocks obvious attacks cheaply; a model converts the surviving request into a typed proposal; an external gate checks the proposal; a human resolves the narrow cases where policy permits declassification or exceptional authority.

A judge model can also contribute operating evidence, provided its judgement is treated as fallible. For example, it may compare the proposal with the user’s stated purpose and flag a semantic mismatch that a fixed rule cannot see. The hard gate can then deny or escalate on that flag without treating an “allow” verdict as sufficient permission. Models may add reasons to stop; they should not be able to manufacture the facts that make a consequential action authorised.

Fail closed without making work impossible

Containment creates a liveness problem. If every untrusted value permanently contaminates the workflow, legitimate tasks stall. If every exception needs a pop-up, users learn to approve reflexively. A usable design therefore separates hard invariants from recoverable uncertainty. Hard invariants, such as tenant isolation or a cryptographic signature requirement, do not yield. Recoverable conditions can route to a bounded process: acquire a fresh source, ask an authorised person to endorse one value, create a clean child context, or resume from a checkpoint with a newly issued capability.

The recovery route must be narrower than the blocked route. “Ask the user whether to ignore all controls” simply moves the attack to the human. A better endorsement names the exact value, destination, effect, expiry and evidence being accepted. It records who accepted it and prevents that approval from becoming general authority for later actions. Recent information-flow work such as APPA explores this recoverability problem because abort-only taint systems can preserve security by destroying too much utility. The engineering objective is not maximum refusal. It is useful progress through explicit, inspectable changes in authority.

The same proposal diverges at the enforcement point Two paths begin with the same hostile payment proposal. In one, a prompt-only rule lets it reach the ledger. In the other, an external property gate blocks it. Same proposal, one changed causal feature MODEL BEN-ATTACKER Rule inside prompt “Use approved accounts” model can ignore it Rule at state boundary mastered beneficiary + approval model cannot waive it LEDGER changed Forbidden transition stays unreachable
Figure 3. Same proposal, different reachable state.Illustrative thought experiment. The model, prompt and action are held constant. Only the enforcement location changes.
FailureInput admissionOutput admissionAction mediationProperty enforcementRecovery
Indirect prompt injection△ Reduce△ Detect residue● Bound effects● Preserve invariant△ Contain incident
Hallucinated tool arguments○ Little△ Validate form● Check scope● Check state△ Recover
Compromised model○ Cannot trust○ Same boundary● External gate● External invariant△ Revoke
Stale world state○ No○ No△ Freshness check● Authoritative read△ Replay
Confused deputy△ Provenance○ No● Identity + capability● Resource policy△ Attribute
Duplicate effect after timeout○ No○ No△ Idempotency key△ Uniqueness● Readback + receipt
Side channel or sandbox escape○ Little○ Little△ Reduce surface△ Limit blast radius△ Detect and stop
Figure 4. Control coverage is not interchangeable.Practitioner synthesis. ● means the plane can directly enforce part of the property; △ means risk reduction or partial coverage; ○ means little direct coverage. Exact coverage depends on implementation and threat model.
Part three
The argument in one passChart · flow
flowchart LR
  A["The hostile-model test"] --> B["Four control planes, four<br/>different jobs"]
  B --> C["From safe text to safe state<br/>transitions"]
  C --> D["Build a small kernel, then try<br/>to mutate past it"]
  D -. "re-test" .-> A
  classDef first fill:#0a1424,stroke:#007D68,stroke-width:2px,color:#e6f3ff
  classDef step fill:#0a1424,stroke:#3f7da0,stroke-width:1px,color:#e6f3ff
  class A first
  class B,C,D step
A structural route through Guardrails and Model-Independent Containment, from The hostile-model test to Source ledger. Reading use: follow the relation, then test it against the surrounding evidence and limits.

From safe text to safe state transitions

Worked example one: an email assistant

A user asks an assistant to summarise an external email and prepare a reply to an internal reviewer. The email is untrusted and contains confidential case material. The minimal design separates three effects. Reading the message is allowed under a read capability. Drafting text is allowed into a quarantined draft store. Sending is a different typed action that requires an authoritative recipient and a send capability.

The model can use the untrusted message to compose the draft because no external effect occurs. It cannot promote an address found inside that message into an authorised destination. If the proposed recipient is reviewer@example.internal and comes from the address book, the draft may be stored. If an injected instruction proposes attacker@outside.example, the send gate refuses. The content classifier may also flag the attempt, but the confidentiality property does not depend on that classification being perfect.

This design is deliberately less convenient than one universal mail_tool(command). The loss of generality buys a narrower action vocabulary, distinct read, draft and send permissions, and a decision point before data leaves the boundary. The model remains useful precisely because it controls the high-dimensional linguistic work while deterministic software controls the small set of consequential transitions.

Worked example two: a production-shaped payment flow

The payment case needs more than an allowlist. The relevant identities are the human who requested work, the agent that proposed a payment, the approver who accepted it and the service that executes it. The relevant state includes the legal entity, invoice, purchase order, mastered beneficiary, approval status, current exposure and prior effect receipts. The relevant authority is temporary and purpose-bound.

A model proposal contains an invoice identifier, beneficiary, amount, currency and rationale. The gate does not trust the rationale as evidence. It reconstructs the beneficiary from mastered state, checks that the capability belongs to this agent and tenant, verifies that the amount is within both the capability and the independent approval, confirms separation of duties, and attaches a one-time idempotency key. The executor then calls a payment-specific endpoint, not a general database or shell tool. After the call, the system reads the payment record back and writes an effect receipt.

An invoice injection can alter the proposal, but it cannot alter the beneficiary master, create an approval, widen the capability or mark a nonce unused. A compromised model can try several individually plausible values, but rate and cumulative-exposure limits restrict enumeration. An operator can revoke the task capability without rotating a shared platform credential. The design protects a business property by composing identity, authority, world state and verified outcome, not by asking the model to narrate those controls.

Capability attenuation narrows authority A broad user objective narrows through human intent, agent task and executor capability into one time-limited payment effect. Authority should narrow as execution approaches Human objective “Settle valid invoices for tenant A” Agent task authority read invoice + propose payment, no ambient write Executor capability release_payment, BEN-001, ≤ £1,000, expires 10 min ONE VERIFIED EFFECT nonce consumed after commit resources · actions · value · time · purpose
Figure 5. Capability attenuation cone.Illustrative. Delegation should remove authority at each step. The model receives only the task capability needed to propose work; the executor receives a still narrower, short-lived capability for one effect.
Payment transition labyrinth A payment moves through proposed, reconciled, independently approved and committed states. Shortcuts from an invoice directly to release are blocked. The payment is a state machine, not a sentence PROPOSEDmodel output RECONCILEDstate matched APPROVEDindependent COMMITTEDread back Direct release shortcut blocked missing mastered state, approval or fresh capability fails closed
Figure 6. Payment transition labyrinth.Synthetic workflow. The permitted route requires state reconciliation, independent approval and postcondition verification. Natural-language confidence cannot open the blocked shortcut.

Thought experiment two: the perfectly polite incident

Suppose an agent calls a destructive tool and only afterwards produces its user-facing answer. The tool call deletes a record. The final answer says, politely and accurately, “I could not complete the request because deletion is prohibited.” An output moderator sees a safe response. A judge model awards full policy compliance. The protected state is still gone.

Now reverse the order. The model first emits a typed proposal. The gate checks authority and state before any deletion. The proposal is denied and the final answer reports that denial. The language may be less polished, but the property holds. Post hoc moderation cannot contain an effect that has already crossed the boundary. This is why traces, explanations and model confidence are operating evidence, not substitutes for effect mediation.

Part four

Build a small kernel, then try to mutate past it

The executable artefact accompanying this article treats the model as an arbitrary proposal generator. It does not inspect prose for malicious intent. Instead, a small Python reference monitor accepts a typed proposal, capability and synthetic world state. It checks the action vocabulary, principal binding, tenant, resource scope, expiry, purpose, nonce, field provenance, amount, mastered beneficiary and independent approval. If allowed, it performs a simulated effect, consumes the nonce and verifies the postcondition.

The mutation suite starts with one authorised £750 payment. It then changes one causal feature at a time: action, delegated permission, actor, tenant, resource, expiry, purpose, nonce, amount, destination integrity, mastered beneficiary, approval presence, approval destination and approver independence. A separate test repeats a previously committed request. The expected outcome is asymmetric: valid cases must work, while every mutation that violates a protected property must fail closed for the intended reason.

2 / 2valid cases allowed and postconditions verified
14 / 14one-feature hostile mutations blocked
1 / 1post-commit replay attempt denied

These numbers are measured from the bundled synthetic harness. They do not estimate attack success against a language model, validate a production implementation or prove freedom from side channels. Their purpose is more modest and more inspectable: demonstrate that the policy is explicit enough to run, that positive work remains possible, and that each named mutation maps to an observable denial.

  1. Write properties before prompts. Express the harm as an invariant over identity, authority, information flow or world state. “The model should be careful” is not testable. “No confidential value may reach an external sink without named approval” is.
  2. Enumerate every effect path. Include tools, direct APIs, batch routes, retries, recovery jobs, admin consoles, caches and subordinate agents. A perfect gate on one path is irrelevant if another path reaches the same resource.
  3. Reduce the action vocabulary. Replace open-ended shell, SQL, browser and generic HTTP tools with typed operations that expose only the needed effect. Validate semantic fields against authoritative state.
  4. Bind narrow authority. Carry principal, tenant, resource, operation, purpose, value, destination, expiry and nonce. Inject credentials after authorisation, and consume or revoke authority after use.
  5. Verify the outcome. Record decision and effect receipts, read back protected state, distinguish unknown outcome from failure, and make retries idempotent.
  6. Attack the enforcement layer. Test policy omissions, provenance spoofing, alternate routes, state races, approval manipulation, replay, side channels, resource exhaustion and emergency withdrawal.
Measured synthetic mutation-suite results Three horizontal bars show two of two valid cases allowed, fourteen of fourteen hostile mutations blocked and one of one replay attempts blocked. Does the kernel fail open when one fact changes? Measured by the bundled standard-library Python harness Valid cases allowed 2 / 2 Hostile mutations blocked 14 / 14 Replay attempts blocked 1 / 1 Boundary: explicit-flow policy test, not a model-security benchmark No claim about policy completeness, side channels, sandbox escape or real payment infrastructure.
Figure 7. Mutation-suite result.Measured on the bundled synthetic harness. The denominator is the test set defined in the artefact, not the universe of attacks.

Inspect the implementation in this paper. It uses only the standard library. Running python3 guardrail_containment_lab.py prints the assumptions, decisions, expected denial reasons and summary as JSON.

Executable artefact: complete Python source
#!/usr/bin/env python3
"""Synthetic containment kernel and mutation suite for Paper 158.

The model is treated as an untrusted proposal generator. This program decides
whether a proposed effect is authorised, records a decision receipt, performs a
small simulated effect, and verifies the postcondition. It uses only the Python
standard library and synthetic data.

Run:
    python3 guardrail_containment_lab.py

Expected summary:
    2/2 positive cases allowed
    14/14 hostile mutations blocked
    1/1 replay attempt blocked
"""

from __future__ import annotations

from dataclasses import asdict, dataclass, field, replace
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from enum import Enum
from hashlib import sha256
import json
from typing import Callable, Iterable, Mapping


class Integrity(str, Enum):
    """Integrity is about who may authorise a value, not whether it is true."""

    AUTHORITATIVE = "authoritative"
    UNTRUSTED = "untrusted"
    UNKNOWN = "unknown"


class Classification(str, Enum):
    PUBLIC = "public"
    INTERNAL = "internal"
    CONFIDENTIAL = "confidential"


@dataclass(frozen=True)
class Value:
    value: str
    integrity: Integrity
    classification: Classification = Classification.PUBLIC
    source: str = "synthetic"


@dataclass(frozen=True)
class Capability:
    subject: str
    tenant: str
    actions: frozenset[str]
    resource_prefixes: tuple[str, ...]
    purpose: str
    expires_at: datetime
    nonce: str
    max_amount: Decimal = Decimal("0")


@dataclass(frozen=True)
class Approval:
    approval_id: str
    tenant: str
    action: str
    destination: str
    amount_limit: Decimal
    expires_at: datetime
    approver: str
    used: bool = False


@dataclass(frozen=True)
class ActionProposal:
    request_id: str
    actor: str
    tenant: str
    action: str
    resource: str
    purpose: str
    nonce: str
    destination: Value | None = None
    amount: Decimal | None = None
    approval_id: str | None = None
    content: Value | None = None


@dataclass
class WorldState:
    now: datetime
    verified_beneficiaries: dict[str, set[str]]
    approvals: dict[str, Approval]
    used_nonces: set[str] = field(default_factory=set)
    ledger: list[dict[str, object]] = field(default_factory=list)
    sent_messages: list[dict[str, object]] = field(default_factory=list)
    released_payments: list[dict[str, object]] = field(default_factory=list)

    def clone(self) -> "WorldState":
        return WorldState(
            now=self.now,
            verified_beneficiaries={k: set(v) for k, v in self.verified_beneficiaries.items()},
            approvals=dict(self.approvals),
            used_nonces=set(self.used_nonces),
            ledger=list(self.ledger),
            sent_messages=list(self.sent_messages),
            released_payments=list(self.released_payments),
        )


@dataclass(frozen=True)
class Decision:
    allowed: bool
    reasons: tuple[str, ...]
    receipt_id: str
    checked_properties: tuple[str, ...]


@dataclass(frozen=True)
class EffectReceipt:
    request_id: str
    effect: str
    committed: bool
    postcondition_verified: bool
    receipt_id: str


class ContainmentKernel:
    """A small, deterministic reference monitor around an untrusted model."""

    ALLOWED_ACTIONS = frozenset({"draft_email", "send_email", "release_payment"})
    INTERNAL_EMAIL_DOMAIN = "example.internal"

    def decide(
        self,
        proposal: ActionProposal,
        capability: Capability,
        state: WorldState,
    ) -> Decision:
        reasons: list[str] = []
        checked: list[str] = []

        def require(condition: bool, code: str, property_name: str) -> None:
            checked.append(property_name)
            if not condition:
                reasons.append(code)

        # The model cannot create an action vocabulary at runtime.
        require(
            proposal.action in self.ALLOWED_ACTIONS,
            "ACTION_NOT_IN_KERNEL_ALLOWLIST",
            "typed action allowlist",
        )
        require(
            proposal.action in capability.actions,
            "ACTION_NOT_DELEGATED",
            "least authority",
        )
        require(
            proposal.actor == capability.subject,
            "SUBJECT_MISMATCH",
            "requesting principal binding",
        )
        require(
            proposal.tenant == capability.tenant,
            "TENANT_MISMATCH",
            "tenant isolation",
        )
        require(
            proposal.resource.startswith(capability.resource_prefixes),
            "RESOURCE_OUT_OF_SCOPE",
            "resource scope",
        )
        require(
            state.now < capability.expires_at,
            "CAPABILITY_EXPIRED",
            "short-lived authority",
        )
        require(
            proposal.purpose == capability.purpose,
            "PURPOSE_MISMATCH",
            "purpose limitation",
        )
        require(
            proposal.nonce == capability.nonce,
            "NONCE_MISMATCH",
            "capability-instance binding",
        )
        require(
            proposal.nonce not in state.used_nonces,
            "REPLAY_DETECTED",
            "exactly-once effect",
        )

        if proposal.action in {"draft_email", "send_email"}:
            self._check_email(proposal, reasons, checked)
        elif proposal.action == "release_payment":
            self._check_payment(proposal, capability, state, reasons, checked)

        receipt_id = self._receipt_id(proposal, capability, reasons)
        return Decision(
            allowed=not reasons,
            reasons=tuple(reasons),
            receipt_id=receipt_id,
            checked_properties=tuple(dict.fromkeys(checked)),
        )

    def _check_email(
        self,
        proposal: ActionProposal,
        reasons: list[str],
        checked: list[str],
    ) -> None:
        checked.extend(("destination provenance", "confidentiality at sink"))
        if proposal.destination is None or proposal.content is None:
            reasons.append("MISSING_TYPED_EMAIL_FIELDS")
            return
        if proposal.destination.integrity is not Integrity.AUTHORITATIVE:
            reasons.append("DESTINATION_NOT_AUTHORITATIVE")
        external = not proposal.destination.value.endswith("@" + self.INTERNAL_EMAIL_DOMAIN)
        if external and proposal.content.classification is Classification.CONFIDENTIAL:
            reasons.append("CONFIDENTIAL_DATA_TO_EXTERNAL_SINK")
        if proposal.action == "send_email" and external:
            # The demo deliberately requires authoritative destination provenance.
            # A production policy could additionally require a separate approval.
            if proposal.destination.source != "address-book":
                reasons.append("EXTERNAL_DESTINATION_NOT_FROM_ADDRESS_BOOK")

    def _check_payment(
        self,
        proposal: ActionProposal,
        capability: Capability,
        state: WorldState,
        reasons: list[str],
        checked: list[str],
    ) -> None:
        checked.extend(
            (
                "authoritative payment fields",
                "positive bounded amount",
                "mastered beneficiary",
                "independent approval",
            )
        )
        if proposal.destination is None or proposal.amount is None:
            reasons.append("MISSING_TYPED_PAYMENT_FIELDS")
            return
        if proposal.destination.integrity is not Integrity.AUTHORITATIVE:
            reasons.append("DESTINATION_NOT_AUTHORITATIVE")
        if proposal.amount <= 0:
            reasons.append("NON_POSITIVE_AMOUNT")
        if proposal.amount > capability.max_amount:
            reasons.append("AMOUNT_EXCEEDS_CAPABILITY")

        mastered = state.verified_beneficiaries.get(proposal.tenant, set())
        if proposal.destination.value not in mastered:
            reasons.append("BENEFICIARY_NOT_VERIFIED")

        approval = state.approvals.get(proposal.approval_id or "")
        if approval is None:
            reasons.append("APPROVAL_MISSING")
            return
        if approval.used:
            reasons.append("APPROVAL_ALREADY_USED")
        if state.now >= approval.expires_at:
            reasons.append("APPROVAL_EXPIRED")
        if approval.tenant != proposal.tenant:
            reasons.append("APPROVAL_TENANT_MISMATCH")
        if approval.action != proposal.action:
            reasons.append("APPROVAL_ACTION_MISMATCH")
        if approval.destination != proposal.destination.value:
            reasons.append("APPROVAL_DESTINATION_MISMATCH")
        if proposal.amount > approval.amount_limit:
            reasons.append("APPROVAL_AMOUNT_EXCEEDED")
        if approval.approver == proposal.actor:
            reasons.append("APPROVAL_NOT_INDEPENDENT")

    def authorise_and_commit(
        self,
        proposal: ActionProposal,
        capability: Capability,
        state: WorldState,
    ) -> tuple[Decision, EffectReceipt]:
        decision = self.decide(proposal, capability, state)
        state.ledger.append(
            {
                "kind": "decision",
                "request_id": proposal.request_id,
                "allowed": decision.allowed,
                "reasons": list(decision.reasons),
                "receipt_id": decision.receipt_id,
            }
        )
        if not decision.allowed:
            return decision, EffectReceipt(
                request_id=proposal.request_id,
                effect=proposal.action,
                committed=False,
                postcondition_verified=True,
                receipt_id=decision.receipt_id,
            )

        before_count = self._effect_count(proposal.action, state)
        if proposal.action == "draft_email":
            # A draft is represented as a message with committed=False.
            state.sent_messages.append(
                {
                    "request_id": proposal.request_id,
                    "to": proposal.destination.value if proposal.destination else None,
                    "content": proposal.content.value if proposal.content else None,
                    "sent": False,
                }
            )
        elif proposal.action == "send_email":
            state.sent_messages.append(
                {
                    "request_id": proposal.request_id,
                    "to": proposal.destination.value if proposal.destination else None,
                    "content": proposal.content.value if proposal.content else None,
                    "sent": True,
                }
            )
        elif proposal.action == "release_payment":
            state.released_payments.append(
                {
                    "request_id": proposal.request_id,
                    "tenant": proposal.tenant,
                    "beneficiary": proposal.destination.value if proposal.destination else None,
                    "amount": str(proposal.amount),
                }
            )
            assert proposal.approval_id is not None
            state.approvals[proposal.approval_id] = replace(
                state.approvals[proposal.approval_id], used=True
            )

        state.used_nonces.add(proposal.nonce)
        after_count = self._effect_count(proposal.action, state)
        postcondition_verified = after_count == before_count + 1
        effect_receipt_id = sha256(
            f"{decision.receipt_id}:{after_count}:{postcondition_verified}".encode("utf-8")
        ).hexdigest()[:20]
        effect_receipt = EffectReceipt(
            request_id=proposal.request_id,
            effect=proposal.action,
            committed=True,
            postcondition_verified=postcondition_verified,
            receipt_id=effect_receipt_id,
        )
        state.ledger.append({"kind": "effect", **asdict(effect_receipt)})
        return decision, effect_receipt

    @staticmethod
    def _effect_count(action: str, state: WorldState) -> int:
        if action in {"draft_email", "send_email"}:
            return len(state.sent_messages)
        if action == "release_payment":
            return len(state.released_payments)
        return 0

    @staticmethod
    def _receipt_id(
        proposal: ActionProposal,
        capability: Capability,
        reasons: Iterable[str],
    ) -> str:
        serialisable = {
            "proposal": _normalise(asdict(proposal)),
            "capability": _normalise(asdict(capability)),
            "reasons": list(reasons),
        }
        payload = json.dumps(serialisable, sort_keys=True, separators=(",", ":"))
        return sha256(payload.encode("utf-8")).hexdigest()[:20]


@dataclass(frozen=True)
class TestCase:
    name: str
    proposal_mutator: Callable[[ActionProposal], ActionProposal] | None = None
    capability_mutator: Callable[[Capability], Capability] | None = None
    state_mutator: Callable[[WorldState], None] | None = None
    expected_reason: str | None = None


def _normalise(value: object) -> object:
    if isinstance(value, datetime):
        return value.isoformat()
    if isinstance(value, Decimal):
        return str(value)
    if isinstance(value, Enum):
        return value.value
    if isinstance(value, frozenset):
        return sorted(value)
    if isinstance(value, tuple):
        return [_normalise(v) for v in value]
    if isinstance(value, list):
        return [_normalise(v) for v in value]
    if isinstance(value, dict):
        return {str(k): _normalise(v) for k, v in value.items()}
    return value


def fixtures() -> tuple[WorldState, Capability, ActionProposal, ActionProposal]:
    now = datetime(2026, 8, 30, 9, 0, tzinfo=timezone.utc)
    state = WorldState(
        now=now,
        verified_beneficiaries={"tenant-a": {"BEN-001"}},
        approvals={
            "APR-001": Approval(
                approval_id="APR-001",
                tenant="tenant-a",
                action="release_payment",
                destination="BEN-001",
                amount_limit=Decimal("1000.00"),
                expires_at=now + timedelta(minutes=15),
                approver="human-approver-7",
            )
        },
    )
    payment_capability = Capability(
        subject="agent-payments-1",
        tenant="tenant-a",
        actions=frozenset({"release_payment"}),
        resource_prefixes=("payments/tenant-a/",),
        purpose="settle-approved-invoice",
        expires_at=now + timedelta(minutes=10),
        nonce="nonce-payment-001",
        max_amount=Decimal("1000.00"),
    )
    payment = ActionProposal(
        request_id="REQ-PAY-001",
        actor="agent-payments-1",
        tenant="tenant-a",
        action="release_payment",
        resource="payments/tenant-a/invoice-773",
        purpose="settle-approved-invoice",
        nonce="nonce-payment-001",
        destination=Value(
            "BEN-001", Integrity.AUTHORITATIVE, source="beneficiary-master"
        ),
        amount=Decimal("750.00"),
        approval_id="APR-001",
    )
    draft = ActionProposal(
        request_id="REQ-MAIL-001",
        actor="agent-mail-1",
        tenant="tenant-a",
        action="draft_email",
        resource="mail/tenant-a/drafts/001",
        purpose="prepare-internal-summary",
        nonce="nonce-mail-001",
        destination=Value(
            "reviewer@example.internal",
            Integrity.AUTHORITATIVE,
            source="address-book",
        ),
        content=Value(
            "Synthetic project summary.",
            Integrity.UNTRUSTED,
            Classification.CONFIDENTIAL,
            source="retrieved-notes",
        ),
    )
    return state, payment_capability, payment, draft


def payment_mutations(now: datetime) -> list[TestCase]:
    return [
        TestCase(
            "unknown action",
            proposal_mutator=lambda p: replace(p, action="run_shell"),
            expected_reason="ACTION_NOT_IN_KERNEL_ALLOWLIST",
        ),
        TestCase(
            "undelegated action",
            capability_mutator=lambda c: replace(c, actions=frozenset()),
            expected_reason="ACTION_NOT_DELEGATED",
        ),
        TestCase(
            "wrong actor",
            proposal_mutator=lambda p: replace(p, actor="agent-attacker"),
            expected_reason="SUBJECT_MISMATCH",
        ),
        TestCase(
            "cross-tenant request",
            proposal_mutator=lambda p: replace(p, tenant="tenant-b"),
            expected_reason="TENANT_MISMATCH",
        ),
        TestCase(
            "resource outside delegated prefix",
            proposal_mutator=lambda p: replace(p, resource="admin/global/keys"),
            expected_reason="RESOURCE_OUT_OF_SCOPE",
        ),
        TestCase(
            "expired capability",
            capability_mutator=lambda c: replace(c, expires_at=now - timedelta(seconds=1)),
            expected_reason="CAPABILITY_EXPIRED",
        ),
        TestCase(
            "purpose laundering",
            proposal_mutator=lambda p: replace(p, purpose="test-transfer"),
            expected_reason="PURPOSE_MISMATCH",
        ),
        TestCase(
            "capability nonce substitution",
            proposal_mutator=lambda p: replace(p, nonce="nonce-forged"),
            expected_reason="NONCE_MISMATCH",
        ),
        TestCase(
            "amount above delegated maximum",
            proposal_mutator=lambda p: replace(p, amount=Decimal("1000.01")),
            expected_reason="AMOUNT_EXCEEDS_CAPABILITY",
        ),
        TestCase(
            "beneficiary supplied by untrusted invoice",
            proposal_mutator=lambda p: replace(
                p,
                destination=Value(
                    "BEN-001", Integrity.UNTRUSTED, source="invoice-pdf"
                ),
            ),
            expected_reason="DESTINATION_NOT_AUTHORITATIVE",
        ),
        TestCase(
            "beneficiary absent from mastered state",
            proposal_mutator=lambda p: replace(
                p,
                destination=Value(
                    "BEN-ATTACKER", Integrity.AUTHORITATIVE, source="beneficiary-master"
                ),
            ),
            expected_reason="BENEFICIARY_NOT_VERIFIED",
        ),
        TestCase(
            "missing independent approval",
            proposal_mutator=lambda p: replace(p, approval_id=None),
            expected_reason="APPROVAL_MISSING",
        ),
        TestCase(
            "approval for different destination",
            state_mutator=lambda s: s.approvals.__setitem__(
                "APR-001", replace(s.approvals["APR-001"], destination="BEN-OTHER")
            ),
            expected_reason="APPROVAL_DESTINATION_MISMATCH",
        ),
        TestCase(
            "self-approval by proposing agent",
            state_mutator=lambda s: s.approvals.__setitem__(
                "APR-001", replace(s.approvals["APR-001"], approver="agent-payments-1")
            ),
            expected_reason="APPROVAL_NOT_INDEPENDENT",
        ),
    ]


def run_suite() -> Mapping[str, object]:
    kernel = ContainmentKernel()
    base_state, payment_cap, payment, draft = fixtures()

    # Positive case 1: confidential source may be used to prepare an internal draft.
    mail_cap = Capability(
        subject="agent-mail-1",
        tenant="tenant-a",
        actions=frozenset({"draft_email"}),
        resource_prefixes=("mail/tenant-a/drafts/",),
        purpose="prepare-internal-summary",
        expires_at=base_state.now + timedelta(minutes=10),
        nonce="nonce-mail-001",
    )
    draft_state = base_state.clone()
    draft_decision, draft_effect = kernel.authorise_and_commit(draft, mail_cap, draft_state)

    # Positive case 2: all payment invariants hold.
    payment_state = base_state.clone()
    payment_decision, payment_effect = kernel.authorise_and_commit(
        payment, payment_cap, payment_state
    )

    positive_results = [
        {
            "name": "internal confidential draft",
            "allowed": draft_decision.allowed,
            "postcondition_verified": draft_effect.postcondition_verified,
        },
        {
            "name": "approved mastered-beneficiary payment",
            "allowed": payment_decision.allowed,
            "postcondition_verified": payment_effect.postcondition_verified,
        },
    ]

    mutation_results: list[dict[str, object]] = []
    for case in payment_mutations(base_state.now):
        state = base_state.clone()
        proposal = payment
        capability = payment_cap
        if case.proposal_mutator:
            proposal = case.proposal_mutator(proposal)
        if case.capability_mutator:
            capability = case.capability_mutator(capability)
        if case.state_mutator:
            case.state_mutator(state)
        decision = kernel.decide(proposal, capability, state)
        mutation_results.append(
            {
                "name": case.name,
                "blocked": not decision.allowed,
                "expected_reason_seen": case.expected_reason in decision.reasons,
                "reasons": list(decision.reasons),
            }
        )

    # Replay is evaluated after a real successful commit, not as a static field mutation.
    replay_state = base_state.clone()
    first, _ = kernel.authorise_and_commit(payment, payment_cap, replay_state)
    second, _ = kernel.authorise_and_commit(payment, payment_cap, replay_state)
    replay_result = {
        "name": "exact replay after successful effect",
        "first_allowed": first.allowed,
        "second_blocked": not second.allowed,
        "expected_reason_seen": "REPLAY_DETECTED" in second.reasons,
        "reasons": list(second.reasons),
    }

    positives_allowed = sum(
        int(r["allowed"] and r["postcondition_verified"]) for r in positive_results
    )
    mutations_blocked = sum(
        int(r["blocked"] and r["expected_reason_seen"]) for r in mutation_results
    )
    replay_blocked = int(
        replay_result["first_allowed"]
        and replay_result["second_blocked"]
        and replay_result["expected_reason_seen"]
    )

    report = {
        "assumptions": [
            "All identities, approvals and mastered beneficiaries are synthetic.",
            "The model can propose arbitrary fields but cannot modify the kernel or world state.",
            "The demo checks explicit flows; it does not claim side-channel freedom.",
            "The simulated effect store is not a substitute for a real downstream transaction boundary.",
        ],
        "summary": {
            "positive_cases_allowed": positives_allowed,
            "positive_cases_total": len(positive_results),
            "hostile_mutations_blocked": mutations_blocked,
            "hostile_mutations_total": len(mutation_results),
            "replay_attempts_blocked": replay_blocked,
            "replay_attempts_total": 1,
        },
        "positive_cases": positive_results,
        "hostile_mutations": mutation_results,
        "replay": replay_result,
    }

    assert positives_allowed == len(positive_results), report
    assert mutations_blocked == len(mutation_results), report
    assert replay_blocked == 1, report
    return report


def main() -> None:
    report = run_suite()
    print(json.dumps(report, indent=2, sort_keys=True))


if __name__ == "__main__":
    main()

The trusted computing base must stay small enough to challenge

The hostile-model test moves trust; it does not eliminate it. The trusted computing base now includes the identity assertion, capability issuer, policy engine, authoritative-state adapter, enforcement proxy, executor and receipt store. If the model can edit their configuration, reach the resource around them or supply the “authoritative” state they consult, containment is cosmetic. These components need separate identities, narrow interfaces and change controls. The executor should not accept a caller-supplied statement that a policy check occurred. It should require a verifiable decision token or make the decision itself.

Economy of mechanism matters here. A thousand-rule policy generated dynamically by another model may be expressive, but it is hard to inspect and easy to contradict. Prefer a small action vocabulary and a small set of composable predicates. Keep policy generation outside the request path where possible. A model may draft a rule from a regulatory or business statement, but a named owner should review the compiled condition, test its positive and negative cases, assign an effective time and approve its release.

Every decision receipt should identify the policy version, principal, capability, relevant state version, checked properties, denial or approval reasons and intended postcondition. That record permits counterfactual replay after a defect: would the same request be allowed under the corrected policy and the state knowable at that time? It also exposes a subtle failure mode in which the policy changes between authorisation and execution. Binding the decision to a state version, short expiry and effect-specific nonce narrows that race.

The policy lifecycle needs the same discipline as code. Run new rules in shadow against historical cases, inspect changed decisions, canary them on low-consequence traffic, retain immediate rollback and expire temporary exceptions. Fuzz not only natural-language prompts but typed fields, encodings, numeric boundaries, object identifiers and sequences of individually permitted calls. Red teams should search for alternate effect paths and combinations that violate a cumulative property while satisfying every local rule. The reference monitor is valuable because it is challengeable, not because the word deterministic makes it infallible.

Where model-independent containment breaks

External enforcement narrows one problem; it does not erase security engineering. First, the policy can be wrong. A perfectly enforced rule that permits the wrong destination is still unsafe. Second, the authoritative state can be stale, compromised or semantically ambiguous. Third, provenance labels can be assigned incorrectly at ingestion. The 2026 systematisation of out-of-band defences identifies provenance assignment as part of the trusted base, and CaMeL’s own analysis reports side-channel limitations and user burden around policy and declassification.

Fourth, explicit data-flow checks can miss implicit flows, timing channels and inference from success or failure. Fifth, a sandbox, proxy or downstream service can contain implementation vulnerabilities. Sixth, a human approval can be socially engineered or degraded by alert fatigue. Seventh, action mediation does not prevent harmful text-to-text outcomes, such as a poisoned summary that persuades a human to act outside the controlled system. Eighth, strong fail-closed policies can create denial of service or make legitimate tasks impossible.

The guarantee is therefore conditional and local: for the enumerated effect paths, trusted enforcement components, authoritative inputs and encoded properties, a hostile model cannot directly authorise a forbidden transition. Every noun in that sentence is an audit question. Broad phrases such as “the agent is safe” discard those conditions and should be rejected.

Explicit failure boundary

Do not rely on this pattern to establish truth, policy correctness, side-channel freedom, sandbox integrity or human judgement. Do not call a property enforced unless all routes to the protected effect cross the same tamper-resistant decision. When the organisation cannot specify the property or mediate the effect, reduce autonomy or keep a person as the accountable executor.

The competing baseline deserves a real test

A model-side detector may be the right first control for a low-risk, high-volume workload. It can preserve generality, require less redesign and stop malicious content before expensive reasoning. The serious comparison is not “detectors bad, deterministic controls good”. It is the marginal risk reduction, utility, latency and operating cost of a detector-only design against a detector plus external gate, under the same adaptive attacker and task distribution.

An observation that would weaken this paper’s practical recommendation would be a model-only defence that preserves the stated high-consequence property across model upgrades, repeated attempts, adaptive attack optimisation, tool mutations and distribution shift, with independently measured false positives and no hidden downstream enforcement. No such general result is established by the sources reviewed here. Conversely, external gates should lose the comparison when the effect is low consequence, the policy cannot be stated more precisely than the model’s judgement, or the control cost destroys the useful task.

Decision instrument

The containment placement worksheet

Use the worksheet before selecting a guardrail product. Begin with a protected property and a concrete forbidden transition. Then name the authoritative facts required to decide it, the component that can mediate every route, the default on missing evidence, the receipt that proves the decision and the recovery action after ambiguity. If any cell remains rhetorical, the design is not yet enforceable.

FieldQuestionPayment example
Protected propertyWhat must remain true under a hostile model?No payment reaches an unmastered beneficiary.
Forbidden transitionWhich before-and-after state violates it?Approved invoice → released funds to any other account.
Authoritative factsWhich state is trusted for the decision?Tenant, beneficiary master, approval receipt, exposure, nonce ledger.
Enforcement pointWhich component mediates every effect route?Payment-specific service before ledger commit.
AuthorityHow is identity, scope, purpose and time bounded?Agent-bound capability, tenant A, BEN-001, ≤ £1,000, ten minutes.
Fail modeWhat happens when a value is unknown or stale?Deny and request independent reconciliation.
Operating evidenceWhat proves decision and outcome?Decision receipt, transaction ID, ledger readback, consumed nonce.
RecoveryHow are unknown outcome and active harm contained?Idempotent retry, hold queue, capability revocation, network cut-off.
Adaptive testHow will a defence-aware attacker vary the system?Forge provenance, race state, substitute approval, replay and alternate route.
Figure 8. Containment placement worksheet.Practitioner decision instrument with a synthetic payment example. A completed worksheet should map into code, policy tests and operating evidence, not remain a diagram.

Two adjacent design decisions remain separate. The Certainty Gradient asks which mechanism should perform each workflow step. Three Principals, One Decision preserves identity and delegated authority across human, agent and resource. This article asks the narrower security question: after those choices are made, does the effect boundary still hold when the model is deliberately uncooperative?

Release evidence, not confidence

A release gate for a consequential agent should include positive tasks, negative property tests, one-feature mutations, adaptive attacks, alternate-path review, state-race tests, replay and unknown-outcome recovery. Run the suite when the model, prompt, tool schema, policy, identity provider, downstream API or workflow changes. A model upgrade can improve utility without weakening a deterministic rule, but it can expose missing actions or drive the system into untested policy branches.

Measure at least task completion, false denial, forbidden-effect rate, approval burden, policy-evaluation latency, unknown outcomes and recovery time. Preserve the exact policy, state snapshot, decision receipt and effect receipt for consequential cases. A zero attack count on a static benchmark is evidence about that benchmark. A containment claim requires a threat model, a closed effect surface and an argument that the monitor is always invoked, tamper-resistant and small enough to inspect.

How the claim changes under pressureChart · state
stateDiagram-v2
  [*] --> Claim
  state "Thought experiment one: move<br/>one line of code" as Claim
  state "A negative control: when<br/>containment adds little" as Test
  state "1. Input admission reduces<br/>exposure" as Revision
  Claim --> Test: expose mechanism
  Test --> Decision: evidence survives
  Test --> Revision: rival survives
  Revision --> Claim: narrow or rebuild
  Decision --> [*]
The revision loop begins with Thought experiment one, move one line of… and keeps counterexamples inside the reasoning path. Reading use: follow the relation, then test it against the surrounding evidence and limits.

Compact glossary

Behavioural guardrail
A prompt, training method or model-based check intended to make unwanted behaviour less likely.
Containment
External mechanisms that keep named effects or state transitions unavailable under the stated threat model.
Complete mediation
The requirement that every access to every protected object crosses an authority check, including recovery and administrative paths.
Capability
A delegable, preferably unforgeable grant that designates a resource and the rights permitted over it.
Property control
A rule over authoritative state, such as tenant isolation, cumulative exposure or confidentiality at a sink.
Effect receipt
Evidence from the executor or authoritative state that an allowed action produced a particular postcondition.
Unknown outcome
A state in which the caller cannot yet determine whether an attempted effect committed, requiring readback rather than blind retry.

Make forbidden effects unreachable

Guardrails are often evaluated where they are easiest to see: the prompt, response and model trace. Consequence appears elsewhere. A message leaves the boundary, a credential reaches an endpoint, code changes a repository, a record is deleted or money moves. The engineering task is to connect that effect to identity, delegated authority, authoritative state and a property that can be tested before and after execution.

The changed architecture is a hostile oracle inside a deterministic authority and property envelope. Keep model alignment, injection detection, output moderation and judge models as useful probability-reduction layers. Do not ask them to provide a guarantee their own input channel can negotiate. Put typed actions, complete mediation, fail-safe defaults, narrow capabilities, state invariants and recovery at boundaries the model cannot rewrite.

The changed experiment is equally concrete. Begin with a legitimate task. Mutate one causal fact. Let the attacker know the defence. Verify the forbidden effect, not the model’s explanation. Then test the control’s own trusted base: policy, provenance, alternate routes, side channels, approvals and recovery. A system earns a bounded containment claim only when the protected property survives those attempts and its conditions remain visible.

The practical decision is therefore not to find a guardrail that makes the model universally trustworthy. It is to decide which authority the model never needs, which effects deserve a hard boundary, and which properties the surrounding system can keep true even on the model’s worst day.

The evidence conversationChart · sequence
sequenceDiagram
  participant Q as Question
  participant M as Mechanism
  participant E as Evidence
  participant D as Decision
  Q->>M: The hostile-model test
  M->>E: Thought experiment one: move one line of code
  E-->>M: compare rival explanations
  E->>D: Source ledger
  D-->>Q: revise scope or act
Question, mechanism, evidence and decision exchange the burdens developed in Guardrails and Model-Independent Containment. Reading use: follow the relation, then test it against the surrounding evidence and limits.

Source ledger

  1. Primary research: Debenedetti et al., Defeating Prompt Injections by Design (CaMeL).
  2. Primary research: Costa et al., Securing AI Agents with Information-Flow Control (FIDES).
  3. Primary research: Beurer-Kellner et al., Design Patterns for Securing LLM Agents against Prompt Injections.
  4. Primary benchmark: Debenedetti et al., AgentDojo.
  5. Primary negative result: Zhan et al., Adaptive Attacks Break Defenses against Indirect Prompt Injection Attacks on LLM Agents.
  6. Primary competing baseline: Shi et al., PromptArmor.
  7. Recent evaluation preprint: Narisetty et al., Adaptive Evaluation of Out-of-Band Defenses. Its reported reproduction is limited and is treated as such in the article.
  8. Recent systems preprint: Kravchenko et al., APPA: Recoverable Information-Flow Control for Real-World LLM Agents.
  9. Foundational security source: Saltzer and Schroeder, The Protection of Information in Computer Systems.
  10. Official guidance: UK NCSC, Prompt injection is not SQL injection (it may be worse).
  11. Official guidance: UK NCSC, Managing the cyber risk of agentic AI.
  12. Official practitioner taxonomy: OWASP GenAI Security Project, Excessive Agency, Improper Output Handling and Prompt Injection.
  13. Official architecture guidance: NIST, SP 800-207 Zero Trust Architecture.