@@ -65,6 +65,10 @@ pub(crate) fn accepted_lab_runner_job_identity_from_record(
6565pub const RUNNER_EXEC_RUN_KIND : & str = "runner_exec" ;
6666
6767const RUNNER_EXEC_OBSERVATION_KIND : & str = "runner_execution" ;
68+ const COMMAND_RESULT_SCHEMA : & str = "homeboy/command-result/v3" ;
69+ const COMMAND_RESULT_STDOUT_LIMIT_BYTES : usize = 64 * 1024 ;
70+ const COMMAND_RESULT_RUN_REF_LIMIT : usize = 32 ;
71+ const DESCENDANT_RUN_GRAPH_LIMIT : usize = 64 ;
6872
6973fn ensure_runner_exec_observation_run (
7074 run_id : & str ,
@@ -531,6 +535,12 @@ pub fn project_terminal_runner_exec_result(
531535 1
532536 } ;
533537 let metadata = run. metadata_json . as_object_mut ( ) . expect ( "metadata object" ) ;
538+ if let Some ( descendants) = terminal_command_result_descendants ( & store, & run. id , snapshot) {
539+ metadata. insert (
540+ "descendant_run_evidence" . to_string ( ) ,
541+ serde_json:: to_value ( descendants) . expect ( "descendant refs serialize" ) ,
542+ ) ;
543+ }
534544 metadata. insert ( "runner_job_id" . to_string ( ) , json ! ( snapshot. job. id) ) ;
535545 metadata. insert ( "runner_job_status" . to_string ( ) , json ! ( snapshot. job. status) ) ;
536546 metadata. insert ( "runner_job_events" . to_string ( ) , json ! ( snapshot. events) ) ;
@@ -573,6 +583,135 @@ pub fn project_terminal_runner_exec_result(
573583 Ok ( true )
574584}
575585
586+ /// Accept only a complete, bounded command-result envelope whose run refs point
587+ /// to locally persisted child observations. A terminal log is runner-controlled,
588+ /// so any malformed field, truncation marker, or unresolved reference rejects
589+ /// the whole projection rather than preserving a plausible false edge.
590+ pub ( crate ) fn terminal_command_result_descendants (
591+ store : & homeboy_core:: observation:: ObservationStore ,
592+ parent_run_id : & str ,
593+ snapshot : & RunnerJobLogSnapshot ,
594+ ) -> Option < Vec < homeboy_core:: observation:: evidence_report:: DescendantRunEvidenceRef > > {
595+ let result = snapshot
596+ . events
597+ . iter ( )
598+ . rev ( )
599+ . find ( |event| matches ! ( event. kind, homeboy_core:: api_jobs:: JobEventKind :: Result ) ) ?
600+ . data
601+ . as_ref ( ) ?;
602+ if result
603+ . pointer ( "/capture/stdout/truncated" )
604+ . and_then ( Value :: as_bool)
605+ == Some ( true )
606+ {
607+ return None ;
608+ }
609+ let stdout = result. get ( "stdout" ) ?. as_str ( ) ?;
610+ if stdout. len ( ) > COMMAND_RESULT_STDOUT_LIMIT_BYTES {
611+ return None ;
612+ }
613+ let envelope: Value = serde_json:: from_str ( stdout) . ok ( ) ?;
614+ if envelope. get ( "schema" ) . and_then ( Value :: as_str) != Some ( COMMAND_RESULT_SCHEMA )
615+ || !bounded_string ( & envelope, "command" , 128 )
616+ || envelope. get ( "success" ) . and_then ( Value :: as_bool) . is_none ( )
617+ || envelope
618+ . get ( "exit_code" )
619+ . and_then ( Value :: as_i64)
620+ . and_then ( |code| i32:: try_from ( code) . ok ( ) )
621+ . is_none ( )
622+ || !bounded_string ( & envelope, "status" , 64 )
623+ {
624+ return None ;
625+ }
626+ let runs = envelope. pointer ( "/refs/runs" ) ?. as_array ( ) ?;
627+ if runs. len ( ) > COMMAND_RESULT_RUN_REF_LIMIT {
628+ return None ;
629+ }
630+ let mut descendants = Vec :: with_capacity ( runs. len ( ) ) ;
631+ for run in runs {
632+ let id = bounded_required_string ( run, "id" , 256 ) ?;
633+ let kind = bounded_required_string ( run, "kind" , 128 ) ?;
634+ let _source = bounded_required_string ( run, "source" , 256 ) ?;
635+ if id == parent_run_id {
636+ return None ;
637+ }
638+ let child = store. get_run ( id) . ok ( ) . flatten ( ) ?;
639+ if kind != child. kind {
640+ return None ;
641+ }
642+ if descendant_run_reaches ( store, & child. id , parent_run_id) ? {
643+ return None ;
644+ }
645+ let reference = homeboy_core:: observation:: evidence_report:: DescendantRunEvidenceRef {
646+ schema : homeboy_core:: observation:: evidence_report:: DESCENDANT_RUN_EVIDENCE_REF_SCHEMA
647+ . to_string ( ) ,
648+ run_id : child. id ,
649+ kind : child. kind ,
650+ source : homeboy_core:: observation:: evidence_report:: DESCENDANT_RUN_EVIDENCE_SOURCE_TERMINAL_COMMAND_RESULT . to_string ( ) ,
651+ } ;
652+ if !reference. is_valid ( ) || descendants. iter ( ) . any ( |existing : & homeboy_core:: observation:: evidence_report:: DescendantRunEvidenceRef | existing. run_id == reference. run_id ) {
653+ return None ;
654+ }
655+ descendants. push ( reference) ;
656+ }
657+ Some ( descendants)
658+ }
659+
660+ /// Follow only persisted, typed descendant edges. Every hop is revalidated
661+ /// against the current observation record and the traversal fails closed once
662+ /// its bounded budget is exhausted.
663+ fn descendant_run_reaches (
664+ store : & homeboy_core:: observation:: ObservationStore ,
665+ start_run_id : & str ,
666+ target_run_id : & str ,
667+ ) -> Option < bool > {
668+ let mut pending = vec ! [ start_run_id. to_string( ) ] ;
669+ let mut visited = std:: collections:: HashSet :: new ( ) ;
670+ while let Some ( run_id) = pending. pop ( ) {
671+ if run_id == target_run_id {
672+ return Some ( true ) ;
673+ }
674+ if !visited. insert ( run_id. clone ( ) ) {
675+ continue ;
676+ }
677+ if visited. len ( ) > DESCENDANT_RUN_GRAPH_LIMIT {
678+ return None ;
679+ }
680+ let run = store. get_run ( & run_id) . ok ( ) . flatten ( ) ?;
681+ let Some ( value) = run. metadata_json . get ( "descendant_run_evidence" ) else {
682+ continue ;
683+ } ;
684+ let refs = serde_json:: from_value :: <
685+ Vec < homeboy_core:: observation:: evidence_report:: DescendantRunEvidenceRef > ,
686+ > ( value. clone ( ) )
687+ . ok ( ) ?;
688+ if refs. len ( ) > COMMAND_RESULT_RUN_REF_LIMIT {
689+ return None ;
690+ }
691+ for reference in refs {
692+ if !reference. is_valid ( ) {
693+ return None ;
694+ }
695+ let child = store. get_run ( & reference. run_id ) . ok ( ) . flatten ( ) ?;
696+ if child. kind != reference. kind {
697+ return None ;
698+ }
699+ pending. push ( child. id ) ;
700+ }
701+ }
702+ Some ( false )
703+ }
704+
705+ fn bounded_string ( value : & Value , field : & str , limit : usize ) -> bool {
706+ bounded_required_string ( value, field, limit) . is_some ( )
707+ }
708+
709+ fn bounded_required_string < ' a > ( value : & ' a Value , field : & str , limit : usize ) -> Option < & ' a str > {
710+ let value = value. get ( field) ?. as_str ( ) ?;
711+ ( !value. is_empty ( ) && value. len ( ) <= limit && !value. chars ( ) . any ( char:: is_control) )
712+ . then_some ( value)
713+ }
714+
576715/// Finish a synchronous transport that has no daemon job identity (diagnostic
577716/// SSH/local execution) after its declared evidence is safely retained.
578717pub fn finish_runner_exec_direct ( run_id : & str , transport : & str , exit_code : i32 ) -> Result < bool > {
0 commit comments