Cloud and BYOC for Orca Agent Engine are in Private Preview — request an invite
Docs

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.

FieldTypeRequiredDescription
kindstringYesexpression.
expressionstringYesA CEL expression of 1 to 4096 characters that produces a boolean.
on_falsestringYesdeny, or ask when the guardrail's only phase is tool_call.
reasonstringNoUp 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

  • true allows and false applies on_false. A deny stops evaluation there. After an ask, 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 request and tool_call, an error denies with Guardrail "<name>" could not be evaluated. At tool_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:

FieldTypePresent
event.phasestringAlways. request, tool_call, or tool_result.
event.session.idstringAlways.
event.tool.namestringAt tool_call and tool_result.
event.tool.inputmapAt tool_call and tool_result. The tool's arguments, keyed as the tool defines them.
event.resultanyAt tool_result. The tool's output.
event.model.idstringThe 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.statemapShared 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(), and matches(), which takes an RE2 pattern and matches anywhere in the string.
  • in for list membership, and the macros has(), all(), exists(), exists_one(), map(), and filter().
  • Arithmetic, comparison, logical operators, and type conversions such as int() and string().

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 an event field outside the allowed set.
  • The guardrail has no phases, or sets on_false: "ask" with a phase other than tool_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:

deny-force-push.json
{
  "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

On this page