Guardrails
The guardrail system is a framework, not a fixed set of classes: GuardrailEnv
is a runtime-checkable protocol with a single core method, evaluate(request).
Any object that implements it — yours or a registered one — plugs into the
wrappers, pipelines, and batch evaluators the same way.
How it works
- Write a guardrail — subclass
BaseGuardrailEnvand implement a pureevaluate(request)that returnsALLOWorDENY. - Compose — chain guardrails in a
GuardrailPipelineand stackWrappers (cache, timeout, audit) around any of them. - Fail closed — wrap the outermost layer in
DenyByDefaultWrapperso any exception becomesDENY, then gate the action onresult.is_allowed.
flowchart LR
A["Agent action"] --> B["DenyByDefaultWrapper"]
B --> C["GuardrailPipeline"]
C -- "ALLOW" --> D["Action runs"]
C -- "DENY" --> X["Action blocked"]
B -- "exception" --> X
Install
pip install briefcase-ai[guardrails]from briefcase.guardrails import ( BaseGuardrailEnv, EvalRequest, EvalResult, Effect,)Write a Guardrail
Subclass BaseGuardrailEnv and implement evaluate(). It receives an
EvalRequest and returns an EvalResult carrying an Effect (ALLOW or
DENY).
from briefcase.guardrails import BaseGuardrailEnv, EvalRequest, EvalResult, Effect
class TierGuardrail(BaseGuardrailEnv): """Allow access to a resource only for agents on the right tier."""
_name = "tier-check"
def __init__(self, allowed_tiers): self._allowed = set(allowed_tiers)
def evaluate(self, request: EvalRequest) -> EvalResult: tier = request.context.get("tier") if tier in self._allowed: return EvalResult( effect=Effect.ALLOW, guardrail_name=self._name, reason=f"tier '{tier}' is permitted", ) return EvalResult( effect=Effect.DENY, guardrail_name=self._name, reason=f"tier '{tier}' is not permitted", )
env = TierGuardrail(allowed_tiers={"standard", "premium"})
request = EvalRequest( agent="support-bot", action="invoke", resource="kb/internal-articles", context={"tier": "premium"},)
result = env.evaluate(request)print(result.is_allowed) # Trueprint(result.effect) # Effect.ALLOWprint(result.reason) # "tier 'premium' is permitted"EvalRequest
EvalRequest( agent="support-bot", # who is acting action="invoke", # what they want to do resource="kb/internal-articles", # what they want to act on context={"tier": "premium"}, # attributes the guardrail evaluates request_id=None, # optional correlation id)EvalResult
| Field | Description |
|---|---|
effect | Effect.ALLOW or Effect.DENY |
guardrail_name | Name of the guardrail that produced the result |
reason | Human-readable explanation |
is_allowed | True when effect == Effect.ALLOW |
policy_id | Optional identifier of the policy applied |
lakefs_sha | Optional commit the policy was loaded from |
eval_time_ms | Evaluation time |
Register and Instantiate
Register a guardrail by string id and instantiate it with make(), the same
register/make split used by Gymnasium. This lets callers construct guardrails
without importing the implementation.
from briefcase.guardrails import register, make
# entry_point is a "module:ClassName" string — in your code that is the import# path of your guardrail, e.g. "myapp.guardrails:TierGuardrail".register( id="tier-check-v1", entry_point=f"{__name__}:TierGuardrail", kwargs={"allowed_tiers": ["standard", "premium"]},)
env = make("tier-check-v1") # uses the registered kwargsenv = make("tier-check-v1", allowed_tiers=["premium"]) # override per callChain Guardrails with a Pipeline
- Define each check as its own guardrail (resource allowlist, tier check, rate check) so each stays small and testable.
- Order them in a
GuardrailPipeline, cheapest and most restrictive first. - Pick a mode —
FIRST_DENYshort-circuits on the firstDENY(the default and the cheapest);ALLandMAJORITYrun every stage.
GuardrailPipeline evaluates a request through several guardrails in order. By
default it short-circuits on the first DENY.
from briefcase.guardrails import ( BaseGuardrailEnv, EvalRequest, EvalResult, Effect, GuardrailPipeline, PipelineMode,)
class ResourceAllowlist(BaseGuardrailEnv): _name = "resource-allowlist"
def __init__(self, allowed): self._allowed = set(allowed)
def evaluate(self, request: EvalRequest) -> EvalResult: ok = request.resource in self._allowed return EvalResult( effect=Effect.ALLOW if ok else Effect.DENY, guardrail_name=self._name, reason="resource permitted" if ok else "resource not allowlisted", )
pipeline = GuardrailPipeline( stages=[ ResourceAllowlist(allowed={"kb/internal-articles"}), TierGuardrail(allowed_tiers={"standard", "premium"}), ], mode=PipelineMode.FIRST_DENY,)
request = EvalRequest( agent="support-bot", action="invoke", resource="kb/internal-articles", context={"tier": "premium"},)
outcome = pipeline.evaluate(request)print(outcome.is_allowed) # Trueprint(len(outcome.individual_results)) # one result per stage that ranPipelineMode options:
| Mode | Behavior |
|---|---|
FIRST_DENY | Stop on the first DENY (default) |
ALL | Evaluate every stage; DENY if any stage denies |
MAJORITY | Majority vote across stages |
flowchart LR
A["EvalRequest"] --> B["ResourceAllowlist"]
B -- "DENY" --> X["Block (short-circuit)"]
B -- "ALLOW" --> C["TierGuardrail"]
C -- "DENY" --> X
C -- "ALLOW" --> D["Allow"]
Composable Wrappers
A wrapper is a GuardrailEnv, so wrappers stack around any guardrail.
from briefcase.guardrails import ( CacheWrapper, TimeoutWrapper, DenyByDefaultWrapper, Effect,)
env = DenyByDefaultWrapper( TimeoutWrapper( CacheWrapper(TierGuardrail(allowed_tiers={"premium"})), max_ms=10.0, fallback_effect=Effect.DENY, ))
result = env.evaluate(request)| Wrapper | Effect |
|---|---|
CacheWrapper | Caches results with a TTL |
TimeoutWrapper | Stops waiting at max_ms and falls back (default DENY). See Limits |
AuditWrapper | Records every (request, result) for observability |
SamplingWrapper | Evaluates a fraction of requests; allows the rest |
DenyByDefaultWrapper | Catches exceptions and returns DENY |
ViolationModeWrapper | Default BLOCK passes DENY through; WARN and AUDIT convert it to ALLOW for soft-deny workflows |
Gate the Action (fail-closed)
A guardrail only governs an action if you actually gate on its result. Evaluate
before the side effect runs, treat anything that is not an explicit ALLOW as
a deny, and let the DenyByDefaultWrapper turn any unexpected exception into a
block.
from briefcase.guardrails import DenyByDefaultWrapper, EvalRequest
# Outermost layer fails closed: any exception inside becomes DENY.gate = DenyByDefaultWrapper(TierGuardrail(allowed_tiers={"premium"}))
def classify_ticket(ticket, *, agent="support-bot"): request = EvalRequest( agent=agent, action="invoke", resource="kb/internal-articles", context={"tier": ticket["tier"]}, )
try: result = gate.evaluate(request) except Exception: # Belt and suspenders: even if the gate itself raises, deny. raise PermissionError("guardrail evaluation failed; action blocked")
if not result.is_allowed: raise PermissionError(f"action denied: {result.reason}")
# Authorized — now it is safe to run the side effect. # return run_classification(ticket) # call your model / tools hereLimits
TimeoutWrapper bounds your wait, not the guardrail’s work. Evaluation runs on
a worker thread and the wrapper stops waiting at max_ms, so a guardrail that
blocks for 5 seconds under a 50ms deadline returns DENY in about 55ms. Python
cannot cancel the call, so that guardrail keeps running on a daemon thread and its
answer is discarded: one that blocks forever leaks a thread per evaluation. The
worker costs about 45us per evaluation, so stack CacheWrapper inside it on a hot
path.
SamplingWrapper allows what it does not evaluate. That is the one wrapper
whose default direction is open. Reach for it when you are measuring a guardrail’s
cost, not when it is the control in force.
ViolationModeWrapper stops enforcing in two of its three modes. It defaults to
BLOCK, which passes DENY through unchanged. WARN and AUDIT convert DENY to
ALLOW and annotate the metadata, which is what you want for a shadow rollout and
never what you want in force.
Evaluation is synchronous and in-process. evaluate() is a plain method on the
calling thread. A guardrail that calls a policy service adds that latency to every
gated action, and CacheWrapper is the only thing standing between you and one call
per request.
The cache keys on the whole request, context included. Two requests differing
only in context are cached separately, which is correct and also means a
high-cardinality context field makes the cache useless.
Nothing gates for you. A guardrail governs an action only where your code
branches on result.is_allowed before the side effect. There is no interceptor and
no decorator that enforces this.
API reference
briefcase.guardrails has the full protocol, wrapper,
and pipeline surface. Gymnasium drives a guardrail from
an RL environment to search for the requests that get past it.
Where this fits
Guardrails are the Control act of the journey: enforce the rule before the action runs. Once an action is authorized, route it; once it has run, capture it.