Custom guardrails
Write guardrail rules as CEL expressions in Orca Agent Engine - the rule shape, the event fields an expression can read, write-time validation, and examples.
A custom guardrail is a rule written as a CEL expression instead of a builtin
type. The expression is a yes-or-no question about the current request or tool call: true allows
the action, and false applies the rule's on_false verdict. Write one when no
builtin guardrail expresses the check, for example a
decision that depends on a tool's arguments.
Registry endpoint
Examples on this page target your registry endpoint - the deployment host root, with no path
suffix. For CLI, set ORCA_REGISTRY_URL and exactly one of ORCA_ACCESS_TOKEN (Bearer) or
ORCA_API_KEY (x-api-key). For TypeScript SDK, set ORCA_BASE_URL / ORCA_API_KEY (Bearer).
To find the endpoint, see Connect to the registry.
Expression rule fields
An expression rule goes in the guardrail's rule field. Unlike a builtin, it has no default phases,
so the guardrail must also set phases.
| Field | Type | Required | Description |
|---|---|---|---|
kind | string | Yes | expression. |
expression | string | Yes | A CEL expression of 1 to 4096 characters that produces a boolean. |
on_false | string | Yes | deny, or ask when the guardrail's only phase is tool_call. |
reason | string | No | Up to 1024 characters. Returned with the verdict: the agent receives it in place of a denied tool result, and a denied request reports it in session.error. |
How an expression is evaluated
trueallows andfalseapplieson_false. Adenystops evaluation there. After anask, the remaining guardrails still run, and the strictest verdict wins.- Anything else is an error. A result that is not a boolean, a field that is missing from the
event, a type mismatch, or an evaluation that takes more than 50 ms is an error. At
requestandtool_call, an error denies withGuardrail "<name>" could not be evaluated.Attool_result, the rule abstains. - The time limit does not interrupt an evaluation. The 50 ms budget is checked after the
expression finishes. Slow expressions come from large collections and complex
matches()patterns.
Event fields
An expression reads one variable, event. These fields are populated:
| Field | Type | Present |
|---|---|---|
event.phase | string | Always. request, tool_call, or tool_result. |
event.session.id | string | Always. |
event.tool.name | string | At tool_call and tool_result. |
event.tool.input | map | At tool_call and tool_result. The tool's arguments, keyed as the tool defines them. |
event.result | any | At tool_result. The tool's output. |
event.model.id | string | The session's model, at every phase in separate mode on a cloud environment. It is absent on self_hosted environments and when skills are selected. |
event.state | map | Shared session counters, such as session_cost_usd and total_tokens. A key appears once the registry has recorded usage for it. |
Reading a field that is not listed here either returns 400 when you write the rule or fails the
rule when it runs. An expression cannot read the text of a user message; use deny_pii_in_llm_request
for content checks on requests.
Guard optional fields with has(): has(event.model) && event.model.id.startsWith("claude")
returns false rather than an error when the model is not reported. An expression that reads
event.state makes the rule stateful, which limits the runtimes that enforce it; see
Enforcement.
Check the tool name before you read event.tool.input. Agent Engine also evaluates
tool_call rules when it decides which skills to copy into the sandbox, with an input that holds
only the skill name. A rule that fails there removes the skill without a session event.
Functions
Expressions use the CEL standard library and no extensions:
- String methods
contains(),startsWith(),endsWith(),size(), andmatches(), which takes an RE2 pattern and matches anywhere in the string. infor list membership, and the macroshas(),all(),exists(),exists_one(),map(), andfilter().- Arithmetic, comparison, logical operators, and type conversions such as
int()andstring().
There are no custom functions and no string extensions such as lowerAscii(). For a
case-insensitive match, use matches("(?i)...").
Write-time validation
The registry parses the expression when you create or update the guardrail and returns 400 if:
- The expression is empty, over 4096 characters, or does not parse.
- It nests more than 128 levels deep, or nests one comprehension, such as
all(), inside another. - It references a variable other than
event, or aneventfield outside the allowed set. - The guardrail has no
phases, or setson_false: "ask"with a phase other thantool_call.
The registry does not check types or field paths below the top level of event. A typo such as
event.tool.inptu is accepted and then fails when the rule runs.
Deny a shell command pattern
This rule denies shell commands that run git push with --force or -f. Save the request as a
file:
{
"name": "deny-force-push",
"scope": "explicit",
"phases": ["tool_call"],
"rule": {
"kind": "expression",
"expression": "event.tool.name != \"mcp__orca__bash\" || !event.tool.input.command.matches(\"git +push.*(--force| -f( |$))\")",
"on_false": "deny",
"reason": "Force pushes are not allowed. Push to a new branch instead."
}
}Then create the guardrail:
guardrail=$(ork guardrails create --file deny-force-push.json -o json)
GUARDRAIL_ID=$(jq -r '.id' <<< "$guardrail")The first clause allows every tool other than the shell without reading its input. The pattern
checks the command text, so it misses forms such as git -C repo push --force.
blast_radius parses commands instead and
can ask before every push.
More examples
Each example is a complete request body for creating a guardrail.
Ask before writing environment files. ask needs tool_call as the only phase. The built-in
write and edit tools take the file's path:
{
"name": "approve-env-writes",
"scope": "explicit",
"phases": ["tool_call"],
"rule": {
"kind": "expression",
"expression": "!(event.tool.name in [\"mcp__orca__write\", \"mcp__orca__edit\"]) || !(has(event.tool.input.path) && event.tool.input.path.endsWith(\".env\"))",
"on_false": "ask",
"reason": "Writing an environment file needs approval."
}
}Allow only one model family. At Workspace scope, this rule denies any request from a session whose model is not a Claude Sonnet model:
{
"name": "sonnet-only",
"scope": "workspace",
"phases": ["request"],
"rule": {
"kind": "expression",
"expression": "has(event.model) && event.model.id.startsWith(\"claude-sonnet\")",
"on_false": "deny",
"reason": "This Workspace runs Claude Sonnet models only."
}
}Sessions on self_hosted environments do not report the model, so this rule denies every request
there.
Test a custom guardrail
Keep a new rule at explicit scope and attach it to one session with an agent_with_overrides
reference, as in Attach to a session. Send a
message that should trigger the rule, then read the session's events. A denied request produces a
session.error; a denied tool call produces a tool result with is_error: true that carries the
rule's reason. What a session shows
lists every outcome, and the ork local tutorial
runs this loop with a rule that denies every request. When the rule behaves as intended, widen its
scope with an update.
What's next
Builtin guardrails
The catalog of builtin guardrail types in Orca Agent Engine - what each type checks, its parameters, and the phases and verdicts it supports.
Enforcement
Which runtimes in Orca Agent Engine enforce each guardrail phase, what a session reports when a rule fires, and how AI Gateway applies the same rules.