CodeDocs Vault

Policies & Governance Engine

Omnigent is a meta-harness: it wraps an underlying coding agent (Claude Code, Codex, Cursor, Hermes, Qwen, Goose, OpenCode…) and orchestrates it. The policy engine is the layer that sits between the LLM's intent and the world. Every risky action — a shell command, a GitHub write, an LLM call that would burn the budget — passes through a gate that returns one of three verdicts:

That three-verdict contract is the whole governance story. Spend caps, tool allowlists, PII scanners, approval gates — they are all just callables that return one of those three words. This doc covers how policies are defined, layered across server / agent / session scope, evaluated, and enforced against a wrapped agent that Omnigent does not directly control.


1. The shape of a policy

A policy is a Python callable referenced by a dotted import path. It takes one event dict and returns a decision dict (or None to abstain).

# docs/POLICIES.md:397
def my_policy(event: PolicyEvent) -> PolicyResponse | None:
    if event["type"] != "tool_call":
        return None                       # abstain on other phases
    if event["data"]["name"] == "dangerous_tool":
        return {"result": "DENY", "reason": "This tool is blocked."}
    return {"result": "ALLOW"}

Two callable forms exist (omnigent/policies/schema.py:297-331):

The event dict carries everything a policy might gate on (omnigent/policies/schema.py:168-225): the type (phase), the target tool name, the data payload, plus a context block with the actor identity (run_as email, OAuth client_id), cumulative usage (total_cost_usd, tokens), the active model, and a read-only labels snapshot. The response can also carry state_updates (a running counter, an extracted entity) and set_labels (persisted conversation flags) — see §6.

Policies are declared in YAML, never code, by the people who consume them. The agent-spec form (docs/POLICIES.md:91-111):

policies:
  limit_tool_calls:
    type: function
    handler: omnigent.policies.builtins.safety.max_tool_calls_per_session
    factory_params: { limit: 100 }
  github_access:
    type: function
    handler: omnigent.policies.builtins.github.github_policy
    factory_params:
      write_repos: [myorg/my-repo]
      write_branches: ["feature/*"]

2. The six enforcement phases

Policies fire at six points in the agent loop (omnigent/spec/types.py:1062-1101). A policy declares which it cares about via on:, or self-selects by abstaining.

Phase When Can block? Gates…
request new user message, before LLM yes PII in prompts, prompt-injection
llm_request before each LLM round-trip yes model routing, prompt budget
llm_response after raw model output yes output filtering
tool_call before dispatching a tool yes (authoritative) allowlists, approval gates, spend
tool_result after a tool returns observational output redaction
response final assistant message yes leak scanning

tool_call is the one that matters most for risky actions, and the only phase that fails closed (omnigent/policies/types.py:42-55): if the verdict can't be obtained, the call is denied. The other phases fail open — for tool_result the side effect already happened, so denying would only block an already-incurred consequence.


3. Policy types (builtins)

Omnigent ships a builtin library under omnigent/policies/builtins/. They map cleanly onto the governance categories the prompt asked about:

Category Policy Behavior File
Approval / pause gate ask_on_os_tools ASK before any file/shell tool, across every harness's tool names omnigent/policies/builtins/safety.py:108
Approval / pause gate ask_on_add_policy ASK before the agent edits its own policies (always on) omnigent/policies/builtins/safety.py:175
Spend cap cost_budget ASK at soft thresholds; downgrade-gate at the hard cap omnigent/policies/builtins/cost.py
Spend cap user_daily_cost_budget Same, but the cap is the owner's spend across all their sessions today omnigent/policies/builtins/cost.py
Rate limit max_tool_calls_per_session DENY after N tool calls omnigent/policies/builtins/safety.py:68
Tool denylist block_skills DENY loading named skills omnigent/policies/builtins/safety.py:218
Tool allow/denylist github_policy Read-allowlist + write repos/branches; ambiguous shell git/gh → ASK omnigent/policies/builtins/github.py
Tool allow/denylist gdrive_policy / gmail_policy / gcalendar_policy Per-scope read/write/send grants on Google Workspace MCP omnigent/policies/builtins/google.py
Data guard deny_pii_in_llm_request Scan for SSN/CC/email/phone; DENY or ASK omnigent/policies/builtins/safety.py
Sandbox enforcement enforce_sandbox Force a sandbox backend / network / write paths on start omnigent/policies/builtins/safety.py
Working-dir lock block_working_dir_changes Block cd/pushd/git -C/worktree, parsing chained commands omnigent/policies/builtins/working_dir.py
Risk accrual risk_score_policy Accumulate a per-session score; escalate guarded tools to ASK/DENY over threshold omnigent/policies/builtins/risk_score.py
Model routing deny_trivial_to_expensive_model LLM-classify the task; DENY trivial tasks on expensive models routing.py

A custom policy becomes usable by exporting a POLICY_REGISTRY list from a module and registering that module in the server config (docs/POLICIES.md:468-501).


