Skip to main content

asupersync/lab/crashpack/
agent_proof.rs

1//! Agent task proof bundles for replayable agent work evidence.
2//!
3//! This module implements ASW-6 requirements for agent task run reproduction:
4//!
5//! - **AgentTaskProofBundle**: Serializable evidence for agent work sessions
6//! - **Replay instructions**: Safe replay guidance with permission levels
7//! - **Redaction policy**: Token/path/content filtering for privacy
8//! - **ASW integration**: Validation frontier and lab harness coordination
9//!
10//! # Quick Start
11//!
12//! ```ignore
13//! use asupersync::lab::crashpack::agent_proof::{AgentTaskProofBundleBuilder, ReplaySafetyLevel};
14//!
15//! // Create proof bundle for agent task
16//! let bundle = AgentTaskProofBundleBuilder::new()
17//!     .with_objective("Fix bug in stream handler")
18//!     .with_agent_id("SapphireHill")
19//!     .with_bead_id("asupersync-abc123")
20//!     .with_command("cargo test --lib stream", 0)
21//!     .with_rch_worker("worker-456")
22//!     .with_commit_id("abc123def")
23//!     .build()?;
24//!
25//! bundle.emit_proof_artifacts("proof_bundles/task_123/")?;
26//! ```
27
28use super::{AtpEvidenceLedger, CrashpackError};
29use crate::lab::oracle::evidence::EvidenceEntry;
30use serde::{Deserialize, Serialize};
31use std::collections::BTreeMap;
32use std::path::{Path, PathBuf};
33use thiserror::Error;
34
35/// Schema version for agent task proof bundles.
36pub const AGENT_PROOF_BUNDLE_SCHEMA_VERSION: u32 = 1;
37
38/// Maximum command output length before redaction (16KB).
39const MAX_COMMAND_OUTPUT_BYTES: usize = 16 * 1024;
40
41/// Maximum file path length in proof bundles.
42const MAX_PATH_LENGTH: usize = 512;
43
44/// Serializable proof bundle for agent task runs and coordination failures.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct AgentTaskProofBundle {
47    /// Schema version for compatibility.
48    pub schema_version: u32,
49
50    /// Objective description of what the agent was trying to accomplish.
51    pub objective: String,
52
53    /// Agent identifier that performed the work.
54    pub agent_id: String,
55
56    /// Bead IDs claimed or worked on during this task.
57    pub bead_ids: Vec<String>,
58
59    /// File reservations held during the task.
60    pub file_reservations: Vec<FileReservationRecord>,
61
62    /// File paths touched during the task.
63    pub touched_paths: Vec<PathBuf>,
64
65    /// Commands executed with outputs and exit statuses.
66    pub commands: Vec<CommandRecord>,
67
68    /// RCH worker admission and refusal details.
69    pub rch_details: Option<RchRecord>,
70
71    /// Git commit IDs before and after the task.
72    pub commit_ids: CommitRecord,
73
74    /// Validation frontier summary when the task started.
75    pub validation_frontier: ValidationFrontierRecord,
76
77    /// Agent Mail thread IDs related to this task.
78    pub mail_thread_ids: Vec<String>,
79
80    /// Proof artifact paths generated during the task.
81    pub artifact_paths: Vec<PathBuf>,
82
83    /// First blocker that prevented task completion (if any).
84    pub first_blocker: Option<BlockerRecord>,
85
86    /// Replay instructions for this task.
87    pub replay_instructions: ReplayInstructions,
88
89    /// Evidence ledger for validation.
90    pub evidence_ledger: AtpEvidenceLedger,
91
92    /// Session metadata.
93    pub metadata: BTreeMap<String, String>,
94}
95
96/// File reservation record for proof bundles.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct FileReservationRecord {
99    /// Path that was reserved.
100    pub path: PathBuf,
101    /// Agent that held the reservation.
102    pub holder: String,
103    /// Reservation timestamp or ID.
104    pub reservation_id: String,
105    /// Whether the reservation was released cleanly.
106    pub released_cleanly: bool,
107}
108
109/// Command execution record with redacted outputs.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct CommandRecord {
112    /// Command that was executed.
113    pub command: String,
114    /// Working directory when command was run.
115    pub working_dir: PathBuf,
116    /// Exit status code.
117    pub exit_status: i32,
118    /// Stdout output (redacted if necessary).
119    pub stdout: String,
120    /// Stderr output (redacted if necessary).
121    pub stderr: String,
122    /// Whether outputs were redacted due to size or content.
123    pub outputs_redacted: bool,
124    /// Duration in milliseconds.
125    pub duration_ms: u64,
126}
127
128/// RCH worker details for proof bundles.
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct RchRecord {
131    /// RCH worker ID that handled the task (if admitted).
132    pub worker_id: Option<String>,
133    /// Whether the task was admitted or refused.
134    pub admitted: bool,
135    /// Refusal reason if not admitted.
136    pub refusal_reason: Option<String>,
137    /// RCH queue position when submitted.
138    pub queue_position: Option<u32>,
139    /// Total time spent waiting for RCH.
140    pub wait_time_ms: Option<u64>,
141}
142
143/// Git commit record for before/after state.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct CommitRecord {
146    /// Commit ID before the task started.
147    pub before_commit: String,
148    /// Commit ID after the task completed (if any).
149    pub after_commit: Option<String>,
150    /// Whether the working tree was dirty before starting.
151    pub dirty_tree_before: bool,
152    /// Whether the working tree was dirty after completion.
153    pub dirty_tree_after: bool,
154    /// Changed files if available.
155    pub changed_files: Vec<PathBuf>,
156}
157
158/// Validation frontier record at task start.
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct ValidationFrontierRecord {
161    /// Main branch commit being validated against.
162    pub main_commit: String,
163    /// Known compilation failures.
164    pub compile_failures: Vec<String>,
165    /// Test failures on the frontier.
166    pub test_failures: Vec<String>,
167    /// Whether production lib lane was green.
168    pub production_lib_green: bool,
169}
170
171/// Blocker that prevented task completion.
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct BlockerRecord {
174    /// Type of blocker (e.g., "compile_error", "test_failure", "reservation_conflict").
175    pub blocker_type: String,
176    /// Human-readable description of the blocker.
177    pub description: String,
178    /// File or component that caused the blocker.
179    pub source_location: Option<String>,
180    /// Whether this blocker was due to concurrent changes.
181    pub concurrent_change: bool,
182}
183
184/// Replay instructions with safety levels and permissions.
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct ReplayInstructions {
187    /// Safety level for replay.
188    pub safety_level: ReplaySafetyLevel,
189    /// Commands that can be replayed safely.
190    pub safe_commands: Vec<String>,
191    /// Commands that require remote worker.
192    pub remote_required_commands: Vec<String>,
193    /// Commands that require explicit user approval.
194    pub approval_required_commands: Vec<String>,
195    /// Environment variables needed for replay.
196    pub environment_variables: BTreeMap<String, String>,
197    /// Files that must be restored before replay.
198    pub required_file_state: Vec<PathBuf>,
199    /// Instructions for manual preparation.
200    pub manual_setup_instructions: Vec<String>,
201}
202
203/// Safety level for replay operations.
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
205pub enum ReplaySafetyLevel {
206    /// Replay is completely safe locally.
207    Safe,
208    /// Replay requires remote worker but no special permissions.
209    RemoteRequired,
210    /// Replay has environment-specific dependencies.
211    EnvironmentDependent,
212    /// Replay requires explicit user approval for destructive operations.
213    ApprovalRequired,
214}
215
216/// Builder for agent task proof bundles.
217#[derive(Debug, Default)]
218pub struct AgentTaskProofBundleBuilder {
219    objective: Option<String>,
220    agent_id: Option<String>,
221    bead_ids: Vec<String>,
222    file_reservations: Vec<FileReservationRecord>,
223    touched_paths: Vec<PathBuf>,
224    commands: Vec<CommandRecord>,
225    rch_details: Option<RchRecord>,
226    commit_ids: Option<CommitRecord>,
227    validation_frontier: Option<ValidationFrontierRecord>,
228    mail_thread_ids: Vec<String>,
229    artifact_paths: Vec<PathBuf>,
230    first_blocker: Option<BlockerRecord>,
231    replay_instructions: Option<ReplayInstructions>,
232    evidence_ledger: AtpEvidenceLedger,
233    metadata: BTreeMap<String, String>,
234}
235
236impl AgentTaskProofBundleBuilder {
237    /// Create a new builder.
238    pub fn new() -> Self {
239        Self::default()
240    }
241
242    /// Set the objective for this task.
243    pub fn with_objective(mut self, objective: impl Into<String>) -> Self {
244        self.objective = Some(objective.into());
245        self
246    }
247
248    /// Set the agent ID.
249    pub fn with_agent_id(mut self, agent_id: impl Into<String>) -> Self {
250        self.agent_id = Some(agent_id.into());
251        self
252    }
253
254    /// Add a bead ID that was worked on.
255    pub fn with_bead_id(mut self, bead_id: impl Into<String>) -> Self {
256        self.bead_ids.push(bead_id.into());
257        self
258    }
259
260    /// Add a file reservation record.
261    pub fn with_file_reservation(mut self, reservation: FileReservationRecord) -> Self {
262        self.file_reservations.push(reservation);
263        self
264    }
265
266    /// Add a touched file path.
267    pub fn with_touched_path(mut self, path: impl Into<PathBuf>) -> Self {
268        let path = path.into();
269        if !self.touched_paths.contains(&path) {
270            self.touched_paths.push(path); // ubs:ignore - pushing to vector, not path join
271        }
272        self
273    }
274
275    /// Add a command execution record.
276    pub fn with_command(mut self, command: impl Into<String>, exit_status: i32) -> Self {
277        let record = CommandRecord {
278            command: command.into(),
279            working_dir: std::env::current_dir().unwrap_or_default(),
280            exit_status,
281            stdout: String::new(),
282            stderr: String::new(),
283            outputs_redacted: false,
284            duration_ms: 0,
285        };
286        self.commands.push(record);
287        self
288    }
289
290    /// Add a complete command record with outputs.
291    pub fn with_command_record(mut self, record: CommandRecord) -> Self {
292        self.commands.push(record);
293        self
294    }
295
296    /// Set RCH details.
297    pub fn with_rch_details(mut self, rch: RchRecord) -> Self {
298        self.rch_details = Some(rch);
299        self
300    }
301
302    /// Convenience method to set RCH worker.
303    pub fn with_rch_worker(mut self, worker_id: impl Into<String>) -> Self {
304        self.rch_details = Some(RchRecord {
305            worker_id: Some(worker_id.into()),
306            admitted: true,
307            refusal_reason: None,
308            queue_position: None,
309            wait_time_ms: None,
310        });
311        self
312    }
313
314    /// Set commit record.
315    pub fn with_commit_record(mut self, commits: CommitRecord) -> Self {
316        self.commit_ids = Some(commits);
317        self
318    }
319
320    /// Convenience method to set commit ID.
321    pub fn with_commit_id(mut self, commit_id: impl Into<String>) -> Self {
322        self.commit_ids = Some(CommitRecord {
323            before_commit: commit_id.into(),
324            after_commit: None,
325            dirty_tree_before: false,
326            dirty_tree_after: false,
327            changed_files: Vec::new(),
328        });
329        self
330    }
331
332    /// Set validation frontier.
333    pub fn with_validation_frontier(mut self, frontier: ValidationFrontierRecord) -> Self {
334        self.validation_frontier = Some(frontier);
335        self
336    }
337
338    /// Add mail thread ID.
339    pub fn with_mail_thread_id(mut self, thread_id: impl Into<String>) -> Self {
340        self.mail_thread_ids.push(thread_id.into());
341        self
342    }
343
344    /// Add artifact path.
345    pub fn with_artifact_path(mut self, path: impl Into<PathBuf>) -> Self {
346        let path = path.into();
347        if !self.artifact_paths.contains(&path) {
348            self.artifact_paths.push(path); // ubs:ignore - pushing to vector, not path join
349        }
350        self
351    }
352
353    /// Set first blocker.
354    pub fn with_first_blocker(mut self, blocker: BlockerRecord) -> Self {
355        self.first_blocker = Some(blocker);
356        self
357    }
358
359    /// Set replay instructions.
360    pub fn with_replay_instructions(mut self, instructions: ReplayInstructions) -> Self {
361        self.replay_instructions = Some(instructions);
362        self
363    }
364
365    /// Add evidence to the ledger.
366    pub fn with_evidence(
367        mut self,
368        oracle_name: impl Into<String>,
369        evidence: EvidenceEntry,
370    ) -> Self {
371        self.evidence_ledger
372            .record_oracle_result(oracle_name, evidence, None);
373        self
374    }
375
376    /// Add metadata.
377    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
378        self.metadata.insert(key.into(), value.into());
379        self
380    }
381
382    /// Build the proof bundle.
383    pub fn build(self) -> Result<AgentTaskProofBundle, AgentProofError> {
384        let objective = self
385            .objective
386            .ok_or(AgentProofError::MissingField("objective"))?;
387        let agent_id = self
388            .agent_id
389            .ok_or(AgentProofError::MissingField("agent_id"))?;
390
391        // Provide default values for required fields
392        let commit_ids = self.commit_ids.unwrap_or_else(|| CommitRecord {
393            before_commit: "unknown".to_string(),
394            after_commit: None,
395            dirty_tree_before: false,
396            dirty_tree_after: false,
397            changed_files: Vec::new(),
398        });
399
400        let validation_frontier =
401            self.validation_frontier
402                .unwrap_or_else(|| ValidationFrontierRecord {
403                    main_commit: "unknown".to_string(),
404                    compile_failures: Vec::new(),
405                    test_failures: Vec::new(),
406                    production_lib_green: false,
407                });
408
409        let replay_instructions = self
410            .replay_instructions
411            .unwrap_or_else(|| ReplayInstructions {
412                safety_level: ReplaySafetyLevel::ApprovalRequired,
413                safe_commands: Vec::new(),
414                remote_required_commands: Vec::new(),
415                approval_required_commands: self
416                    .commands
417                    .iter()
418                    .map(|c| c.command.clone())
419                    .collect(),
420                environment_variables: BTreeMap::new(),
421                required_file_state: Vec::new(),
422                manual_setup_instructions: vec![
423                    "Review commands before replay".to_string(),
424                    "Ensure clean working directory".to_string(),
425                ],
426            });
427
428        let bundle = AgentTaskProofBundle {
429            schema_version: AGENT_PROOF_BUNDLE_SCHEMA_VERSION,
430            objective,
431            agent_id,
432            bead_ids: self.bead_ids,
433            file_reservations: self.file_reservations,
434            touched_paths: self.touched_paths,
435            commands: apply_redaction_policy(self.commands),
436            rch_details: self.rch_details,
437            commit_ids,
438            validation_frontier,
439            mail_thread_ids: self.mail_thread_ids,
440            artifact_paths: self.artifact_paths,
441            first_blocker: self.first_blocker,
442            replay_instructions,
443            evidence_ledger: self.evidence_ledger,
444            metadata: self.metadata,
445        };
446
447        validate_bundle(&bundle)?;
448        Ok(bundle)
449    }
450}
451
452impl AgentTaskProofBundle {
453    /// Emit proof artifacts to the specified directory.
454    pub fn emit_proof_artifacts(
455        &self,
456        output_dir: impl AsRef<Path>,
457    ) -> Result<(), AgentProofError> {
458        let output_dir = output_dir.as_ref();
459        std::fs::create_dir_all(output_dir)?;
460
461        // Emit main proof bundle
462        let bundle_path = output_dir.join("agent_proof_bundle.json");
463        let bundle_json = serde_json::to_string_pretty(self)?;
464        std::fs::write(&bundle_path, bundle_json)?;
465
466        // Emit evidence ledger
467        let evidence_path = output_dir.join("evidence_ledger.json");
468        std::fs::write(&evidence_path, self.evidence_ledger.export_json()?)?;
469
470        // Emit replay script
471        let replay_script = self.generate_replay_script()?;
472        let replay_path = output_dir.join("replay.sh");
473        std::fs::write(&replay_path, replay_script)?;
474
475        // Make replay script executable
476        #[cfg(unix)]
477        {
478            use std::os::unix::fs::PermissionsExt;
479            let mut perms = std::fs::metadata(&replay_path)?.permissions();
480            perms.set_mode(0o755);
481            std::fs::set_permissions(&replay_path, perms)?;
482        }
483
484        // Emit command summary
485        let command_summary = self.generate_command_summary();
486        let commands_path = output_dir.join("commands.txt");
487        std::fs::write(&commands_path, command_summary)?;
488
489        // Emit blocker report if there was one
490        if let Some(ref blocker) = self.first_blocker {
491            let blocker_report = self.generate_blocker_report(blocker);
492            let blocker_path = output_dir.join("blocker_report.txt");
493            std::fs::write(&blocker_path, blocker_report)?;
494        }
495
496        Ok(())
497    }
498
499    fn generate_replay_script(&self) -> Result<String, AgentProofError> {
500        let mut script = String::from("#!/bin/bash\n");
501        script.push_str("# Agent Task Proof Bundle Replay Script\n");
502        script.push_str(&format!("# Generated for agent: {}\n", self.agent_id));
503        script.push_str(&format!("# Objective: {}\n", self.objective));
504        script.push_str(&format!(
505            "# Safety level: {:?}\n",
506            self.replay_instructions.safety_level
507        ));
508        script.push('\n');
509
510        script.push_str("set -euo pipefail\n\n");
511
512        // Add safety warning
513        script.push_str("echo \"WARNING: This replay script contains agent task commands.\"\n");
514        script.push_str(&format!(
515            "echo \"Safety level: {:?}\"\n",
516            self.replay_instructions.safety_level
517        ));
518
519        match self.replay_instructions.safety_level {
520            ReplaySafetyLevel::Safe => {
521                script.push_str("echo \"All commands are safe to run.\"\n");
522            }
523            ReplaySafetyLevel::RemoteRequired => {
524                script.push_str("echo \"Some commands require remote worker.\"\n");
525            }
526            ReplaySafetyLevel::EnvironmentDependent => {
527                script.push_str("echo \"Commands depend on specific environment setup.\"\n");
528            }
529            ReplaySafetyLevel::ApprovalRequired => {
530                script.push_str("echo \"Commands require explicit approval before execution.\"\n");
531                script.push_str("read -p \"Continue? (y/N) \" -n 1 -r\n");
532                script.push_str("echo\n");
533                script.push_str("if [[ ! $REPLY =~ ^[Yy]$ ]]; then exit 1; fi\n");
534            }
535        }
536        script.push('\n');
537
538        // Set environment variables
539        if !self.replay_instructions.environment_variables.is_empty() {
540            script.push_str("# Environment variables\n");
541            for (key, value) in &self.replay_instructions.environment_variables {
542                script.push_str(&format!("export {}={}\n", key, shell_escape(value)));
543            }
544            script.push('\n');
545        }
546
547        // Add manual setup instructions
548        if !self
549            .replay_instructions
550            .manual_setup_instructions
551            .is_empty()
552        {
553            script.push_str("# Manual setup required:\n");
554            for instruction in &self.replay_instructions.manual_setup_instructions {
555                script.push_str(&format!("# - {}\n", instruction));
556            }
557            script.push('\n');
558        }
559
560        // Add commands by safety category
561        if !self.replay_instructions.safe_commands.is_empty() {
562            script.push_str("# Safe commands\n");
563            for cmd in &self.replay_instructions.safe_commands {
564                script.push_str(&format!("echo \"Running: {}\"\n", cmd));
565                script.push_str(&format!("{}\n", cmd));
566            }
567            script.push('\n');
568        }
569
570        if !self.replay_instructions.remote_required_commands.is_empty() {
571            script.push_str("# Remote worker required commands\n");
572            for cmd in &self.replay_instructions.remote_required_commands {
573                script.push_str(&format!("echo \"Remote required: {}\"\n", cmd));
574                script.push_str(&format!("# {}\n", cmd));
575            }
576            script.push('\n');
577        }
578
579        if !self
580            .replay_instructions
581            .approval_required_commands
582            .is_empty()
583        {
584            script.push_str("# Approval required commands\n");
585            for cmd in &self.replay_instructions.approval_required_commands {
586                script.push_str(&format!("read -p \"Execute '{}'? (y/N) \" -n 1 -r\n", cmd));
587                script.push_str("echo\n");
588                script.push_str("if [[ $REPLY =~ ^[Yy]$ ]]; then\n");
589                script.push_str(&format!("    {}\n", cmd));
590                script.push_str("else\n");
591                script.push_str(&format!("    echo \"Skipped: {}\"\n", cmd));
592                script.push_str("fi\n");
593            }
594        }
595
596        script.push_str("\necho \"Replay complete.\"\n");
597        Ok(script)
598    }
599
600    fn generate_command_summary(&self) -> String {
601        let mut summary = "Agent Task Command Summary\n".to_string();
602        summary.push_str(&format!("Agent: {}\n", self.agent_id));
603        summary.push_str(&format!("Objective: {}\n", self.objective));
604        summary.push_str(&format!("Commands executed: {}\n\n", self.commands.len()));
605
606        for (i, cmd) in self.commands.iter().enumerate() {
607            summary.push_str(&format!("Command {}: {}\n", i + 1, cmd.command));
608            summary.push_str(&format!("  Exit status: {}\n", cmd.exit_status));
609            summary.push_str(&format!("  Duration: {}ms\n", cmd.duration_ms));
610            summary.push_str(&format!("  Working dir: {}\n", cmd.working_dir.display()));
611
612            if cmd.outputs_redacted {
613                summary.push_str("  Outputs: [REDACTED]\n");
614            } else {
615                if !cmd.stdout.is_empty() {
616                    summary.push_str(&format!(
617                        "  Stdout: {}\n",
618                        truncate_output(&cmd.stdout, 200)
619                    ));
620                }
621                if !cmd.stderr.is_empty() {
622                    summary.push_str(&format!(
623                        "  Stderr: {}\n",
624                        truncate_output(&cmd.stderr, 200)
625                    ));
626                }
627            }
628            summary.push('\n');
629        }
630
631        summary
632    }
633
634    fn generate_blocker_report(&self, blocker: &BlockerRecord) -> String {
635        let mut report = String::from("Agent Task Blocker Report\n");
636        report.push_str("========================\n\n");
637        report.push_str(&format!("Blocker Type: {}\n", blocker.blocker_type));
638        report.push_str(&format!("Description: {}\n", blocker.description));
639
640        if let Some(ref location) = blocker.source_location {
641            report.push_str(&format!("Source: {}\n", location));
642        }
643
644        report.push_str(&format!(
645            "Concurrent Change: {}\n",
646            blocker.concurrent_change
647        ));
648        report.push('\n');
649
650        if blocker.concurrent_change {
651            report.push_str(
652                "This blocker was likely caused by concurrent changes to the codebase.\n",
653            );
654            report.push_str("Consider retrying the task after syncing with the latest changes.\n");
655        }
656
657        report
658    }
659}
660
661/// Apply redaction policy to command records.
662fn apply_redaction_policy(commands: Vec<CommandRecord>) -> Vec<CommandRecord> {
663    commands
664        .into_iter()
665        .map(|mut cmd| {
666            let stdout_redacted = should_redact_output(&cmd.stdout);
667            let stderr_redacted = should_redact_output(&cmd.stderr);
668
669            if stdout_redacted || stderr_redacted {
670                cmd.outputs_redacted = true;
671
672                if stdout_redacted {
673                    cmd.stdout =
674                        "[REDACTED - output too long or contains sensitive content]".to_string();
675                }
676
677                if stderr_redacted {
678                    cmd.stderr =
679                        "[REDACTED - output too long or contains sensitive content]".to_string();
680                }
681            }
682
683            // Redact sensitive patterns in command itself
684            cmd.command = redact_sensitive_patterns(cmd.command);
685
686            cmd
687        })
688        .collect()
689}
690
691/// Check if output should be redacted.
692fn should_redact_output(output: &str) -> bool {
693    // Redact if too long
694    if output.len() > MAX_COMMAND_OUTPUT_BYTES {
695        return true;
696    }
697
698    // Redact if contains sensitive patterns
699    let sensitive_patterns = [
700        "token=",
701        "password=",
702        "secret=",
703        "key=",
704        "auth=",
705        "bearer ",
706        "api_key",
707    ];
708
709    let output_lower = output.to_ascii_lowercase();
710    sensitive_patterns
711        .iter()
712        .any(|pattern| output_lower.contains(pattern))
713}
714
715/// Redact sensitive patterns from command strings.
716fn redact_sensitive_patterns(command: String) -> String {
717    let mut result = command;
718
719    // Simple pattern matching for sensitive flags
720    let sensitive_patterns = [
721        "--token=",
722        "--password=",
723        "--secret=",
724        "--api-key=",
725        "--auth=",
726    ];
727
728    for pattern in &sensitive_patterns {
729        if let Some(start) = result.find(pattern) {
730            let value_start = start + pattern.len();
731            if let Some(end) = result[value_start..].find(' ').map(|i| value_start + i) {
732                result.replace_range(value_start..end, "[REDACTED]");
733            } else {
734                // Value extends to end of string
735                result.replace_range(value_start.., "[REDACTED]");
736            }
737        }
738    }
739
740    // Handle special case of -p flag with space-separated value
741    if let Some(p_idx) = result.find(" -p ") {
742        let value_start = p_idx + 4;
743        if let Some(end) = result[value_start..].find(' ').map(|i| value_start + i) {
744            result.replace_range(value_start..end, "[REDACTED]");
745        } else {
746            result.replace_range(value_start.., "[REDACTED]");
747        }
748    }
749
750    result
751}
752
753/// Validate proof bundle before creation.
754fn validate_bundle(bundle: &AgentTaskProofBundle) -> Result<(), AgentProofError> {
755    if bundle.objective.is_empty() {
756        return Err(AgentProofError::InvalidBundle(
757            "Objective cannot be empty".to_string(),
758        ));
759    }
760
761    if bundle.agent_id.is_empty() {
762        return Err(AgentProofError::InvalidBundle(
763            "Agent ID cannot be empty".to_string(),
764        ));
765    }
766
767    // Validate path lengths
768    for path in &bundle.touched_paths {
769        if path.as_os_str().len() > MAX_PATH_LENGTH {
770            return Err(AgentProofError::InvalidBundle(format!(
771                "Path too long: {}",
772                path.display()
773            )));
774        }
775    }
776
777    for path in &bundle.artifact_paths {
778        if path.as_os_str().len() > MAX_PATH_LENGTH {
779            return Err(AgentProofError::InvalidBundle(format!(
780                "Artifact path too long: {}",
781                path.display()
782            )));
783        }
784    }
785
786    Ok(())
787}
788
789/// Shell-escape a string for safe inclusion in scripts.
790fn shell_escape(s: &str) -> String {
791    if s.chars()
792        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
793    {
794        s.to_string()
795    } else {
796        format!("'{}'", s.replace('\'', "'\"'\"'"))
797    }
798}
799
800/// Truncate output to a reasonable length for summaries.
801fn truncate_output(output: &str, max_chars: usize) -> String {
802    if output.len() <= max_chars {
803        output.replace('\n', "\\n")
804    } else {
805        format!(
806            "{}... [truncated]",
807            output[..max_chars].replace('\n', "\\n")
808        )
809    }
810}
811
812/// Errors for agent proof bundle operations.
813#[derive(Debug, Error)]
814pub enum AgentProofError {
815    #[error("Missing required field: {0}")]
816    MissingField(&'static str),
817    #[error("Invalid bundle: {0}")]
818    InvalidBundle(String),
819    #[error("IO error: {0}")]
820    Io(#[from] std::io::Error),
821    #[error("Serialization error: {0}")]
822    Serialization(#[from] serde_json::Error),
823    #[error("Crashpack error: {0}")]
824    Crashpack(#[from] CrashpackError),
825}
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830
831    #[test]
832    fn builder_requires_objective_and_agent_id() {
833        let result = AgentTaskProofBundleBuilder::new().build();
834        assert!(matches!(
835            result,
836            Err(AgentProofError::MissingField("objective"))
837        ));
838
839        let result = AgentTaskProofBundleBuilder::new()
840            .with_objective("test task")
841            .build();
842        assert!(matches!(
843            result,
844            Err(AgentProofError::MissingField("agent_id"))
845        ));
846
847        let bundle = AgentTaskProofBundleBuilder::new()
848            .with_objective("test task")
849            .with_agent_id("TestAgent")
850            .build()
851            .expect("should build with required fields");
852
853        assert_eq!(bundle.objective, "test task");
854        assert_eq!(bundle.agent_id, "TestAgent");
855    }
856
857    #[test]
858    fn commands_are_redacted_for_sensitive_content() {
859        let bundle = AgentTaskProofBundleBuilder::new()
860            .with_objective("test task")
861            .with_agent_id("TestAgent")
862            .with_command("curl --token=secret123 https://api.example.com", 0)
863            .build()
864            .expect("should build");
865
866        assert_eq!(bundle.commands.len(), 1);
867        assert!(bundle.commands[0].command.contains("[REDACTED]"));
868        assert!(!bundle.commands[0].command.contains("secret123"));
869    }
870
871    #[test]
872    fn replay_instructions_default_to_approval_required() {
873        let bundle = AgentTaskProofBundleBuilder::new()
874            .with_objective("test task")
875            .with_agent_id("TestAgent")
876            .with_command("cargo test", 0)
877            .build()
878            .expect("should build");
879
880        assert_eq!(
881            bundle.replay_instructions.safety_level,
882            ReplaySafetyLevel::ApprovalRequired
883        );
884        assert_eq!(
885            bundle.replay_instructions.approval_required_commands,
886            vec!["cargo test"]
887        );
888    }
889
890    #[test]
891    fn touched_paths_are_deduplicated() {
892        let bundle = AgentTaskProofBundleBuilder::new()
893            .with_objective("test task")
894            .with_agent_id("TestAgent")
895            .with_touched_path("src/main.rs")
896            .with_touched_path("src/main.rs")
897            .with_touched_path("src/lib.rs")
898            .build()
899            .expect("should build");
900
901        assert_eq!(bundle.touched_paths.len(), 2);
902        assert!(bundle.touched_paths.contains(&PathBuf::from("src/main.rs")));
903        assert!(bundle.touched_paths.contains(&PathBuf::from("src/lib.rs")));
904    }
905
906    #[test]
907    fn bundle_validation_rejects_empty_fields() {
908        let result = AgentTaskProofBundle {
909            schema_version: AGENT_PROOF_BUNDLE_SCHEMA_VERSION,
910            objective: String::new(),
911            agent_id: "TestAgent".to_string(),
912            bead_ids: Vec::new(),
913            file_reservations: Vec::new(),
914            touched_paths: Vec::new(),
915            commands: Vec::new(),
916            rch_details: None,
917            commit_ids: CommitRecord {
918                before_commit: "abc123".to_string(),
919                after_commit: None,
920                dirty_tree_before: false,
921                dirty_tree_after: false,
922                changed_files: Vec::new(),
923            },
924            validation_frontier: ValidationFrontierRecord {
925                main_commit: "def456".to_string(),
926                compile_failures: Vec::new(),
927                test_failures: Vec::new(),
928                production_lib_green: true,
929            },
930            mail_thread_ids: Vec::new(),
931            artifact_paths: Vec::new(),
932            first_blocker: None,
933            replay_instructions: ReplayInstructions {
934                safety_level: ReplaySafetyLevel::Safe,
935                safe_commands: Vec::new(),
936                remote_required_commands: Vec::new(),
937                approval_required_commands: Vec::new(),
938                environment_variables: BTreeMap::new(),
939                required_file_state: Vec::new(),
940                manual_setup_instructions: Vec::new(),
941            },
942            evidence_ledger: AtpEvidenceLedger::new(),
943            metadata: BTreeMap::new(),
944        };
945
946        assert!(matches!(
947            validate_bundle(&result),
948            Err(AgentProofError::InvalidBundle(_))
949        ));
950    }
951
952    #[test]
953    fn proof_bundle_can_emit_artifacts() {
954        use tempfile::TempDir;
955
956        let bundle = AgentTaskProofBundleBuilder::new()
957            .with_objective("test task")
958            .with_agent_id("TestAgent")
959            .with_command("echo hello", 0)
960            .build()
961            .expect("should build");
962
963        let temp_dir = TempDir::new().expect("should create temp dir");
964        bundle
965            .emit_proof_artifacts(temp_dir.path())
966            .expect("should emit artifacts");
967
968        // Check that expected files were created
969        assert!(temp_dir.path().join("agent_proof_bundle.json").exists());
970        assert!(temp_dir.path().join("evidence_ledger.json").exists());
971        assert!(temp_dir.path().join("replay.sh").exists());
972        assert!(temp_dir.path().join("commands.txt").exists());
973
974        // Verify replay script is executable on Unix
975        #[cfg(unix)]
976        {
977            use std::os::unix::fs::PermissionsExt;
978            let perms = std::fs::metadata(temp_dir.path().join("replay.sh"))
979                .expect("should read permissions");
980            assert!(perms.permissions().mode() & 0o111 != 0);
981        }
982    }
983}