#6607·omnigent

Guardrails policy that can return DENY breaks sys_session_send with "requires parent session inbox"

Author: milega-external-issues-botCreated Sep 5, 2026Updated Sep 19, 2026
LabelsBugcomp:runnercomp:policiesP1-hightriagedvalidated:reproduced

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 inbox

The 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:

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:

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:

python
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 DENYALLOW 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_create and sys_call_async to the allowed names changes nothing.
  • Not the extra reason: argument to cel_policy: rows 2 and 5 keep it.
  • Not bundle size, sub-agent count, skills, or tools.timeout: rows 4 and 5 hold all of those constant.

Code pointers

The error is raised at runner/tool_dispatch.py:2034:

python
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.