@@ -254,7 +254,7 @@ impl Hook {
254254 let stderr = String :: from_utf8_lossy ( & output. stderr ) . trim ( ) . to_string ( ) ;
255255
256256 Ok ( HookOutcome :: from_cmd (
257- & hook_name, exit_code, stdout, stderr, context,
257+ & hook_name, exit_code, & stdout, & stderr, context,
258258 ) )
259259 }
260260
@@ -501,85 +501,98 @@ impl HookOutcome {
501501 fn from_cmd (
502502 name : & str ,
503503 exit_code : Option < i32 > ,
504- stdout : String ,
505- stderr : String ,
504+ stdout : & str ,
505+ stderr : & str ,
506506 context : & HookContext ,
507507 ) -> Self {
508- if !stdout. is_empty ( )
509- && let Ok ( json_val) = serde_json:: from_str :: < serde_json:: Value > ( & stdout)
510- {
511- let action_res = serde_json:: from_value :: < ActionOutput > ( json_val. clone ( ) ) ;
512- if let Ok ( action) = action_res {
513- if let Some ( decision) = & action. decision {
514- match decision {
515- ActionDecision :: Block | ActionDecision :: Deny => {
516- return HookOutcome :: Error {
517- name : name. to_string ( ) ,
518- exit_code,
519- message : format ! (
520- "Operation blocked by decision:\n {}" ,
521- action. message. unwrap_or( stdout)
522- ) ,
523- } ;
524- }
525- ActionDecision :: Allow if action. updated_input . is_none ( ) => {
526- return HookOutcome :: Success ;
527- }
528- _ => { }
529- }
530- }
531-
532- if let Some ( data) = action. updated_input {
533- return HookOutcome :: Transformed {
534- name : name. to_string ( ) ,
535- data,
536- } ;
537- }
538-
539- // Return raw JSON as delta — the pipeline handles merging.
540- if context. data . is_tool ( ) || action. decision . is_none ( ) {
541- return HookOutcome :: Transformed {
542- name : name. to_string ( ) ,
543- data : json_val,
544- } ;
545- }
546-
547- if matches ! ( action. decision, Some ( ActionDecision :: Allow ) ) {
548- return HookOutcome :: Success ;
549- }
550- }
508+ // Try parsing stdout as structured action output.
509+ if let Some ( outcome) = Self :: parse_action_response ( name, exit_code, stdout, context) {
510+ return outcome;
551511 }
552512
553- // Exit-code based fallback
513+ // Exit-code based fallback.
554514 if exit_code == Some ( 0 ) {
555515 return HookOutcome :: Success ;
556516 }
557517
558- let combined_output = if stderr. is_empty ( ) {
559- stdout
560- } else if stdout. is_empty ( ) {
561- stderr
562- } else {
563- format ! ( "{stdout}\n {stderr}" )
518+ let combined = match ( stdout, stderr) {
519+ ( "" , s) | ( s, "" ) => s. to_string ( ) ,
520+ ( s, e) => format ! ( "{s}\n {e}" ) ,
564521 } ;
565522
566- let is_rejection = matches ! ( exit_code, Some ( 2 | 64 | 65 | 77 ) ) ;
567-
568- if is_rejection {
523+ if matches ! ( exit_code, Some ( 2 | 64 | 65 | 77 ) ) {
569524 HookOutcome :: Error {
570525 name : name. to_string ( ) ,
571526 exit_code,
572- message : format ! ( "Operation blocked:\n {combined_output }" ) ,
527+ message : format ! ( "Operation blocked:\n {combined }" ) ,
573528 }
574529 } else {
575530 HookOutcome :: Warning {
576531 name : name. to_string ( ) ,
577532 exit_code,
578- message : combined_output ,
533+ message : combined ,
579534 }
580535 }
581536 }
582537
538+ /// Try to interpret stdout as a structured action response.
539+ fn parse_action_response (
540+ name : & str ,
541+ exit_code : Option < i32 > ,
542+ stdout : & str ,
543+ context : & HookContext ,
544+ ) -> Option < Self > {
545+ if stdout. is_empty ( ) {
546+ return None ;
547+ }
548+ let json_val: serde_json:: Value = serde_json:: from_str ( stdout) . ok ( ) ?;
549+ let action: ActionOutput = serde_json:: from_value ( json_val. clone ( ) ) . ok ( ) ?;
550+
551+ // Decision-based handling.
552+ if let Some ( ref decision) = action. decision {
553+ match decision {
554+ ActionDecision :: Block | ActionDecision :: Deny => {
555+ return Some ( HookOutcome :: Error {
556+ name : name. to_string ( ) ,
557+ exit_code,
558+ message : format ! (
559+ "Operation blocked by decision:\n {}" ,
560+ action. message. as_deref( ) . unwrap_or( stdout)
561+ ) ,
562+ } ) ;
563+ }
564+ ActionDecision :: Allow if action. updated_input . is_none ( ) => {
565+ return Some ( HookOutcome :: Success ) ;
566+ }
567+ ActionDecision :: Allow => {
568+ return action. updated_input . map ( |data| HookOutcome :: Transformed {
569+ name : name. to_string ( ) ,
570+ data,
571+ } ) ;
572+ }
573+ ActionDecision :: Ask => { }
574+ }
575+ }
576+
577+ // Explicit data transform takes priority.
578+ if let Some ( data) = action. updated_input {
579+ return Some ( HookOutcome :: Transformed {
580+ name : name. to_string ( ) ,
581+ data,
582+ } ) ;
583+ }
584+
585+ // Return raw JSON as delta for tool contexts or when no decision was made.
586+ if context. data . is_tool ( ) || action. decision . is_none ( ) {
587+ return Some ( HookOutcome :: Transformed {
588+ name : name. to_string ( ) ,
589+ data : json_val,
590+ } ) ;
591+ }
592+
593+ None
594+ }
595+
583596 pub fn format ( & self ) -> String {
584597 match self {
585598 HookOutcome :: Success => String :: new ( ) ,
0 commit comments