constraints: list of string expressions evaluated against
the call’s arguments. Every constraint must pass for the call to authorize
(implicit AND).
New here? Start with Writing conditions — a
guided, example-first walkthrough. This page is the terse reference to look
things up once you know the shape.
Types are compared strictly, matching Rego: a boolean is not a number, so
true == 1 is false and true in [1] is false (unlike Python’s True == 1).
Cross-type ordered comparisons ("x" > 10) never pass — they fail closed.
Each side of a comparison is an operand — a literal, a field path, or
count(...):
A bare unquoted word on the right is read as a field reference, so a
forgotten-quotes typo (
args.x == USD) compares against the field USD
(usually absent → deny) rather than the string "USD". Quote strings.
Whole-line string functions take a field and a string literal:
Quantifiers constrain the elements of a list-valued argument.
. is the
current element (.field for a sub-field), usable in any condition:
Element sub-fields and nesting work too:
. is only valid inside
a quantifier body.
Besides args.*, constraints can reference two call-scope facts: role
(the caller’s role) and tool (the tool being invoked) — e.g.
role == "admin" or tool == "refund_order". These mirror Rego’s
input.role / input.tool. Two further namespaces follow below: ctx.* (who
is calling) and run.* (what this invocation has done so far).
Caller attributes (ctx.*)
Constraints can also filter on the caller’s attributes — an open bag set on
the request scope (HexgateContext(attributes={...})) — through the ctx.<key>
namespace, alongside args.* / role / tool:
ctx.<key> fails closed (deny), like any absent reference.
ctx.* may gate allow/deny decisions — but it carries the same trust
contract as role: populate HexgateContext.attributes from trusted
server-side data (your auth/session/IdP), never from raw client input. An
attribute is exactly as trustworthy as the code that set it.
Run facts (run.*)
Everything above describes this call. run.* describes the invocation so
far — how many tools the agent has already run, how long it has been going,
how many tokens it has spent. It is what a runaway agent is stopped with.
Facts are accumulated in-process by the SDK, so they are always available and
never need a network call. A run starts at the adapter boundary (ainvoke,
run, run_sync, astream_events, …) and ends when it returns.
The 13 paths
run.tools_used is list-valued, so read it with count(...), any(...) or
every(...). A typo’d path, a deeper path (run.id.value), or a list path used
where a scalar belongs are all rejected when the policy loads rather than
denying silently at runtime.
What the counters mean
These distinctions change what a cap does, so they are worth reading once:- A tool counts when it is dispatched, after the decision has allowed it.
run.tool_calls < 20permits exactly 20 executions, in parallel as well as in sequence. - A denied call consumes no tool budget. It counts toward
run.denialsinstead, which you can bound separately — otherwise an agent misbehaving against one tool would exhaust a legitimate caller’s budget. - An approval gate counts when it fires, not when it is granted. An
approval that is refused adds to
run.approvalsand nothing else. - A guard halt before the tool is a denial; the tool never ran. A guard halt after the tool counts both: the call, because the side effect already happened and only the result is withheld, and a denial, because a post-guard halt is a refusal like any other.
- A tool that raises counts as both a call and an error.
Where facts aren’t collected
In a few places a decision happens outside any run scope. There, every counter reads zero andrun.id is "" — so a counter cap passes. This is
deliberate: an unwired boundary should not brick the agent. The cases:
- Network egress. The egress proxy is one per process and sits above every run, and a tool’s outbound connection carries no invocation id to correlate back, so egress decisions record nothing.
- Methods that bypass the wrapper, such as LangChain’s
agent.batch(). - Tools that dispatch guarded work to a raw thread or thread pool.
asyncio.gather,asyncio.to_threadand LangChain’s own executor all carry the run correctly; only a hand-rolledthreading.Threadloses it.
What a denied model is told
On arun.* denial the model sees the constraint text — run.tool_calls < 20
— so it learns the threshold and that it was crossed. It is never told the
current counter value, and the run’s id and counters are not in the payload the
model receives. Budget pressure is deliberately not surfaced to the agent.
One more, if you consume streams: two astream() generators consumed
round-robin in the same task will cross-contaminate, because Python has no
per-generator context. Consume one at a time, or put each in its own task. (This
already affects caller identity via HexgateContext, so it isn’t new to
run.*.)
Where a constraint can live
Three places, widening in reach:default_policy.constraints is the common trap. It is the catch-all policy,
not a floor: once a tool is listed under tools:, the default no longer applies
to it, so a run cap written there silently misses every tool you named.
mode: deny is denied
before constraints are consulted, so nothing here can grant access.
args.* at policy level reaches admission and reach
“Every tool the role can reach” includes the synthetic keys admission: and
agents: lower to — agent.run, plus agent.handoff:<target> and
agent.tool:<target>. Those gates pass only their own arguments (args.agent
for admission; args.agent / args.target / args.via for reach), so an
args.* constraint written with tools in mind reads a missing field there, and
a missing ref fails closed: the agent is refused before it starts, and every
handoff and agent-as-tool call is denied.
When you do want one argument fenced role-wide, exempt the whole reserved
namespace — tool is a fact like any other, and the grammar has or:
tool == "agent.run": naming the admission key alone still
leaves agent.handoff:<target> and agent.tool:<target> denied.
Inheritance unions them
Every other field overrides throughinherits: — a child’s tools entry
replaces its parent’s. Policy-level constraints are the exception: they
accumulate, so a child cannot drop a fence a mixin declared.
Policy-level constraints belong in a single-file policy — flat, or role-keyed
under
roles:, where a top-level block unions into every role. Composed module
policies (policies/boundaries/, policies/capabilities/) reject them at
load: the fold composes per-tool rules only, and silently dropping a
role-wide fence would be fail-open.Upgrading
Top-levelconstraints: is new. Single-file policies ignore keys they don’t
recognise, so before this release the block parsed and was silently dropped —
after it, the same YAML is enforced against every tool the role can reach. If
you wrote one speculatively, it starts denying on upgrade, and the bundle’s
rego_hash and wasm_hash change with it. Note that source_hash does not
move — it is the sha256 of your policy’s raw YAML bytes, which the upgrade
leaves untouched, so diffing it will tell you nothing changed. Grep your
policies for a top-level constraints: before you roll forward — hexgate policy show-rego prints the compiled rules if you want to diff exactly what
moved. (Composed module policies are unaffected: they rejected the key outright,
then and now.)
Role-keyed documents activate one release later. A document with a top-level
roles: key validated only what sat inside each role, so a constraints:
sibling was dropped there even after the release above — including a malformed
expression, which hexgate policy validate could not see either, since there was
nothing left to validate. It now unions into every role, so the same upgrade note
applies: grep role-keyed policies too. Any
other unrecognised sibling of roles: is now a load error rather than a silent
drop, naming the key and telling you to move it inside a role.
Named constants
Define reusable values once in aconsts: block and reference them as
consts.<name> — a number, string, or list:
inherits: like tools, so shared constants belong in
a mixin. A consts.<name> that isn’t defined is rejected at build (and denies
on the pydantic engine); across roles a name must map to a single value.
Boolean composition
Combine conditions withand, or, not, and parentheses:
or < and < not; use () to group. Multiple
constraint lines are still AND-ed, so and is only needed to combine with an
or. (Boolean ops inside a quantifier body — e.g.
every(args.x, .a == 1 or .b == 2) — aren’t supported yet.)
End to end
With thisbilling role policy and async with HexgateContext(user_id="alice", user_roles=["billing"]):
refund_order(amount=200, currency="USD")→ ✅ allowedrefund_order(amount=600, currency="USD")→ ❌ denied — constraintargs.amount <= 500refund_order(amount=200, currency="EUR")→ ❌ denied — constraintargs.currency == "USD"wire_transfer(amount=50000)→ ✋ requires approval (mode =approval_required)
user_roles=["default"] and refund_order is missing from that policy — it
falls through to default_policy.mode (deny).
Constraints are Rego-compatible by design: the WASM engine compiles them to OPA
Rego unchanged, and the pydantic engine evaluates the same strings in-process —
both produce identical decisions (there’s a parity test suite that proves it).