@@ -117,7 +117,11 @@ async def run_tool_loop(
117117 "attempt" : attempts ,
118118 "success" : False ,
119119 "error" : policy_error ,
120- "approved" : True ,
120+ "approved" : False ,
121+ "policy_decision" : (
122+ "hook_ask" if before_tool_dispatch .decision is HookDecision .ASK
123+ else "hook_block"
124+ ),
121125 "evidence_collected" : False ,
122126 "duration_ms" : (time .perf_counter () - tool_start ) * 1000.0 ,
123127 "budget_stats" : agent ._budget_stats (),
@@ -347,15 +351,16 @@ async def approval_observer(payload: dict[str, Any]) -> None:
347351 if hasattr (result , "success" ) and not result .success :
348352 tool_success = False
349353 evidence_collected = bool (getattr (result , "evidence" , []))
350- denied_status = "fail"
351354 denied_output = getattr (result , "output" , {})
352- approved = True
353- if not tool_success :
354- if isinstance (denied_output , dict ):
355- candidate_status = denied_output .get ("status" )
356- if isinstance (candidate_status , str ) and candidate_status :
357- denied_status = candidate_status
358- approved = denied_status != "not_allow"
355+ # Derive "approved" from both the pre-invocation policy decision
356+ # AND the runtime approval outcome. When policy_decision is
357+ # "approve_required", the GovernedToolGateway may still deny at
358+ # runtime (returning output.status="not_allow"), so we must also
359+ # check the tool result for an explicit denial signal.
360+ approved = policy_decision != PolicyDecision .DENY .value
361+ if not tool_success and isinstance (denied_output , dict ):
362+ if denied_output .get ("status" ) == "not_allow" :
363+ approved = False
359364 await agent ._emit_hook (
360365 HookPhase .AFTER_TOOL ,
361366 {
@@ -374,9 +379,14 @@ async def approval_observer(payload: dict[str, Any]) -> None:
374379 )
375380
376381 if not tool_success :
382+ tool_fail_status = "fail"
383+ if isinstance (denied_output , dict ):
384+ candidate = denied_output .get ("status" )
385+ if isinstance (candidate , str ) and candidate :
386+ tool_fail_status = candidate
377387 return {
378388 "success" : False ,
379- "status" : denied_status ,
389+ "status" : tool_fail_status ,
380390 "error" : result .error or "tool failed" ,
381391 "output" : denied_output ,
382392 "result" : result ,
@@ -421,14 +431,13 @@ async def approval_observer(payload: dict[str, Any]) -> None:
421431 "policy_decision" : policy_decision ,
422432 },
423433 )
424- approved = True
425- try :
426- from dare_framework .tool .exceptions import HumanApprovalRequired
434+ # Derive approved from the policy decision made before execution,
435+ # not from the exception type. Infra errors do not revoke approval.
436+ approved = policy_decision != PolicyDecision .DENY .value
437+ from dare_framework .tool .exceptions import HumanApprovalRequired
427438
428- if isinstance (exc , HumanApprovalRequired ):
429- approved = False
430- except Exception :
431- approved = True
439+ if isinstance (exc , HumanApprovalRequired ):
440+ approved = False
432441 await agent ._emit_hook (
433442 HookPhase .AFTER_TOOL ,
434443 {
0 commit comments