4. Scope, precedence & the trust model

Policies are set at three scopes, each owned by a different persona, and they layer in a fixed order. This is the heart of the governance design. From docs/images/policy-trust-model.png:

                                    ┌──────────┐
                                    │ IT Admin │  IT Policy (server-wide)
                                    └────┬─────┘
   User Policy                          │
   (session)                            ▼
   ┌──────┐   request   ┌──────────────────┐  tool call   ┌────────┐
   │ User │ ──────────▶ │  Agent Session   │ ───────────▶ │ Target │
   └──────┘             └──────────────────┘              └────────┘
                          ▲              ▲
        Developer Policy  │              │  malicious injection
        (agent spec)  ┌───┴────┐   ┌─────┴──────┐
                      │ Agent  │   │ Content in │
                      │ Dev    │   │  Session   │
                      └────────┘   └────────────┘

The trust model names four parties that influence a session and one adversary:

Precedence

The builder merges the three layers in session → agent-spec → admin order (omnigent/runtime/policies/builder.py:240):

all_policy_specs = session_policy_specs + agent_policy_specs + admin_policy_specs
all_policy_specs.append(_ASK_ON_ADD_POLICY_SPEC)   # omnigent/runtime/policies/builder.py:246

The engine then evaluates that list in order, and a DENY from any policy short-circuits the rest (omnigent/runtime/policies/engine.py:307). The ordering means:

Crucially, ordering composes verdicts but does not let a looser layer override a stricter one: because any DENY short-circuits, a later admin DENY still blocks an action an earlier session policy allowed, and an earlier DENY blocks before admin runs. Every layer can only tighten. The single unconditional addition — ask_on_add_policy appended last — guarantees the agent can never silently edit its own policy set (omnigent/runtime/policies/builder.py:242-246); sys_add_policy always pauses for a human.

Sub-agents inherit the root session's policies, prepended before any child-specific ones (omnigent/runtime/policies/builder.py:224-238), so a guardrail set on the top-level session governs the whole spawn tree — an agent can't escape a gate by spawning a child.


5. How the engine evaluates one phase

PolicyEngine.evaluate(ctx) (omnigent/runtime/policies/engine.py:222-349) is a single linear pass — no priority queue, no rule DSL:

  1. Inject context once: trajectory, session_state, usage, model, labels, llm_client (omnigent/runtime/policies/engine.py:287-293).
  2. For each policy, in declaration order:
    • Skip if its on: selector or condition: label-gate doesn't match (_should_fire, omnigent/runtime/policies/engine.py:296). The label-gate is a cheap way to skip an expensive policy unless a prior policy set a flag.
    • Dispatch → get a single-policy PolicyResult.
    • Accumulate its label writes and state updates.
    • DENY → short-circuit immediately (omnigent/runtime/policies/engine.py:307-314), applying accumulated writes and returning.
    • If it returned transformed data (e.g. PII-redacted args), feed that forward as the next policy's input (omnigent/runtime/policies/engine.py:315-319) — a small content-transform pipeline.
    • ASK → accumulate the reason, keep going (omnigent/runtime/policies/engine.py:320-324).
  3. After the loop: if any policy ASKed, return a composed ASK with all reasons joined — and withhold all label/state writes until the human approves (omnigent/runtime/policies/engine.py:326-339). A denied ASK leaves no trace.
  4. Otherwise apply writes and return ALLOW (omnigent/runtime/policies/engine.py:340-349).

One contract, every harness The same tool_call event shape is produced whether the underlying agent is Omnigent's own loop, Claude Code, Codex, or Cursor. The ask_on_os_tools policy lists six different harnesses' tool-name families (omnigent/policies/builtins/safety.py:108-129) — sys_os_shell, Bash, Shell, terminal, lowercase bash… — so one policy gates file/shell access no matter which wrapped agent is running. The policy author writes the rule once; the harness adapters normalize into it.


6. Enforcing against a wrapped agent

Omnigent's hard problem: the policy engine runs in the Omnigent server, but the dangerous tool call is dispatched inside an underlying agent process (Claude Code, Codex, Cursor) that Omnigent doesn't control. The tie-in is the underlying harness's native permission-hook system.

Each supported harness exposes a pre-tool hook. Omnigent ships a thin hook subprocess per harness (omnigent/claude_native_hook.py, omnigent/codex_native_hook.py, omnigent/inner/cursor_policy_hook.py, *_native_permissions.py) that the executor wires into the harness config at session start. On every tool call the harness invokes the hook, which POSTs the tool call back to the Omnigent server's /v1/sessions/{id}/policies/evaluate endpoint and translates the verdict into the harness's own permission vocabulary.

The shared translation lives in omnigent/native_policy_hook.py:

Two enforcement subtleties are worth lifting out:

