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
ToolCall — tool_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.