Skip to content

Guardrails

guardrails For: governance & control

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

  1. Write a guardrail — subclass BaseGuardrailEnv and implement a pure evaluate(request) that returns ALLOW or DENY.
  2. Compose — chain guardrails in a GuardrailPipeline and stack Wrappers (cache, timeout, audit) around any of them.
  3. Fail closed — wrap the outermost layer in DenyByDefaultWrapper so any exception becomes DENY, then gate the action on result.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

Terminal window
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) # True
print(result.effect) # Effect.ALLOW
print(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

FieldDescription
effectEffect.ALLOW or Effect.DENY
guardrail_nameName of the guardrail that produced the result
reasonHuman-readable explanation
is_allowedTrue when effect == Effect.ALLOW
policy_idOptional identifier of the policy applied
lakefs_shaOptional commit the policy was loaded from
eval_time_msEvaluation 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 kwargs
env = make("tier-check-v1", allowed_tiers=["premium"]) # override per call

Chain Guardrails with a Pipeline

  1. Define each check as its own guardrail (resource allowlist, tier check, rate check) so each stays small and testable.
  2. Order them in a GuardrailPipeline, cheapest and most restrictive first.
  3. Pick a modeFIRST_DENY short-circuits on the first DENY (the default and the cheapest); ALL and MAJORITY run 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) # True
print(len(outcome.individual_results)) # one result per stage that ran

PipelineMode options:

ModeBehavior
FIRST_DENYStop on the first DENY (default)
ALLEvaluate every stage; DENY if any stage denies
MAJORITYMajority 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)
WrapperEffect
CacheWrapperCaches results with a TTL
TimeoutWrapperStops waiting at max_ms and falls back (default DENY). See Limits
AuditWrapperRecords every (request, result) for observability
SamplingWrapperEvaluates a fraction of requests; allows the rest
DenyByDefaultWrapperCatches exceptions and returns DENY
ViolationModeWrapperDefault 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 here

Limits

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.