Skip to content

fix(agent): scope HITL approval to the specific call + enable approve/deny promptly#4854

Merged
mmabrouk merged 1 commit into
big-agentsfrom
feat/agent-hitl-overapproval-settle
Jun 25, 2026
Merged

fix(agent): scope HITL approval to the specific call + enable approve/deny promptly#4854
mmabrouk merged 1 commit into
big-agentsfrom
feat/agent-hitl-overapproval-settle

Conversation

@mmabrouk

Copy link
Copy Markdown
Member

What and why

Two HITL findings from the final agent QA sweep, both in the tool-approval path.

Finding 1 (HIGH, Codex) — approval over-authorized by tool NAME

A stored approval decision was indexed by BOTH the ACP toolCallId AND the bare tool
NAME. The bare-name key was a HITL bypass: approving call A auto-approved any later call B
to the same tool with different args.

  • Before: approve edit({path:"a.txt"}) → a later edit({path:"/etc/shadow"}) (fresh
    tool-call id) falls through to the edit name key and silently auto-runs.
  • After: the cold-replay anchor is approvalKey(name, args) — the tool name bound to a
    canonical (key-sorted) hash of its args — not the bare name. The same call (same name +
    args) resumes; a different call to the same tool re-prompts.

The name fallback exists because /messages is cold-replay: each turn rebuilds the session
from the replayed transcript, so the harness mints a fresh ACP tool-call id for the
re-raised gate and the precise toolCallId no longer matches across turns. Binding the
fallback to name + args keeps the resume working while closing the hole — precision and
resume coexist (no escalation).

Finding 2 (F-026) — Approve/Deny disabled ~70-140s, reads as a hang

The buttons were gated on the conversation-level busy (status === submitted/streaming).
But an approval request can only appear while the stream is in flight — so busy is
necessarily true when the prompt first shows, leaving the buttons disabled for the whole
turn. The AI SDK is built for mid-stream answers: addToolApprovalResponse records the
decision immediately and only auto-resends once the stream settles. So the gate was wrong,
not a backend-park latency.

Fix: enable Approve/Deny as soon as the approval-requested part renders; guard only
against a double-submit. Drops the now-dead busy prop on AgentMessage and its two
callers.

Files

  • services/agent/src/responder.tsapprovalKey(name,args) + canonicalJson; rework
    permissionRequestKeys (live) and extractApprovalDecisions (stored) to drop bare-name.
  • services/agent/tests/unit/responder.test.ts — adds the two required tests (different-args
    call re-prompts; parked call resumes under a fresh id via name+args) + a bare-name-not-a-key
    guard; updates the existing lookups to name+args.
  • web/oss/src/components/AgentChatSlice/{components/ToolPart,components/AgentMessage,components/AgentChatConversation,AgentChatPanel}.tsx
    — enable Approve/Deny promptly; remove the dead busy prop.

Tests

  • Runner: responder.test.ts 16/16 green; typecheck clean for these files (a pre-existing
    otel.ts error from a sibling lane is unrelated).
  • FE: eslint clean on the 4 changed files; no typecheck errors in the changed files.

Not done here (in-scope of sibling lanes)

The SDK ingress (adapters/vercel/messages.py) does not currently emit the {approved}
envelope for a verbatim approval-responded tool part; live resume depends on whatever
decision shape reaches extractApprovalDecisions. The responder is correct + precise
regardless, and the resume property is pinned at the responder layer by unit tests. Logged
as D-007 in the QA decisions log.

https://claude.ai/code/session_01GYo3UEfvsZpncagqb28Mbc

@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Jun 25, 2026
@vercel

vercel Bot commented Jun 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agenta-documentation Ready Ready Preview, Comment Jun 25, 2026 8:12pm

Request Review

@dosubot dosubot Bot added Backend Bug Report Something isn't working Frontend labels Jun 25, 2026
@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes

    • Improved approval handling so permissions now match the specific action and its parameters, reducing unintended reuse of earlier approvals.
    • Changed approvals to apply once per request, helping prevent over-approval across repeated actions.
  • New Features

    • Updated chat tool actions so approve/deny controls stay responsive and prevent duplicate submissions.
    • Streaming state now applies only to the latest message, improving chat clarity during responses.

Walkthrough

The responder now matches HITL approvals by tool-call id or tool name plus stable arguments, and approval extraction indexes those keys from tool_call and tool_result blocks. The chat UI now marks only the latest message as streaming and handles approval-button state locally.

Changes

HITL approval matching

Layer / File(s) Summary
Approval keying and extraction
services/agent/src/responder.ts
approvalKey canonicalizes tool arguments, permissionRequestKeys uses tool-call ids or name+args keys, extractApprovalDecisions correlates approval results through tool calls, and decisionToReply maps allow to once.
Approval matching tests
services/agent/tests/unit/responder.test.ts, services/agent/tests/unit/sandbox-agent-orchestration.test.ts, services/agent/tests/unit/sandbox-agent-permissions.test.ts
Responder and sandbox tests update fixtures and assertions for name+args approval keys, cold replay behavior, and once replies.

