Skip to content
allsrc.dev
Go back

Pattern: The HITL Approval Gate

An accounts-payable agent reads an invoice email and calls wire_transfer(to_account="ACME-9921", amount=18500).

The tool is authorized — finance agents exist to move money — so a privilege broker rightly lets it through. The question is not may it. It is should it, this time: is that invoice real?

The broker cannot know. A human can, in about four seconds.

TL;DR

The naive fix — “make the agent ask in the chat” — fails twice. The person in the chat is often not the person with authority, and a run that blocks a thread waiting for a CFO is a run that times out and retries.

So: pause the run, don’t block it. The gate returns PAUSE, the harness parks the run, and the request lands in a queue with a fingerprint of (principal, tool, arguments). A human decides out of band. When the run resumes and the model proposes the same action, the fingerprint matches the verdict.

The property that makes this trustworthy: the human approved an action, not a session. Approve $18,500 and the model comes back asking for $48,500 — the fingerprint misses and the gate pauses again.

OWASP: ASI02, ASI05, and ASI09 (Human-Agent Trust Exploitation) — the gate is where over-trust gets a checkpoint, and also where approval fatigue becomes the attack surface.

Runnable code: patterns/governance/hitl_approval_gate/

The cycle, as program output

uv run python -m patterns.governance.hitl_approval_gate.demo
=== WITHOUT the pattern: $18,500 moves on the model's say-so ===
  final: Done — I transferred $18,500 to ACME-9921 to settle the invoice.

=== WITH the pattern: the run pauses on a fingerprinted request ===
  status: paused
  parked on: awaiting human approval: amount 18500 USD exceeds the auto-approve
             limit of 10000 USD (ref 0fd23a106bab74fa)

--- a human approves (out-of-band: Slack, portal, ticket) ---
  status: completed

--- alternate history: the human rejects instead ---
  tool result -> DENIED by policy: a human reviewer rejected this action
  final: A reviewer rejected the transfer — I've flagged the invoice for manual processing.

Note the last line. The rejection comes back as a tool result, so the agent re-plans rather than failing opaquely.

The pattern

store = InMemoryApprovalStore()          # a durable table in production
gate = HITLApprovalGate(
    [GateRule("wire_transfer", above("amount", 10_000, unit=" USD"))],
    store,
)
harness = Harness(model, tools, hooks=[broker, gate])

Design decisions worth arguing about:

1. Approve actions, not sessions. The fingerprint binds the verdict to exact arguments. There is a test named test_approval_does_not_transfer_to_different_arguments because this is the property most home-grown approval flows get wrong: they approve “the next tool call,” and the next tool call is not necessarily the one the human looked at.

2. Pause, don’t block. The run result is paused with a pending action. Nothing waits in memory. Approval can arrive from Slack, a portal, or a ticket, hours later, from someone who was not in the conversation.

3. Resume by replay. Re-running the same input through a deterministic harness reaches the same proposed action, which now matches a verdict — no new machinery. With a live model replay is not bit-identical, so pair the gate with checkpointing to resume from the paused turn instead. On LangGraph you get this for free via interrupt() and a checkpointer, which is a genuine argument for that framework if approvals are central to your product.

4. Triggers are risk tiers, not authorization. above("amount", 10_000) encodes “routine below, judgment above.” What is ever legal stays in the broker. The two hooks compose in the same harness, and the ordering matters: never ask a human to approve something categorically forbidden.

When to use it

When NOT to use it

Trade-offs and failure modes

Frequently asked questions

How do I decide what needs approval?

Two questions: is it reversible, and is it visible to someone outside the company? Irreversible-and-external is always gated. Reversible-and-internal almost never is. Set the threshold from the distribution of real actions, not from a round number — a $10,000 limit on a system where the median transfer is $12,000 gates everything.

Should the agent wait, or return to the user?

Return. Tell the user their request is pending review and give them a reference. An agent that appears to hang is a support ticket; an agent that says “flagged for review, reference 0fd23a1” is a product.

What if nobody responds?

Then you have discovered your real approval SLA. Give pending requests an expiry, expire them into a denial rather than an approval, and alert on the expiry rate — a rising rate means the gate is mis-tuned or the queue is unstaffed.

Can an LLM be the approver?

For the judgment this pattern exists to capture, no. The point is putting accountability on a human who can be asked why. An LLM reviewer is a second opinion with the same failure modes as the first, which is the argument made at length in verification loops.

References


Part of the agent harness and governance series. Next: the redaction boundary — why one scrubber is always wrong, and how an agent can act on data it never saw.



Previous Post
Prompt Injection Defences Are Losing. Bind Capabilities Instead
Next Post
One Redaction Function Is Always Wrong