ALLOW returns "no opinion," not "allow" (omnigent/native_policy_hook.py:160-170). On a PreToolUse ALLOW the hook returns None, so the harness's own permission prompt still fires. Emitting an explicit "allow" would auto-approve the tool and suppress the user's native consent prompt — collapsing two independent gates (the deployment's policy gate and the user's own consent gate) into one. The policy layer may block but must never silence the human.

ASK never reaches the hook as ASK. ASK is resolved server-side: /policies/evaluate parks the HTTP request (long-poll, up to a day), publishes an approval card to the web UI, and returns a hard ALLOW or DENY once the human responds. If a stray ASK ever does reach the hook it fails closed to deny, not the old defer — because defer handed control back to the harness's permission_mode, which acceptEdits / bypassPermissions would auto-approve, re-opening the very bypass the gate exists to close (omnigent/native_policy_hook.py:209-232).

The fail-closed default is phase-aware and duplicated on both sides so they can't drift: PreToolUse denies on an unreachable server; UserPromptSubmit / PostToolUse fail open (omnigent/native_policy_hook.py:247-287, mirroring FAIL_CLOSED_PHASES in omnigent/policies/types.py:55). The hook retries transient 5xx/connect errors for 30s, re-sending a stable elicitation id so a dropped connection re-attaches to the same approval card rather than prompting the human twice (omnigent/native_policy_hook.py:290-382).

flowchart TD
    A["Wrapped agent emits tool call<br/>(Claude/Codex/Cursor)"] --> B["Native PreToolUse hook"]
    B --> C["POST /v1/sessions/{id}/policies/evaluate"]
    C --> D["PolicyEngine.evaluate<br/>(session → spec → admin, in order)"]
    D --> E{Composed verdict}
    E -- ALLOW --> F["Hook returns None →<br/>harness's own consent prompt still runs"]
    E -- DENY --> G["permissionDecision: deny<br/>agent gets error string"]
    E -- ASK --> H["Server parks request,<br/>publishes approval card to web UI"]
    H --> I{Human decides}
    I -- accept --> J["Hard ALLOW + apply withheld<br/>label/state writes"]
    I -- decline / timeout --> K["Hard DENY, no side effects"]
    C -. server unreachable .-> L{"Phase fail-closed?"}
    L -- tool_call --> G
    L -- request / result --> F

7. Audit & trace

Omnigent does not write a dedicated "policy decision log," but policy decisions are observable and the ASK flow is persisted:

The raw material for an audit trail is all there (per-decision events, durable elicitation rows, deciding-policy on every DENY); what's missing is a single queryable "policy ledger" view aggregating them.


8. The injection guard you don't see

The most easily-overlooked safety property is in the registry (omnigent/policies/registry.py:154-189). A user supplies a policy as a dotted import path — which is, on its face, arbitrary-code-execution by config: nothing stops subprocess.Popen or builtins.exec from being named as a "policy handler." is_registered_handler is the guard: a handler may be attached only if some module's POLICY_REGISTRY exported it. The registry is the allowlist. It's enforced at every untrusted entry point — the session/admin policy write APIs and the agent-bundle upload validator — but deliberately skipped for trusted local omnigent run, which keeps supporting ad-hoc custom handlers. The threat model is precise: a remote user can pick from the catalog, a local operator can write new entries.


9. The cleverest trick: the spend cap that downgrades instead of stops

cost_budget (omnigent/policies/builtins/cost.py) gates a session on cumulative LLM spend at two layers:

So the budget never bricks a session mid-task — it nudges the work onto cheaper inference and lets it continue. The expensive-model match is by case-insensitive substring token ("opus", "gpt-5") so it catches any deployment of a tier (docs/POLICIES.md:233). And because the soft-ASK approval is recorded against the root conversation (and, for the daily variant, against the user+day), approving once on a parent agent carries to every sub-agent and across the user's sessions — no re-prompting at the same threshold (omnigent/policies/schema.py:88-98, omnigent/runtime/policies/engine.py:510-565).

This is a strong candidate insight card: a guardrail that degrades gracefully (route to cheaper) instead of failing hard (kill the run) is far more usable, and far more likely to be left enabled.


10. For your projects

For your project — EU AI Act compliance platform: This engine is almost a reference implementation of three AI Act duties:

For your project — Swisscheese: The native-hook tie-in is the pattern Swisscheese needs to gate risky actions of agents it doesn't own. You don't have to modify the underlying coding agent — you inject a PreToolUse hook that phones home to a central policy server and translates the verdict into the harness's permission vocabulary (omnigent/native_policy_hook.py). Note the two load-bearing design choices: (1) the gate fails closed on tool_call when the server is unreachable (omnigent/policies/types.py:55), and (2) a stray ASK fails closed to deny rather than defer, because defer would let bypassPermissions auto-approve — a subtle bypass that's easy to get wrong (omnigent/native_policy_hook.py:209-221). For a writer → reviewer → fixer pipeline, the same engine could gate the fixer's writes behind a reviewer-approval ASK.


11. Design observations

Good ideas:

Potential pitfalls: