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:
- ALLOW — the action proceeds.
- DENY — the action is blocked; the agent gets an error string.
- ASK — the action is paused for a human, who approves (→ ALLOW) or refuses (→ DENY).
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):
- Direct callable — receives the event, returns a verdict.
- Factory — a function called once at build time with
factory_params, returning the evaluator. This is how a rate limit gets itslimit, or a budget itsmax_cost_usd.
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:
- IT Admin sets server-wide policy — organizational guardrails on every session.
- Agent Developer ships agent-spec policy in the agent YAML.
- User adds session policy at runtime (UI toggle or by telling the agent in chat).
- Content in Session is the untrusted party — a malicious injection riding in a web page or a tool result, trying to steer the agent. The policy layer is precisely the defense against an injection successfully ordering a risky tool call.
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:246The 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:
- Session policies run first — a user can add a stricter gate to
their own task and have it short-circuit before the developer's or
admin's policies even run (
docs/POLICIES.md:19-21). - Admin policies run last — they get the final say on anything the earlier layers ALLOWed. This is the "organizational floor."
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:
- Inject context once: trajectory, session_state, usage, model,
labels, llm_client (
omnigent/runtime/policies/engine.py:287-293). - For each policy, in declaration order:
- Skip if its
on:selector orcondition: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).
- Skip if its
- 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. - 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:
hook_payload_to_evaluation_requestmapsPreToolUse→PHASE_TOOL_CALL,PostToolUse→PHASE_TOOL_RESULT,UserPromptSubmit→PHASE_REQUEST(omnigent/native_policy_hook.py:62-139).evaluation_response_to_hook_outputmaps the verdict back (omnigent/native_policy_hook.py:142-244).
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:
- Elicitations are durable rows. Every ASK registers a row in the
pending_tool_callstable with the__elicitation__sentinel and the request params stored in theargumentscolumn "for inspection / replay" (omnigent/runtime/policies/approval.py:1-34, 54-77). The verdict arrives by PATCH and wakes the parked workflow. So every human approval/refusal has a recorded artifact tied to a conversation. - The ASK is an SSE event.
_await_elicitationemits aresponse.elicitation_requestevent whose shape mirrors MCP'selicitation/createverbatim (omnigent/runtime/policies/approval.py:11-30), and the engine exchangespolicy_evaluation.requested/ verdict events across the harness boundary (omnigent/runtime/harnesses/_scaffold.py:633-684) — both correlated by a stableevaluation_id. - DENY reaches the conversation. A composed DENY carries the
deciding_policyname and a human reason (omnigent/policies/types.py:200-216); the reason is surfaced to the agent and user (omnigent/native_policy_hook.py:230,omnigent/inner/cursor_policy_hook.py:91-94) and logged on the runner side. - Approved/denied ASKs respect a side-effect invariant: label and
state writes are applied only on approve (
omnigent/runtime/policies/engine.py:326-339,omnigent/runtime/policies/approval.py:22-25), so the persisted state never reflects an action a human refused — clean traceability of what was actually permitted.
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:
- Soft thresholds ASK the first time spend crosses each checkpoint
(
ask_thresholds_usd), once per checkpoint, surfaced as an approval card. - The hard limit is not a stop — it's a downgrade gate. Once
max_cost_usdis reached, the policy DENYs the turn (request phase) or tool call only while the session is still on an expensive model (omnigent/policies/builtins/cost.py:22-27). The DENY reason tells the user to switch to a cheaper model with/model. Once they switch off an expensive deployment, the same actions are allowed again.
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:
- Article 14 (human oversight). The ASK verdict is human-in-the-loop:
the action is paused, a human approves or refuses, and the side
effects of a refused action are guaranteed to never apply
(
omnigent/runtime/policies/engine.py:326-339). The "ALLOW returns no-opinion so the user's own consent prompt still fires" rule (omnigent/native_policy_hook.py:160-170) is exactly the principle that an automated allow must not remove a human control point. - Article 12 (logging & traceability). Durable elicitation rows
with stored request params (
omnigent/runtime/policies/approval.py:54-77), correlated evaluation events (omnigent/runtime/harnesses/_scaffold.py:633-642), anddeciding_policyon every DENY give you the per-decision record. To be audit-grade you'd add the missing piece: a single append-only ledger of (action, verdict, deciding_policy, actor, timestamp). - Article 9 (risk management).
risk_score_policyis a literal risk-accrual control — score sensitive actions and data labels, escalate to ASK/DENY past a threshold — the kind of layered risk-mitigation a Article 9 system must document.
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:
- Three verdicts, one contract. ALLOW/DENY/ASK as the entire surface keeps policies trivial to write and compose. A spend cap and a PII scanner are the same kind of object.
- Layering that only tightens. Session → spec → admin with any-DENY-short-circuits means no layer can loosen another's rule, and the user can always add a stricter gate to their own task.
- Harness-neutral enforcement. One policy gates six harnesses because the adapters normalize into a single event shape.
- Registry-as-allowlist. Turning "dotted import path" from an RCE vector into a curated catalog with one shared check.
- Graceful degradation. The budget downgrades the model instead of killing the run.
Potential pitfalls:
- No unified audit ledger. The events and rows exist but aren't aggregated into one queryable trail — a gap for compliance use.
- Fail-open on advisory phases.
request/tool_resultfail open on a server outage. Defensible, but a deployment that relies on a PIIrequestgate should know it's best-effort. - ASK depends on a reachable, responsive web UI. The gate long-polls for up to a day; if no human ever answers, the timeout maps to DENY (safe) but the agent stalls until then.
- Trusted local path skips the registry check.
omnigent runaccepts arbitrary handlers by design — fine locally, but a foot-gun if that path is ever exposed to less-trusted config.