Skip to main content
Every tool call in Hexgate already passes one check: decide, the policy verdict that answers allow / deny / needs-approval. Guards are the open extension point next to it — small functions you attach before and after a tool call to do everything a yes/no rule was never meant to express: strip a secret out of the arguments, rate-limit a tool per user, watch a result before the model reads it, add metrics. A tool call runs through four steps:

What a guard can do

  • Observe. Just look — log it, count it, emit a metric. Change nothing.
  • Rewrite the arguments (before-guards only). Strip a credential so the tool never receives it; the cleaned call is what runs.
  • Halt. Refuse the call and hand the model a short, safe reason instead.
A before-guard sees the ToolCall; an after-guard also sees the ToolOutcome (the return value, or a raised error). Result rewrite is not in v1 — after-guards observe or halt.

The one rule that is a security property

Before-guards run before decide, so decide always authorizes the exact arguments that will execute. If a guard cleans the arguments, the policy checks the cleaned version. A guard can only ever narrow a call — tweak it or refuse it — it can never slip something past the policy, because decide still runs on whatever the guard produced.

Authoring

Write a guard with @before_tool or @after_tool, then hand the agent one flat guards list — each guard already knows whether it runs before or after, so the list stays flat.
The decorators are dual-form — bare (@before_tool) or called (@before_tool(tool_names=..., observe=...)) — and double as inline wrappers (guards=[before_tool(lambda call: ...)]). Position is the decorator, reach is tool_names (a name or list; default every tool), and observe=True marks a fail-open watcher.

What a guard returns

What a guard receives

ToolCalltool_name, args (a read-only JSON mapping), agent_name, context (the active HexgateContext), and scratch (a per-call dict shared from a before-guard to an after-guard). ToolOutcome (after only) — ok, value (the return), and error (the stringified exception when the tool raised, so a watcher sees a failure the same way it sees a result).

The halt message: safe by construction

Halt.reason is the only field the model sees. Name the rule and category, never the offending input — a leaked value both exposes the secret and hands the model a substring to obfuscate and resend in a loop. Halt.detail carries the specifics on the operator/audit channel only, and the rendered refusal is marked retryable: false. Pick guards whose reason names a fix (“remove the credential”) over dead walls (“this is blocked”) so the model reworks the call in one step.

Error tiers: fail-closed, or observe

A guard that can rewrite or halt is a security control, so it fails closed: if it raises, the call is denied — a crash inside the trust boundary must not become a silent allow. An observe guard fails open: a raise is swallowed and logged, and in exchange it may neither rewrite nor halt. Safe by default; loose only when you say so.

Guards across frameworks

The same guards attach wherever your agent lives — the guard itself never changes, only the one call that installs it. Guards run through the same shared pipeline on every framework, so behavior is identical; only how a refusal reaches the model differs (a tool-result string on most, a ModelRetry on Pydantic AI), and Hexgate handles that. See the adapters overview for each wrapper.
guards= is always Hexgate’s argument. The only hooks= on the surface is the OpenAI/Google HexgateRunner.run(hooks=...), which is the framework SDK’s own run-lifecycle RunHooks — a different thing. Rule of thumb: if it says guards, it’s Hexgate’s; if hooks, it’s the framework’s.

Official plugins

hexgate.plugins ships ready-to-use guards built on one shared secret detector, so you can drop credential protection in without writing the matcher yourself.
secret_guard and secret_redactor are the two halves of the outbound case: pick per tool by whether a secret’s presence means the call is wrong (guard) or merely incidental and safe to strip (redactor) — don’t put both on the same tool, since the redactor would clean the arguments before the guard ever saw them. The refusal and the redaction record name the credential’s category and field, never the value:

What the detector matches

The detector is prefix-only in v1: high-confidence provider patterns (AWS, GitHub, OpenAI/Anthropic, Slack, Google, Stripe, Hexgate fty_, and PEM private keys). It does not guess at unrecognized secrets by entropy — a random-looking value is as likely to be a content hash or opaque ID as a secret, and a false positive on the fail-closed secret_guard would block a real call. That tradeoff (precision over recall for v1) is recorded in ADR R-GUARD-005. The detector primitives are exported too — scan_secrets, redact_secrets, safe_reason, SecretHit — for building a custom, scoped guard when you need one.