Does HITL approval actually bind the action that reaches the executor?

Hey guys, I’ve been testing a failure mode in agent authorization that I’m not sure is getting enough attention.

We usually model the flow as:

agent proposes → human approves → tool executes

But I think there are actually three separate questions:

  1. What action was shown to the human?

  2. What action did the human approve (including any edits)?

  3. What exact action actually reached the executor?

A system can have a perfectly good HITL flow and still have a gap between #2 and #3 if the execution inputs can change after approval, the authorization is replayed, or another execution path reaches the capability.

I’ve been testing this with a small runtime authorization layer and deliberately trying to mutate arguments, targets and runtime identity between approval and execution.

Curious how people building production LangGraph agents think and are currently dealin with this.

Do your approval mechanisms bind to the exact action payload, or primarily to the approval/resume event?

Appreciate any insight :slight_smile:

hi @Brodin2001

with the stock HumanInTheLoopMiddleware the approval binds to the resume event, not to the action payload.

What the code does: after_model re-runs on resume, re-reads the last AIMessage from current state, and pairs the decisions list with tool calls by index. The only validation is the count. For “approve” it returns whatever tool call is in state at that moment. For “edit” it takes edited_action name/args as-is, with no check that the new name is even a gated tool. On the LangGraph side, the interrupt id is a hash of the task namespace, not of the value, and a plain Command(resume=…) is stored as a null-task write consumed by the next interrupt() call. Nothing anywhere compares shown vs approved vs executed.

Probes, all with a gated transfer tool:

  • update_state rewrites the args after the interrupt was shown, then plain approve: executes the mutated args, no re-prompt.
  • Resume with a different context identity: the tool runs under the resumer’s identity, not the proposer’s.
  • edit with name pointing at an ungated tool: executes it, no further gate.
  • A wrap_tool_call middleware placed after HITL rewrites args: executes the rewritten args.
  • Resume again against the interrupted checkpoint_id after approval already ran: tool executes a second time.
  • Re-sending the resume to a finished thread: no-op, the next proposal still interrupts, so naive replay is not free.

So your #2 to #3 gap is real on every axis except naive replay. Also note the middleware only gates tool calls proposed by that agent’s model node - subagents need their own interrupt_on, and a tool called from any other node/graph/script is never seen by it.

What I do to close it (tested against the same probes, all blocked, legit approve/edit still work):

  1. Subclass the middleware. For each gated call put a sha256 of (thread_id, tool_call_id, name, args, approver identity) into the action request. The client echoes it back in the decision. On resume, recompute from current state and refuse if the echo does not match. Record the digest of the final (post-edit) tool call in a state key, and reject edits that retarget outside interrupt_on.
  2. Add an innermost wrap_tool_call (last in the middleware list) that recomputes the digest from request.tool_call and requires an exact match in that state key. This catches any rewrite between approval and executor.
  3. Keep a one-shot record keyed by thread_id + tool_call_id in the Store, not the checkpoint, because time travel restores the checkpoint and would replay it.
  4. For the highest-value tools, do the same check inside the tool body via ToolRuntime, so the capability refuses unapproved payloads regardless of who called it.
  5. On Agent Server, add auth handlers for threads.update and threads.create_run so only the thread owner can update_state or resume, and pin identity to langgraph_auth_user rather than caller-supplied context.

Two small upstream improvements would help everyone: include tool_call_id in each ActionRequest and require it in each Decision, and reject edit decisions whose name is not in interrupt_on.

Hey, this is super useful — thanks for actually running the probes.

The fact that you were able to get the mutated args, identity changes, middleware rewrites and alternate tool path through the stock HITL flow is pretty much the exact gap I’m trying to isolate with AgentGuard.

I also really like the way you closed it with the execution-bound digest and the innermost check. That’s very close to the direction I’ve been taking with the capability/receipt model.

One thing I’m trying to work out now is where the framework-specific implementation ends and the generic security primitive begins.

From your experience, do you think the core property is essentially:

“At the final execution boundary, verify that the exact action being executed is the action that was authorized under the same policy/context”

— independent of LangGraph/HITL?

If so, I think that’s probably the boundary I should keep in AgentGuard core, with the LangGraph/MCP pieces just acting as adapters around it.

Also interested in your thoughts on the policy-authority point you raised in the OWP retest. I want to make sure I’m fixing that at the right abstraction level rather than adding framework-specific complexity to the core.

Really appreciate the detail here. Absolute legend.