From 9ae159f6191a2671926b5b0f438a6f15c61bb404 Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:24:25 +0900 Subject: [PATCH] Persist supervisor-owned target containment receipts - Persist private target process-tree and side-effect confinement observations beside each command summary. - Bind receipts to the supervisor lifecycle, exact attempt, runtime and summary digest, separately from fixed-version probes and provider reports. - Keep missing legacy observations unknown and restored evidence diagnostic-only. - Cover target/probe distinctions, tampering, cross-attempt substitution and failed returned attempts without changing execution authority. Validation: formatting and the new receipt tests passed on Linux; all-target compilation, Clippy and the full platform suites run in this PR's CI. --- src/supervise.rs | 1 + src/supervise/assignment_execution.rs | 16 +- .../managed_parent_controller_tests.rs | 40 +- src/supervise/reporting.rs | 16 +- src/supervise/target_containment.rs | 612 ++++++++++++++++++ src/supervise/tests/run_artifacts.rs | 10 + 6 files changed, 675 insertions(+), 20 deletions(-) create mode 100644 src/supervise/target_containment.rs diff --git a/src/supervise.rs b/src/supervise.rs index 95e5af76..90991362 100644 --- a/src/supervise.rs +++ b/src/supervise.rs @@ -433,6 +433,7 @@ use acceptance::*; use researcher::*; mod reporting; +mod target_containment; #[cfg(all(test, target_os = "linux"))] pub(crate) use reporting::append_precreated_worker_execution_journal; #[cfg(target_os = "linux")] diff --git a/src/supervise/assignment_execution.rs b/src/supervise/assignment_execution.rs index a017ab25..04820ddf 100644 --- a/src/supervise/assignment_execution.rs +++ b/src/supervise/assignment_execution.rs @@ -4041,10 +4041,10 @@ fn dispatch_and_capture_child_attempt_with_release_state<'a>( state.store(1, Ordering::Release); } } - // Retain returned continuation bytes before checkpoints, settlement diagnostics, + // Retain returned target observations before checkpoints, settlement diagnostics, // or envelope validation can return early. This does not authorize or clean scratch. let returned_evidence = match &external_run_result { - Ok(run) if command.codex_managed_readonly_continuation_enabled() => { + Ok(run) => { let imported = preserve_returned_parent_evidence( context, &attempt_artifacts, @@ -4052,7 +4052,9 @@ fn dispatch_and_capture_child_attempt_with_release_state<'a>( launch_runtime, run, ); - if !run.scratch_quiescence_verified() { + if command.codex_managed_readonly_continuation_enabled() + && !run.scratch_quiescence_verified() + { if release_state.is_some() { // Isolated continuation owns a separate failure disposition. // Keep every real returned resource alive; its controller must @@ -4213,6 +4215,7 @@ fn preserve_returned_parent_evidence( run: &ExternalAgentRun, ) -> Result<()> { with_supervisor_artifacts(context.artifacts, |writer, _| { + super::target_containment::persist(writer, artifacts, command, runtime, run)?; let bytes = run.stdout_bytes(); if bytes.len() > MAX_SUPERVISOR_REPORT_BYTES { bail!("descriptor-held parent stdout exceeds its configured byte limit"); @@ -4222,13 +4225,6 @@ fn preserve_returned_parent_evidence( bytes, ArtifactFileDisposition::PrivateEvidence, )?; - write_artifact_json( - writer, - &artifacts.command_record_relative, - &command_record_from_external_for_runtime(run, command, runtime), - MAX_SUPERVISOR_REPORT_BYTES, - ArtifactFileDisposition::PrivateEvidence, - )?; Ok(()) }) .context("managed parent returned evidence import failed") diff --git a/src/supervise/assignment_execution/managed_parent_controller_tests.rs b/src/supervise/assignment_execution/managed_parent_controller_tests.rs index fe15b1b9..f64f7f39 100644 --- a/src/supervise/assignment_execution/managed_parent_controller_tests.rs +++ b/src/supervise/assignment_execution/managed_parent_controller_tests.rs @@ -419,6 +419,7 @@ fn exercise_retained_failure( use crate::external_agent::{ CapturedOutput, CodexParentEvidence, CodexParentResolvedField, CodexParentTurnUsage, }; + use std::os::unix::fs::PermissionsExt; let checkpoint_failure = case == "managed-cycle-retained-checkpoint-error"; let continuation_failure = case == "managed-cycle-retained-continuation" || checkpoint_failure; let import_failure = case == "managed-cycle-retained-import-error"; @@ -427,7 +428,9 @@ fn exercise_retained_failure( .run_dir .join(&prepared.attempt_artifacts.raw_stdout_relative); if import_failure { - fs::create_dir_all(blocked_raw.parent().unwrap())?; + let logs = blocked_raw.parent().unwrap(); + fs::create_dir_all(logs)?; + fs::set_permissions(logs, fs::Permissions::from_mode(0o700))?; fs::create_dir(&blocked_raw)?; } let retained = Mutex::new(Vec::<(PathBuf, Vec)>::new()); @@ -617,6 +620,41 @@ fn exercise_retained_failure( Ok(()) })?; assert!(!initial_scratch.exists()); + for (relative, _) in retained.lock().unwrap().iter() { + let summary = relative.with_extension("summary.json"); + let receipt = relative.with_extension("summary.target-containment.json"); + with_supervisor_artifacts(context.artifacts, |writer, _| { + let summary_bytes = writer.read_authenticated(&summary, MAX_SUPERVISOR_REPORT_BYTES)?; + let bytes = writer.read_authenticated(&receipt, 16 * 1024)?; + let receipt: serde_json::Value = serde_json::from_slice(&bytes)?; + assert_eq!(receipt["version"], 1); + assert_eq!( + receipt["binding"]["run_id"], + context.options.run_id.as_str() + ); + assert_eq!(receipt["binding"]["task_id"], "parent"); + assert_eq!(receipt["binding"]["runtime"], "codex"); + assert_eq!( + receipt["binding"]["command_summary"], + serde_json::to_value(&summary)? + ); + assert_eq!( + receipt["command_summary_sha256"], + crate::artifacts::state_auth::sha256_hex(&summary_bytes) + ); + assert_eq!(receipt["target_process_tree"]["status"], "observed"); + assert_eq!( + receipt["target_process_tree"]["evidence"]["status"], + "verified_empty" + ); + assert_eq!(receipt["target_side_effects"]["status"], "observed"); + assert_eq!( + receipt["target_side_effects"]["evidence"]["status"], + "verified" + ); + Ok(()) + })?; + } if !import_failure { for (relative, raw) in retained.lock().unwrap().iter() { let imported = fs::read(context.run_dir.join(relative))?; diff --git a/src/supervise/reporting.rs b/src/supervise/reporting.rs index fced77fc..36dbf0df 100644 --- a/src/supervise/reporting.rs +++ b/src/supervise/reporting.rs @@ -798,6 +798,13 @@ pub(super) fn import_external_attempt_evidence( runtime, } = context; let import_result = (|| -> Result<()> { + super::target_containment::persist( + writer, + artifacts, + external_command, + runtime, + external_run, + )?; if raw_report_validated { if let Some(contents) = external_run.output_last_message() { if contents.len() > MAX_SUPERVISOR_REPORT_BYTES { @@ -825,15 +832,6 @@ pub(super) fn import_external_attempt_evidence( stdout_bytes, ArtifactFileDisposition::PrivateEvidence, )?; - let command_record = - command_record_from_external_for_runtime(external_run, external_command, runtime); - write_artifact_json( - writer, - &artifacts.command_record_relative, - &command_record, - MAX_SUPERVISOR_REPORT_BYTES, - ArtifactFileDisposition::PrivateEvidence, - )?; Ok(()) })(); diff --git a/src/supervise/target_containment.rs b/src/supervise/target_containment.rs new file mode 100644 index 00000000..e8124fa2 --- /dev/null +++ b/src/supervise/target_containment.rs @@ -0,0 +1,612 @@ +//! Private diagnostic receipts. Restored observations never carry execution authority. + +use super::*; +use crate::artifacts::state_auth::sha256_hex; + +const VERSION: u32 = 1; +const MAX_RECEIPT_BYTES: usize = 16 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct TargetBinding { + run_id: String, + task_id: String, + parent: Option, + attempt: usize, + role: String, + runtime: SupervisorRuntime, + command_summary: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(tag = "status", content = "evidence", rename_all = "snake_case")] +enum Observation { + Unknown, + Observed(T), +} + +impl From> for Observation { + fn from(value: Option) -> Self { + value.map_or(Self::Unknown, Self::Observed) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct TargetContainmentReceipt { + version: u32, + binding: TargetBinding, + command_summary_sha256: String, + target_launch_attempted: bool, + target_process_tree: Observation, + target_side_effects: Observation, +} + +fn receipt_path(summary: &Path) -> PathBuf { + summary.with_extension("target-containment.json") +} + +fn binding( + run_dir: &Path, + artifacts: &ChildAttemptArtifacts, + command: &ExternalAgentCommand, + runtime: SupervisorRuntime, +) -> Result { + let identity = command + .agent_lifecycle + .as_ref() + .context("target receipt requires supervisor lifecycle identity")?; + let attempt = command + .assignment_process_launch_attempt + .filter(|attempt| *attempt > 0) + .context("target receipt requires a supervisor launch attempt")?; + if run_dir.file_name().and_then(|name| name.to_str()) != Some(&identity.run_id) + || normalize_agent_id(&identity.task_id)? != identity.task_id + || identity.role.trim().is_empty() + || command.prompt != artifacts.prompt_path + || command.json_log != artifacts.log_path + || command.output_last_message != artifacts.report_path + || (runtime != SupervisorRuntime::Fake + && command + .invocation + .adapter_id() + .and_then(|id| id.to_runtime_id()) + != Some(runtime)) + { + bail!("target receipt lifecycle, command, or runtime binding mismatch"); + } + let summary = &artifacts.command_record_relative; + let plain = PathBuf::from("logs").join(format!("{}.summary.json", identity.task_id)); + let numbered = PathBuf::from("logs").join(format!( + "{}.attempt-{attempt}.summary.json", + identity.task_id + )); + let nested = identity.parent.as_ref().map(|parent| { + PathBuf::from("nested") + .join(parent) + .join(format!("attempt-{attempt}")) + .join(&identity.task_id) + .join("command.json") + }); + if summary != &plain && summary != &numbered && nested.as_ref() != Some(summary) { + bail!("target receipt command summary does not match the lifecycle attempt"); + } + Ok(TargetBinding { + run_id: identity.run_id.clone(), + task_id: identity.task_id.clone(), + parent: identity.parent.clone(), + attempt, + role: identity.role.clone(), + runtime, + command_summary: summary.clone(), + }) +} + +pub(super) fn persist( + writer: &mut ArtifactRunWriter, + artifacts: &ChildAttemptArtifacts, + command: &ExternalAgentCommand, + runtime: SupervisorRuntime, + run: &ExternalAgentRun, +) -> Result<()> { + let binding = binding(writer.run_dir(), artifacts, command, runtime)?; + let record = command_record_from_external_for_runtime(run, command, runtime); + let mut summary = serde_json::to_vec_pretty(&record)?; + summary.push(b'\n'); + if summary.len() > MAX_SUPERVISOR_REPORT_BYTES { + bail!("target command summary exceeds its configured byte limit"); + } + let receipt = TargetContainmentReceipt { + version: VERSION, + binding, + command_summary_sha256: sha256_hex(&summary), + target_launch_attempted: run.stdout.target_launch_attempted, + target_process_tree: run.process_tree.into(), + target_side_effects: run.side_effects.into(), + }; + let relative = receipt_path(&artifacts.command_record_relative); + // Returned-parent capture and ordinary import can observe the same held run. + // Refuse contradictory repeats rather than replacing the earlier observation. + match fs::symlink_metadata(writer.run_dir().join(&relative)) { + Ok(_) => { + let bytes = writer.read_authenticated(&relative, MAX_RECEIPT_BYTES)?; + let retained_summary = writer.read_authenticated( + &artifacts.command_record_relative, + MAX_SUPERVISOR_REPORT_BYTES, + )?; + let existing = validate(&bytes, &receipt.binding, &retained_summary)?; + if existing != receipt { + bail!("target receipt contradicts a previously retained observation"); + } + return Ok(()); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + writer.write_bytes( + &artifacts.command_record_relative, + &summary, + ArtifactFileDisposition::PrivateEvidence, + )?; + write_artifact_json( + writer, + &relative, + &receipt, + MAX_RECEIPT_BYTES, + ArtifactFileDisposition::PrivateEvidence, + )?; + let summary = writer.read_authenticated( + &artifacts.command_record_relative, + MAX_SUPERVISOR_REPORT_BYTES, + )?; + let bytes = writer.read_authenticated(&relative, MAX_RECEIPT_BYTES)?; + validate(&bytes, &receipt.binding, &summary)?; + Ok(()) +} + +fn validate( + bytes: &[u8], + expected: &TargetBinding, + summary: &[u8], +) -> Result { + let receipt: TargetContainmentReceipt = serde_json::from_slice(bytes)?; + if receipt.version != VERSION + || &receipt.binding != expected + || receipt.command_summary_sha256 != sha256_hex(summary) + { + bail!("target receipt version, attempt identity, or command summary hash mismatch"); + } + Ok(receipt) +} + +// Diagnostic readback only. In particular, neither this type nor the receipt can +// be converted to a launch grant, cleanup proof, lease release, or accepted report. +#[derive(Debug, PartialEq, Eq)] +enum RestoredTargetContainment { + UnknownLegacy, + Retained(TargetContainmentReceipt), +} + +#[allow(dead_code)] // Kept private until an operator diagnostic consumer needs readback. +fn read(reader: &ArtifactRunReader, expected: &TargetBinding) -> Result { + if reader.finalization().run_id != expected.run_id { + bail!("target receipt expected run does not match the authenticated manifest"); + } + let summary_record = reader + .finalization() + .files + .iter() + .find(|file| file.path == expected.command_summary) + .context("target command summary is absent from the authenticated manifest")?; + if summary_record.disposition != ArtifactFileDisposition::PrivateEvidence + || summary_record.bytes > MAX_SUPERVISOR_REPORT_BYTES as u64 + { + bail!("target command summary must be bounded private evidence"); + } + let summary = reader.read(&expected.command_summary)?; + let relative = receipt_path(&expected.command_summary); + let record = reader + .finalization() + .files + .iter() + .find(|file| file.path == relative); + let Some(record) = record else { + return Ok(RestoredTargetContainment::UnknownLegacy); + }; + if record.disposition != ArtifactFileDisposition::PrivateEvidence + || record.bytes > MAX_RECEIPT_BYTES as u64 + { + bail!("target receipt must be bounded private evidence"); + } + let receipt = validate(&reader.read(relative)?, expected, &summary)?; + Ok(RestoredTargetContainment::Retained(receipt)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + external_agent::{ + EnvironmentExecutable, EnvironmentFixedVersionProbeEvidence, + EnvironmentFixedVersionProbeStream, + }, + process_runner::{ContainmentBackend, SideEffectConfinementProfileKind}, + }; + + struct Fixture { + _temp: tempfile::TempDir, + repo: PathBuf, + run_id: RunId, + writer: ArtifactRunWriter, + artifacts: ChildAttemptArtifacts, + command: ExternalAgentCommand, + } + + fn fixture() -> Fixture { + let (temp, repo) = super::super::tests::injected_repository(); + let run_id = RunId::new("target-receipt-test").unwrap(); + let writer = + ArtifactRunWriter::reserve(&repo, RunArtifactFamily::Supervise, run_id.clone(), "test") + .unwrap(); + let dirs = RunDirs::for_writer(&writer); + let artifacts = child_attempt_artifacts( + &dirs, + &dirs.run_dir.join("incoming"), + &dirs.run_dir.join("capture"), + "research", + 2, + true, + ); + let mut command = ExternalAgentCommand::codex( + "unused-codex", + &repo, + &artifacts.prompt_path, + &artifacts.log_path, + &artifacts.report_path, + Duration::from_secs(1), + ) + .with_agent_lifecycle(&repo, "researcher", run_id.as_str(), "research"); + command.assignment_process_launch_attempt = Some(2); + Fixture { + _temp: temp, + repo, + run_id, + writer, + artifacts, + command, + } + } + + fn finalize(mut writer: ArtifactRunWriter, repo: &Path, run_id: &RunId) -> ArtifactRunReader { + let report = super::super::tests::artifact_test_final_report(run_id); + write_final_report(&mut writer, &report).unwrap(); + writer + .finalize( + RunArtifactFamily::Supervise.final_report_relative_path(), + false, + ) + .unwrap(); + ArtifactRunReader::open(repo, RunArtifactFamily::Supervise, run_id).unwrap() + } + + #[test] + fn target_receipt_binds_attempt_and_distinguishes_probe() { + let mut fixture = fixture(); + let mut run = deterministic_fake_run(&fixture.command, Vec::new()); + run.process_tree = Some(ProcessTreeEvidence::Unverified( + ContainmentBackend::SystemdUserService, + )); + run.side_effects = Some(SideEffectConfinementEvidence::Unverified( + SideEffectConfinementProfileKind::ExternalCodex, + )); + let stream = EnvironmentFixedVersionProbeStream { + text: String::new(), + truncated: false, + }; + run.attach_fixed_version_probe_evidence_for_test(EnvironmentFixedVersionProbeEvidence { + executable: EnvironmentExecutable::Git, + exit_code: Some(0), + timed_out: false, + stdout: stream.clone(), + stderr: stream, + process_tree: ProcessTreeEvidence::VerifiedEmpty( + ContainmentBackend::SystemdUserService, + ), + side_effects: SideEffectConfinementEvidence::Verified( + SideEffectConfinementProfileKind::TrustedFixedNetwork, + ), + }); + let expected = binding( + fixture.writer.run_dir(), + &fixture.artifacts, + &fixture.command, + SupervisorRuntime::Codex, + ) + .unwrap(); + persist( + &mut fixture.writer, + &fixture.artifacts, + &fixture.command, + SupervisorRuntime::Codex, + &run, + ) + .unwrap(); + persist( + &mut fixture.writer, + &fixture.artifacts, + &fixture.command, + SupervisorRuntime::Codex, + &run, + ) + .unwrap(); + let reader = finalize(fixture.writer, &fixture.repo, &fixture.run_id); + let RestoredTargetContainment::Retained(receipt) = read(&reader, &expected).unwrap() else { + panic!("missing target receipt") + }; + assert_eq!(receipt.binding.attempt, 2); + assert_eq!(receipt.binding.role, "researcher"); + assert_eq!( + receipt.target_process_tree, + Observation::Observed(run.process_tree.unwrap()) + ); + assert_eq!( + receipt.target_side_effects, + Observation::Observed(run.side_effects.unwrap()) + ); + let summary: CommandRunRecord = + serde_json::from_slice(&reader.read(&expected.command_summary).unwrap()).unwrap(); + assert!(summary + .fixed_version_probe_evidence + .unwrap() + .process_tree + .is_verified_empty()); + assert!(!external_containment_verified( + &run, + SupervisorRuntime::Codex + )); + assert!(reader + .finalization() + .files + .iter() + .all(|record| record.disposition == ArtifactFileDisposition::PrivateEvidence)); + } + + #[test] + fn missing_legacy_target_is_unknown() { + let mut legacy = fixture(); + let run = deterministic_fake_run(&legacy.command, Vec::new()); + let expected = binding( + legacy.writer.run_dir(), + &legacy.artifacts, + &legacy.command, + SupervisorRuntime::Codex, + ) + .unwrap(); + let record = command_record_from_external_for_runtime( + &run, + &legacy.command, + SupervisorRuntime::Codex, + ); + legacy + .writer + .write_json( + &expected.command_summary, + &record, + ArtifactFileDisposition::PrivateEvidence, + ) + .unwrap(); + let reader = finalize(legacy.writer, &legacy.repo, &legacy.run_id); + assert_eq!( + read(&reader, &expected).unwrap(), + RestoredTargetContainment::UnknownLegacy + ); + + let mut current = fixture(); + persist( + &mut current.writer, + ¤t.artifacts, + ¤t.command, + SupervisorRuntime::Codex, + &run, + ) + .unwrap(); + let reader = finalize(current.writer, ¤t.repo, ¤t.run_id); + let RestoredTargetContainment::Retained(receipt) = read(&reader, &expected).unwrap() else { + panic!("missing current receipt") + }; + assert_eq!(receipt.target_process_tree, Observation::Unknown); + assert_eq!(receipt.target_side_effects, Observation::Unknown); + } + + #[test] + fn tampered_or_cross_attempt_receipt_is_rejected() { + let mut fixture = fixture(); + let run_dir = fixture.writer.run_dir().to_path_buf(); + let run = deterministic_fake_run(&fixture.command, Vec::new()); + let expected = binding( + fixture.writer.run_dir(), + &fixture.artifacts, + &fixture.command, + SupervisorRuntime::Codex, + ) + .unwrap(); + persist( + &mut fixture.writer, + &fixture.artifacts, + &fixture.command, + SupervisorRuntime::Codex, + &run, + ) + .unwrap(); + let reader = finalize(fixture.writer, &fixture.repo, &fixture.run_id); + for field in ["run", "task", "attempt", "role", "runtime", "parent"] { + let mut other = expected.clone(); + match field { + "run" => other.run_id = "other-run".into(), + "task" => other.task_id = "other-task".into(), + "attempt" => other.attempt += 1, + "role" => other.role = "worker".into(), + "runtime" => other.runtime = SupervisorRuntime::Grok, + "parent" => other.parent = Some("other-parent".into()), + _ => unreachable!(), + } + assert!(read(&reader, &other).is_err(), "accepted wrong {field}"); + } + let relative = receipt_path(&expected.command_summary); + let bytes = reader.read(&relative).unwrap(); + let summary = reader.read(&expected.command_summary).unwrap(); + assert!(validate(&bytes, &expected, b"different command summary").is_err()); + let mut altered: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + altered["version"] = json!(2); + assert!(validate(&serde_json::to_vec(&altered).unwrap(), &expected, &summary).is_err()); + altered["version"] = json!(1); + altered["child_claim"] = json!("verified"); + assert!(validate(&serde_json::to_vec(&altered).unwrap(), &expected, &summary).is_err()); + fs::write(run_dir.join(relative), b"{}").unwrap(); + assert!(read(&reader, &expected).is_err()); + assert!(ArtifactRunReader::open( + &fixture.repo, + RunArtifactFamily::Supervise, + &fixture.run_id + ) + .is_err()); + } + + #[test] + fn failed_timeout_without_valid_report_retains_target_before_scratch_disposal() { + let mut fixture = fixture(); + let (incoming, capture) = create_invocation_scratches(&mut fixture.writer).unwrap(); + let mut run = deterministic_fake_run(&fixture.command, b"invalid report".to_vec()); + run.exit_code = Some(1); + run.timed_out = true; + run.process_tree = Some(ProcessTreeEvidence::VerifiedEmpty( + ContainmentBackend::SystemdUserService, + )); + run.side_effects = Some(SideEffectConfinementEvidence::Verified( + SideEffectConfinementProfileKind::ExternalCodex, + )); + let expected = binding( + fixture.writer.run_dir(), + &fixture.artifacts, + &fixture.command, + SupervisorRuntime::Fake, + ) + .unwrap(); + import_external_attempt_evidence( + &mut fixture.writer, + ExternalAttemptEvidenceContext { + incoming_scratch: &incoming, + capture_scratch: &capture, + artifacts: &fixture.artifacts, + external_run: &run, + external_command: &fixture.command, + raw_report_validated: false, + runtime: SupervisorRuntime::Fake, + }, + ) + .unwrap(); + assert!(!incoming.path().exists()); + assert!(!capture.path().exists()); + let reader = finalize(fixture.writer, &fixture.repo, &fixture.run_id); + let RestoredTargetContainment::Retained(receipt) = read(&reader, &expected).unwrap() else { + panic!("missing failed target receipt") + }; + assert_eq!( + receipt.target_process_tree, + Observation::Observed(run.process_tree.unwrap()) + ); + assert_eq!( + receipt.target_side_effects, + Observation::Observed(run.side_effects.unwrap()) + ); + let summary: CommandRunRecord = + serde_json::from_slice(&reader.read(&expected.command_summary).unwrap()).unwrap(); + assert!(summary.timed_out); + assert_eq!(summary.exit_code, Some(1)); + assert!(reader.read(&fixture.artifacts.raw_report_relative).is_err()); + assert!(!external_containment_verified( + &run, + SupervisorRuntime::Codex + )); + } + + #[test] + fn missing_or_contradictory_lifecycle_and_repeated_observations_are_refused() { + let mut fixture = fixture(); + let mut run = deterministic_fake_run(&fixture.command, Vec::new()); + let mut command = fixture.command.clone(); + command.agent_lifecycle = None; + assert!(persist( + &mut fixture.writer, + &fixture.artifacts, + &command, + SupervisorRuntime::Codex, + &run + ) + .is_err()); + command = fixture.command.clone(); + command.assignment_process_launch_attempt = None; + assert!(persist( + &mut fixture.writer, + &fixture.artifacts, + &command, + SupervisorRuntime::Codex, + &run + ) + .is_err()); + command = fixture.command.clone(); + command.assignment_process_launch_attempt = Some(3); + assert!(persist( + &mut fixture.writer, + &fixture.artifacts, + &command, + SupervisorRuntime::Codex, + &run + ) + .is_err()); + assert!(persist( + &mut fixture.writer, + &fixture.artifacts, + &fixture.command, + SupervisorRuntime::Grok, + &run + ) + .is_err()); + persist( + &mut fixture.writer, + &fixture.artifacts, + &fixture.command, + SupervisorRuntime::Codex, + &run, + ) + .unwrap(); + run.process_tree = Some(ProcessTreeEvidence::VerifiedEmpty( + ContainmentBackend::SystemdUserService, + )); + assert!(persist( + &mut fixture.writer, + &fixture.artifacts, + &fixture.command, + SupervisorRuntime::Codex, + &run + ) + .is_err()); + run.process_tree = None; + fs::write( + fixture + .writer + .run_dir() + .join(&fixture.artifacts.command_record_relative), + b"tampered summary", + ) + .unwrap(); + assert!(persist( + &mut fixture.writer, + &fixture.artifacts, + &fixture.command, + SupervisorRuntime::Codex, + &run + ) + .is_err()); + } +} diff --git a/src/supervise/tests/run_artifacts.rs b/src/supervise/tests/run_artifacts.rs index 3b8c3317..bdfcce12 100644 --- a/src/supervise/tests/run_artifacts.rs +++ b/src/supervise/tests/run_artifacts.rs @@ -2001,6 +2001,9 @@ fn supervise_writer_discards_reusable_invocation_scratches_and_finalizes_private &artifacts.report_path, Duration::from_secs(1), ); + let mut command = + command.with_agent_lifecycle(&repo_path, "child_orchestrator", run_id.as_str(), "child-a"); + command.assignment_process_launch_attempt = Some(1); let external_run = deterministic_fake_run(&command, child_bytes.clone()); import_external_attempt_evidence( &mut writer, @@ -2077,6 +2080,13 @@ fn attempted_unverified_target_preserves_both_scratches_and_has_no_marker() { serde_json::to_vec(&injected_child_report(&assignment)).expect("serialize report"); fs::write(&artifacts.report_path, &child_bytes).expect("write incoming report"); fs::write(&artifacts.log_path, b"unverified capture\n").expect("write capture"); + let mut command = command.with_agent_lifecycle( + &repo_path, + "child_orchestrator", + "artifact-unverified-target", + "child-unverified", + ); + command.assignment_process_launch_attempt = Some(1); let mut run = deterministic_fake_run(&command, child_bytes); run.program_trust = ExternalProgramTrust::TrustedSystemCodex; run.process_tree = Some(ProcessTreeEvidence::Unverified(