PolicyEnforcer that returns a typed Decision.
Deny-by-default; the policy file lists what’s allowed.
allow— the original tool runs unchanged.deny— the framework sees a[policy_denied]marker as the tool result; the model can recover.approval_required— calls a dev-supplied handler, or returns an[approval_required]marker (see approval-required tool calls).
decide resolves it internally from the active request
context via a contextvar.
Where enforcement happens
Enforcement sits at the tool boundary. When you wrap or load an agent, each tool is replaced with a guarded version that runs the enforcer before the real tool:- The model emits a tool call; the framework dispatches it.
- The guarded tool asks the enforcer to decide, passing the tool name and its arguments. The caller’s role isn’t a parameter — it’s resolved from the active request context (a contextvar), so the call site only supplies the tool name and arguments.
- The enforcer evaluates the call against the active policy. Two engines back
this — an in-process pydantic engine (the default) and a WASM engine (what
production runs) — and both return the same
Decision. allow→ the original tool runs.deny→ the model sees a[policy_denied]marker as the tool result.approval_required→ the dev-supplied handler is called, or an[approval_required]marker is returned.- At the start of every turn — each agent run (one
ainvoke/astream_eventscall, typically handling a single user message) — the policy is refreshed once, up front. If it changed, the new version takes effect on the tool calls in that run — no restart, no re-wrapping.
HEXGATE_LOCAL_POLICYset → a local YAML file or bundle directory, refreshed on file changeHEXGATE_API_KEYset, no local override → the platform, refreshed via ETag /304 Not Modified- Neither → no refresh; enforcement uses whatever was loaded once
What the per-turn refresh covers
Only the policy. Thesystem_prompt, the manifest’s tool list, and the model id
are read once at agent construction and stay fixed for the life of the process.
Edit those on the dashboard and the change lands at the next hexgate serve
restart — not at the next turn. That split is intentional: policy is the
operator’s live, per-decision-auditable lever, while the manifest (tools, model,
prompt) is owned by your code.
approval_required without a handler
- No handler attached — behaves like a graceful block: the tool returns a
structured
ok: Falseresult witherror_type: "approval_required"so the agent can try a fallback instead of crashing. - Handler attached (via
enforce_policy(..., approval_handler=...)) — the host decides whether to allow the action at runtime.