Guardrails policy that can return DENY breaks sys_session_send with "requires parent session inbox"
Summary
An agent that declares a guardrails CEL policy whose expression can return
DENY can no longer dispatch sub-agents: every sys_session_send call fails
with
Error: sys_session_send requires parent session inboxThe policy is not denying the call. cel_policy evaluates ALLOW for that
exact event (verified below). What matters is only whether the expression is
capable of returning DENY anywhere — flipping the terminal branch from
DENY to ALLOW, with no other change, makes dispatch work.
A policy with no DENY branch — constant or evaluated — does not trigger this.
Neither does an agent with no guardrails at all.
Version: ghcr.io/omnigent-ai/omnigent-server@sha256:caa84ada76a34857e297f12d2a82b0cfb12ca03755df31c40d1d30bd37a3730e
(tag v0.12.0); runner is the omnigent CLI 0.12.0 (built 2026-09-01).
Harness claude-sdk, os_env.sandbox.type: none, async: true.
Minimal reproducer
Two bundles, 36 lines each, differing in one word.
min/config.yaml:
spec_version: 1
name: min_parent
description: Minimal reproducer for the deny-capable-policy dispatch bug.
executor:
type: omnigent
config:
harness: claude-sdk
skills: none
tools:
timeout: 300
agents:
- child
async: true
os_env:
type: caller_process
cwd: .
sandbox:
type: none
guardrails:
policies:
allowlist_then_deny:
type: function
"on": [tool_call]
function:
path: omnigent.policies.builtins.cel.cel_policy
arguments:
expression: >
event.type != "tool_call"
? {"result": "ALLOW"}
: has(event.data.name)
&& type(event.data.name) == string
&& event.data.name.matches("^(ToolSearch|sys_session_send|sys_read_inbox)$")
? {"result": "ALLOW"}
: {"result": "DENY"}
prompt: |
Diagnostic probe. Do exactly what you are asked and quote tool results verbatim.min/agents/child/config.yaml:
spec_version: 1
name: child
description: Child of the minimal reproducer.
executor:
type: omnigent
config:
harness: claude-sdk
skills: none
os_env:
type: caller_process
cwd: .
sandbox:
type: none
prompt: |
Answer briefly and literally.Start a session on that bundle and have the agent make one call:
sys_session_send(agent="child", title="ping", args={input: "reply exactly PING"})Actual: Error: sys_session_send requires parent session inbox
Expected: the child launches — which is exactly what happens if you change
the last line of the expression from {"result": "DENY"} to
{"result": "ALLOW"} and change nothing else:
{"task_id": "...", "kind": "sub_agent", "agent": "child", "title": "ping",
"status": "launching", ...}The policy allows the call
Evaluated with the same entry point the runtime uses
(omnigent.policies.builtins.cel.cel_policy, built from the parsed spec's
policy arguments) against a synthetic event of the shape the runtime delivers:
event = {"type": "tool_call", "target": "",
"data": {"name": "sys_session_send",
"arguments": {"agent": "child", "title": "ping",
"args": {"input": "hi"}}}}Verdict: ALLOW. Also ALLOW with arguments omitted and with
arguments: {}. So the failure is not a denial of this call.
Evidence matrix
Same dispatch call in every row; sessions created identically; only the policy differs.
| # | guardrails | expression | dispatch |
|---|---|---|---|
| 1 | none | — | works |
| 2 | present | constant {"result": "ALLOW"} |
works |
| 3 | present | evaluated, reads event.*, every branch ALLOW |
works |
| 4 | present | allowlist by name, terminal DENY |
fails |
| 5 | present | row 4 with terminal DENY → ALLOW |
works |
Rows 4 and 5 are byte-identical apart from that one word.
Reproduced on a real 300-line bundle as well, in three topologies — a session
created from the web UI, a session started by omnigent run from a local
bundle directory, and a sub-session created via sys_session_create. Identical
failure in all three, so it is not topology-specific.
Ruled out
- Not the verdict for the dispatch call (shown above).
- Not the tool-name allowlist: adding
sys_session_createandsys_call_asyncto the allowed names changes nothing. - Not the extra
reason:argument tocel_policy: rows 2 and 5 keep it. - Not bundle size, sub-agent count,
skills, ortools.timeout: rows 4 and 5 hold all of those constant.
Code pointers
The error is raised at runner/tool_dispatch.py:2034:
if session_inbox is not None:
_runner_app._session_inboxes_ref.setdefault(conversation_id, session_inbox)
elif conversation_id not in _runner_app._session_inboxes_ref:
return "Error: sys_session_send requires parent session inbox"The inbox is created in runner/app.py:3358 (_initialize_session, reached
from POST /v1/sessions). Why a deny-capable policy routes the call to a place
where session_inbox is None and the session is absent from
_session_inboxes_ref was not obvious from reading. _spawn_async_tool, which
deliberately passes session_inbox=None for non-terminal tools
(tool_dispatch.py:7509), serves sys_call_async only and is not on this path.
Impact
Any agent that both enforces a real guardrails boundary and orchestrates sub-agents is impossible today: the two features are mutually exclusive. The boundary has to be dropped for the orchestration to work, which is the opposite of the safety trade one would want — orchestrators are exactly the agents worth constraining.
The failure is also silent in the sense that matters: the bundle loads, validates, registers, opens a session and answers. It breaks only at the first dispatch, in whatever session a user happens to start.
Source: omnigent-ai/omnigent