Agent chat streaming controls

Layer / File(s) Summary
Streaming flag propagation
web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx, web/oss/src/components/AgentChatSlice/components/AgentChatConversation.tsx, web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx
AgentChatConversation passes isStreaming for the last message, and AgentMessage drops busy from its props and tool-part wiring.
Tool approval response state
web/oss/src/components/AgentChatSlice/components/ToolPart.tsx
ToolPart removes the external disabled prop and uses local responding state to guard approval and denial clicks.

Sequence Diagram(s)

sequenceDiagram
  participant AgentRunRequest.messages
  participant extractApprovalDecisions
  participant approvalKey
  AgentRunRequest.messages->>extractApprovalDecisions: tool_call and tool_result blocks
  extractApprovalDecisions->>approvalKey: derive name+args key
  extractApprovalDecisions-->>AgentRunRequest.messages: decisions keyed by toolCallId and approvalKey
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • Agenta-AI/agenta#4848: Updates the same services/agent/src/responder.ts HITL resume path and adds park/resume behavior that this PR extends with stable tool-argument keying.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the two main changes: scoped HITL approval and enabling approve/deny promptly.
Description check ✅ Passed The description clearly matches the implemented fixes and testing updates in the pull request.
Docstring Coverage ✅ Passed Docstring coverage is 85.71% which is sufficient. The required threshold is 60.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/agent-hitl-overapproval-settle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mmabrouk

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@mmabrouk

Copy link
Copy Markdown
Member Author

Review focus (what I need eyes on):

  1. Security correctness of the over-approval fix (Finding 1). The HITL bypass was that a
    stored allow keyed by bare tool NAME auto-approved any later call to that tool with
    different args. I replaced the bare-name key with approvalKey(name, args) (name + a
    canonical key-sorted JSON hash of the args) in responder.ts, keeping the toolCallId
    key. Please sanity-check: (a) is the bypass actually closed, and (b) does the legitimate
    cold-replay resume still work — the cold model mints a fresh ACP tool-call id each turn,
    so resume relies on the name+args anchor (proven in the two new responder tests).

  2. Cross-layer note. The name on the LIVE ACP request comes from title/kind (ACP has
    no name field); the STORED side reads the FE tool part's toolName. If those ever
    diverge the failure mode is fail-closed (re-park, not bypass). I did NOT touch the SDK
    ingress (adapters/vercel/messages.py, sibling lane) — flagged as D-007 in the QA
    decisions log. Confirm that's the right boundary.

  3. F-026 settle delay (Finding 2). I removed the disabled={busy} gate on Approve/Deny
    so they're clickable as soon as the prompt renders (the AI SDK records the decision
    mid-stream and defers the resend until the stream settles). Confirm this doesn't
    regress the approve→resume round-trip.

Do NOT merge to big-agents — orchestrator does one live re-verify at the end.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🧹 Nitpick comments (2)
services/agent/tests/unit/responder.test.ts (1)

82-93: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a live-wire fallback case for title/kind.

The helper comment references the ACP shape without name, but every permission request here still uses name. Add one test with raw.toolCall.title or kind and no name so the production fallback in permissionToolName stays covered.

web/oss/src/components/AgentChatSlice/components/ToolPart.tsx (1)

92-96: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Stabilize the approval click handlers.

The new respond(true) / respond(false) inline handlers can be hoisted with useCallback to match the TSX callback guideline.

As per coding guidelines, web/**/*.tsx should “Use useCallback to create stable callbacks in render instead of defining inline functions.”

♻️ Proposed refactor
-    const respond = (approved: boolean) => {
+    const respond = useCallback((approved: boolean) => {
         if (responding || !approval?.id) return
         setResponding(true)
         onApprovalResponse({id: approval.id, approved})
-    }
+    }, [approval?.id, onApprovalResponse, responding])
+
+    const handleApprove = useCallback(() => respond(true), [respond])
+    const handleDeny = useCallback(() => respond(false), [respond])
-                                onClick={() => respond(true)}
+                                onClick={handleApprove}
...
-                                onClick={() => respond(false)}
+                                onClick={handleDeny}

Also applies to: 173-181

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8b9ed764-5434-4c00-9285-7ae319a699a6

📥 Commits

Reviewing files that changed from the base of the PR and between cb9de4c and a7a189b.

📒 Files selected for processing (6)
  • services/agent/src/responder.ts
  • services/agent/tests/unit/responder.test.ts
  • web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx
  • web/oss/src/components/AgentChatSlice/components/AgentChatConversation.tsx
  • web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx
  • web/oss/src/components/AgentChatSlice/components/ToolPart.tsx
💤 Files with no reviewable changes (3)
  • web/oss/src/components/AgentChatSlice/components/AgentChatConversation.tsx
  • web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx
  • web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx

Comment thread services/agent/src/responder.ts
@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Railway Preview Environment

