Skip to content

feat: add human_approval helper with pending decision contract - #8169

Closed
Shivani . (Shivani767) wants to merge 1 commit into
langchain-ai:mainfrom
Shivani767:feat/human-approval
Closed

Shivani . (Shivani767) wants to merge 1 commit into
langchain-ai:mainfrom
Shivani767:feat/human-approval

Conversation

@Shivani767

Copy link
Copy Markdown

Fixes #8026

This PR adds a lightweight human_approval() helper built on LangGraph's existing interrupt() and Command(resume=...) primitives, providing a reusable HITL approval workflow.

Fixes #8026

Safety Guarantees

  • Allow-listed calls execute without interruption
  • Deny-listed calls produce terminal denial
  • Approval-required calls create a pending record before execution
  • Mismatched resumes fail closed
  • Edited arguments create a new digest and require fresh approval
  • Expired decisions cannot be resumed
  • Cancelled decisions cannot be resumed
  • Terminal decisions cannot be resumed
  • Malformed resume payloads never execute the tool

Tests

Includes comprehensive coverage in two test files:

  • test_human_approval.py: All main functionality
  • test_human_approval_fail_closed.py: Specific focus on safety/fail-closed behavior

How did you verify your code works?

  • Ran all tests locally
  • Verified state transitions
  • Tested fail-closed behavior for all edge cases

Notes

This implementation intentionally avoids introducing a new graph-level primitive (like an ApprovalNode) and instead builds on existing LangGraph execution semantics, as requested by maintainers in the issue discussion.

@github-actions

Copy link
Copy Markdown
Contributor

This PR has been automatically closed because you are not assigned to the linked issue.

External contributors must be assigned to an issue before opening a PR for it. Please:

  1. Comment on the linked issue to request assignment from a maintainer
  2. Once assigned, your PR will be reopened automatically

Maintainers: reopen this PR or remove the missing-issue-link label to bypass this check.

@github-actions github-actions Bot closed this Jun 23, 2026
@Shivani767

Copy link
Copy Markdown
Author

Hi maintainers! I've created a PR for this issue (#8169). Could you please assign me to this issue? Thanks!

@corridor-security corridor-security Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security Issues

  • Approval bypass via client-controlled decision IDs
    human_approval() accepts resume_command_id from the untrusted resume payload and creates a new pending decision when no existing record is found. An attacker who can resume an interrupted run can supply a fresh ID, avoid an existing expired/cancelled/mismatched decision, then resume again with action: "approve" to execute an approval-gated tool. The decision ID should be generated and stored server-side before the first interrupt, and unknown resume IDs should fail closed.

resume_command_id=resume_command_id,
expires_at=datetime.now() + expires_in,
)
store.save(pending_decision)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The approval nonce is taken from the untrusted resume payload, and an unknown ID causes a new pending decision to be created instead of failing closed:

resume_command_id = resume_payload["resume_command_id"]
pending_decision = store.get(resume_command_id)

if pending_decision is None:
    # First pass: create pending record and interrupt again
    pending_decision = PendingDecision(
        checkpoint_id=checkpoint_id or str(uuid.uuid4()),
        node_name=node_name,
        tool_name=tool_name,
        tool_call_id=tool_call_id,
        args_digest=args_digest,
        decision_shape=decision_shape,
        resume_command_id=resume_command_id,
        expires_at=datetime.now() + expires_in,
    )
    store.save(pending_decision)

Because resume_command_id is client-controlled here, a caller who can resume an interrupted graph can provide a fresh ID to bypass an existing expired, cancelled, or mismatched approval record. The helper then creates a new pending decision for that attacker-chosen ID and allows it to be approved on the next resume.

Remediation: Generate resume_command_id server-side before the first interrupt(), save the pending decision before exposing the approval request, include that server-generated ID in the interrupt payload, and treat store.get(resume_command_id) is None as a validation failure. Also bind the stored decision to the expected checkpoint/tool/tool-call/args before approval.

For more details, see the finding in Corridor.

Provide feedback: Reply with whether this is a valid vulnerability or false positive to help improve Corridor's accuracy.

Rudrendu Paul (RudrenduPaul) added a commit to RudrenduPaul/toolgovern that referenced this pull request Jul 18, 2026
Today's require-approval gate is a synchronous, in-process callback with a
30s fail-closed timeout and no way to resume a decision out of band. Add
PendingApprovalRegistry (TS + Python) as a keyed, server-generated-id store
for require-approval decisions, wired into governTool()/govern_tool()
alongside the existing synchronous onApprovalRequired path (unchanged for
callers who don't opt in).

- registerPending()/get()/resolvePending() plus alias tolerance
  (registerAlias) so a rewritten thread/conversation id still resolves to
  the same pending entry -- modeled on the bug fixed in
  microsoft/agent-framework#6908.
- resolvePending() never creates a new entry for an unrecognized id --
  closes the resume-token bypass Corridor's security bot found in
  langchain-ai/langgraph#8169's human_approval() helper.
- Edited arguments supplied to resolvePending() are re-run through the
  classifier before an "allow" is accepted; a still-risky edit is denied
  even after approval, with a real test proving it on both sides.
- governTool()/govern_tool() persist a pending approval BEFORE invoking
  the synchronous handler. A genuine handler answer (allow/deny) closes
  the entry out as terminal; a fail-closed default (no handler, timeout,
  or a throwing handler) leaves it open for later resolution -- otherwise
  the async path would be permanently unreachable.
- resumePendingApproval()/resume_pending_approval() closes the loop: given
  the resolved outcome, it actually invokes the tool and appends one trace
  entry with approvedBy populated end-to-end, not only on the sync path.

60 new TypeScript tests, 53 new Python tests, all existing suites green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request]: Add a high-level ApprovalNode for Human-in-the-Loop workflows

1 participant