Skip to content

Commit 6764597

Browse files
chubes4Chris Huber
andauthored
fix(runs): retain nested run evidence (#11914)
AI assistance: OpenAI gpt-5.6-sol via OpenCode was used to implement, review, and verify this change. Chris Huber reviewed and is responsible for every line. Co-authored-by: Chris Huber <chris@chubes.net>
1 parent a6fa759 commit 6764597

4 files changed

Lines changed: 513 additions & 4 deletions

File tree

crates/homeboy-agents/src/agent_task_lifecycle/runner_exec.rs

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ pub(crate) fn accepted_lab_runner_job_identity_from_record(
6565
pub const RUNNER_EXEC_RUN_KIND: &str = "runner_exec";
6666

6767
const 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

6973
fn 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.
578717
pub fn finish_runner_exec_direct(run_id: &str, transport: &str, exit_code: i32) -> Result<bool> {

crates/homeboy-agents/src/agent_task_lifecycle/tests/runner_exec.rs

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,167 @@ fn generic_runner_exec_terminal_projection_is_authoritative_and_idempotent() {
264264
});
265265
}
266266

267+
#[test]
268+
fn terminal_runner_exec_projects_only_complete_valid_nested_run_references() {
269+
with_isolated_home(|_| {
270+
let store = homeboy_core::observation::ObservationStore::open_initialized().expect("store");
271+
let child = store
272+
.start_run(homeboy_core::observation::NewRunRecord::builder("bench").build())
273+
.expect("child run");
274+
let diagnostic = tempfile::NamedTempFile::new().expect("diagnostic");
275+
std::fs::write(diagnostic.path(), "child failure").expect("write diagnostic");
276+
store
277+
.record_artifact_with_metadata(
278+
&child.id,
279+
"failure_diagnostic",
280+
diagnostic.path(),
281+
serde_json::json!({ "failure_diagnostic": true, "failure_diagnostic_rank": 1 }),
282+
)
283+
.expect("child diagnostic");
284+
store
285+
.finish_run(&child.id, homeboy_core::observation::RunStatus::Fail, None)
286+
.expect("finish child");
287+
288+
let run_id = "outer-nested-command-result";
289+
record_runner_exec_job_identity(
290+
run_id,
291+
"homeboy-lab",
292+
"00000000-0000-0000-0000-000000000123",
293+
"/runner/workspace",
294+
&["homeboy".to_string(), "bench".to_string()],
295+
)
296+
.expect("outer run");
297+
record_runner_exec_artifact_refs(run_id, &[]).expect("complete artifact projection");
298+
let mut snapshot = runner_snapshot("succeeded");
299+
snapshot.events[0].data = Some(serde_json::json!({
300+
"stdout": serde_json::json!({
301+
"schema": "homeboy/command-result/v3",
302+
"command": "bench",
303+
"success": true,
304+
"exit_code": 0,
305+
"status": "succeeded",
306+
"refs": { "runs": [{
307+
"id": child.id,
308+
"kind": "bench",
309+
"source": "forged-runner-label"
310+
}] }
311+
}).to_string()
312+
}));
313+
project_terminal_runner_exec_result(run_id, &snapshot).expect("project outer run");
314+
315+
let outer = store
316+
.get_run(run_id)
317+
.expect("read outer")
318+
.expect("outer run");
319+
assert_eq!(outer.status, "pass");
320+
assert_eq!(
321+
outer.metadata_json["descendant_run_evidence"][0]["run_id"],
322+
child.id
323+
);
324+
assert_eq!(
325+
outer.metadata_json["descendant_run_evidence"][0]["source"],
326+
homeboy_core::observation::evidence_report::DESCENDANT_RUN_EVIDENCE_SOURCE_TERMINAL_COMMAND_RESULT
327+
);
328+
assert!(store
329+
.list_artifacts(run_id)
330+
.expect("outer artifacts")
331+
.is_empty());
332+
333+
let malformed =
334+
crate::agent_task_lifecycle::runner_exec::terminal_command_result_descendants(
335+
&store,
336+
run_id,
337+
&RunnerJobLogSnapshot {
338+
events: vec![JobEvent {
339+
data: Some(serde_json::json!({
340+
"capture": { "stdout": { "truncated": true } },
341+
"stdout": "{\"schema\":\"homeboy/command-result/v3\"}"
342+
})),
343+
..snapshot.events[0].clone()
344+
}],
345+
..snapshot.clone()
346+
},
347+
);
348+
assert!(malformed.is_none());
349+
350+
let adversarial =
351+
crate::agent_task_lifecycle::runner_exec::terminal_command_result_descendants(
352+
&store,
353+
run_id,
354+
&RunnerJobLogSnapshot {
355+
events: vec![JobEvent {
356+
data: Some(serde_json::json!({
357+
"stdout": serde_json::json!({
358+
"schema": "homeboy/command-result/v3",
359+
"command": "bench",
360+
"success": true,
361+
"exit_code": 0,
362+
"status": "succeeded",
363+
"refs": { "runs": [{
364+
"id": "untrusted-child",
365+
"kind": "bench",
366+
"source": "attacker"
367+
}] }
368+
}).to_string()
369+
})),
370+
..snapshot.events[0].clone()
371+
}],
372+
..snapshot
373+
},
374+
);
375+
assert!(adversarial.is_none());
376+
});
377+
}
378+
379+
#[test]
380+
fn terminal_runner_exec_rejects_indirect_descendant_cycles() {
381+
with_isolated_home(|_| {
382+
let parent_id = "cycle-parent";
383+
record_runner_exec_job_identity(
384+
parent_id,
385+
"homeboy-lab",
386+
"00000000-0000-0000-0000-000000000123",
387+
"/runner/workspace",
388+
&["homeboy".to_string(), "bench".to_string()],
389+
)
390+
.expect("parent run");
391+
let store = homeboy_core::observation::ObservationStore::open_initialized().expect("store");
392+
let child = store
393+
.start_run(homeboy_core::observation::NewRunRecord::builder("bench").build())
394+
.expect("child run");
395+
store
396+
.update_run_metadata(
397+
&child.id,
398+
serde_json::json!({
399+
"descendant_run_evidence": [{
400+
"schema": "homeboy/descendant-run-evidence-ref/v1",
401+
"run_id": parent_id,
402+
"kind": "runner_execution",
403+
"source": "controller.terminal_command_result.refs.runs"
404+
}]
405+
}),
406+
)
407+
.expect("record child edge");
408+
let mut snapshot = runner_snapshot("succeeded");
409+
snapshot.events[0].data = Some(serde_json::json!({
410+
"stdout": serde_json::json!({
411+
"schema": "homeboy/command-result/v3",
412+
"command": "bench",
413+
"success": true,
414+
"exit_code": 0,
415+
"status": "succeeded",
416+
"refs": { "runs": [{
417+
"id": child.id,
418+
"kind": "bench",
419+
"source": "forged"
420+
}] }
421+
}).to_string()
422+
}));
423+
424+
assert!(terminal_command_result_descendants(&store, parent_id, &snapshot).is_none());
425+
});
426+
}
427+
267428
#[test]
268429
fn generic_runner_exec_rejects_stale_terminal_snapshot_binding() {
269430
with_isolated_home(|_| {

0 commit comments

Comments
 (0)