Preview URL https://gateway-production-3375.up.railway.app/w
Image tag pr-4854-48a24e2
Status Failed
Railway logs Open logs
Logs View workflow run
Updated at 2026-06-25T20:38:14.693Z

…/deny promptly

Two HITL findings from the final QA sweep.

Finding 1 (HIGH): a stored permission-approval decision was keyed by BOTH the
ACP toolCallId AND the bare tool NAME, so approving call A auto-approved any
later call B to the same tool with different args — a HITL bypass. Replace the
bare-name key with approvalKey(name, args) (name + a canonical, key-sorted JSON
hash of the args) on both the live-request side (permissionRequestKeys) and the
stored side (extractApprovalDecisions). Keep the toolCallId key. A cold-replayed
run mints a fresh ACP tool-call id for the re-raised gate, so the id no longer
matches — the name+args anchor resumes the SAME call while a DIFFERENT call
(same name, different args) re-prompts. Resume and precision coexist.

Finding 2 (F-026): the Approve/Deny buttons were gated on the conversation-level
busy state, so they stayed disabled for the whole turn (~70-140s) and read as a
hang. An approval request can only appear while the stream is in flight, and the
AI SDK records the decision immediately and defers the resend until the stream
settles, so the gate was wrong. Enable Approve/Deny as soon as the prompt
renders; guard only against a double-submit. Drops the now-dead busy prop on
AgentMessage and its two callers.

Tests: responder unit suite (16 tests incl. the two required — different-args
re-prompts, fresh-id resume by name+args); FE eslint clean.

Claude-Session: https://claude.ai/code/session_01GYo3UEfvsZpncagqb28Mbc
@mmabrouk mmabrouk force-pushed the feat/agent-hitl-overapproval-settle branch from a7a189b to be881b9 Compare June 25, 2026 20:11
@mmabrouk

Copy link
Copy Markdown
Member Author

Codex xhigh review applied (round 2). Codex found the bare-name bypass was closed but
flagged 3 more over-approval paths + a robustness issue; all in-scope ones are now fixed
(commit force-pushed):

  1. HITL allow mapped to "always", not "once" (HIGH). A per-call approval was being
    sent to the harness as always, which authorizes the tool broadly for the rest of the
    turn without re-gating — defeating the per-call precision. decisionToReply now maps
    allow -> once only. Headless auto-allow gates each call anyway, so once is equivalent
    for it and strictly safer for HITL. (responder.ts + the 3 orchestration/permission tests.)

  2. Retained toolCallId key unsafe across cold replay (HIGH). extractApprovalDecisions
    no longer emits a historical toolCallId key at all — the /messages path is always
    cold-replay (fresh session per turn), so a stored historical id can never legitimately
    match the fresh re-raised id; keeping it only risked an args-blind match on an id
    collision. The cross-turn anchor is now name+args exclusively.

  3. Absent-args collapse / non-JSON args (MED). approvalKey normalizes absent args to
    {} (so a genuine no-arg tool still resumes) and FAILS CLOSED (no key -> re-prompt) for
    non-JSON args (bigint, cycles, NaN/Infinity, Date/Map) instead of throwing or colliding.

Out of scope (sibling SDK lane, flagged not fixed): Codex Finding 3 — live end-to-end
resume on the slice's Track B sends approvals as a top-level tool_approvals field that
/messages ingress does not read, and the Vercel converter handles tool-approval-response
parts not verbatim approval-responded tool parts (adapters/vercel/messages.py,
routing.py). The responder is correct + precise regardless; the resume property is pinned at
the responder layer by unit tests. Logged as D-007. This needs the SDK lane to wire the
decision through extractApprovalDecisions end to end.

Tests: full runner suite 252/252 green; typecheck clean.

@mmabrouk

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (1)
services/agent/src/responder.ts (1)

76-91: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Align the ApprovalDecisions contract comment with the new extractor behavior.

This block still says each stored decision is indexed under both toolCallId and approvalKey(name, input), but extractApprovalDecisions() now persists only the name+args key and the replayed-id assertions depend on that. Leaving the old description here makes the matching rules look broader than they are.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2194d933-64c3-42db-88a3-41b7d7d48a1e

📥 Commits

Reviewing files that changed from the base of the PR and between a7a189b and be881b9.

📒 Files selected for processing (8)
  • services/agent/src/responder.ts
  • services/agent/tests/unit/responder.test.ts
  • services/agent/tests/unit/sandbox-agent-orchestration.test.ts
  • services/agent/tests/unit/sandbox-agent-permissions.test.ts
  • web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx
  • web/oss/src/components/AgentChatSlice/components/AgentChatConversation.tsx
  • web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx
  • web/oss/src/components/AgentChatSlice/components/ToolPart.tsx
💤 Files with no reviewable changes (3)
  • web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx
  • web/oss/src/components/AgentChatSlice/components/AgentChatConversation.tsx
  • web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • web/oss/src/components/AgentChatSlice/components/ToolPart.tsx

@mmabrouk mmabrouk merged commit 6324757 into big-agents Jun 25, 2026
33 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Backend Bug Report Something isn't working Frontend size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant