Skip to main content

aidens_contracts/
tool_artifacts.rs

1//! Repository, patch, command, sandbox, and packet artifacts.
2//!
3//! Tool outputs are local operator receipts; repository contents and package truth remain separately verified.
4
5use super::*;
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
8pub struct RepoReadReportV1 {
9    pub receipt_id: ArtifactId,
10    pub kind: ArtifactKindV1,
11    pub tool_id: String,
12    pub sandbox_root: String,
13    pub requested_path: String,
14    pub resolved_path: String,
15    pub bytes: u64,
16    pub content_digest: DisplayDigestV1,
17    pub allowed: bool,
18    #[serde(default, skip_serializing_if = "Vec::is_empty")]
19    pub reason_codes: Vec<String>,
20    pub read_at: DateTime<Utc>,
21}
22
23impl RepoReadReportV1 {
24    pub fn allowed(
25        sandbox_root: impl Into<String>,
26        requested_path: impl Into<String>,
27        resolved_path: impl Into<String>,
28        bytes: u64,
29        content: &str,
30    ) -> Self {
31        Self {
32            receipt_id: display_only_unstable_id("repo-read"),
33            kind: ArtifactKindV1::RepoRead,
34            tool_id: "aidens:repo-read:1".into(),
35            sandbox_root: sandbox_root.into(),
36            requested_path: requested_path.into(),
37            resolved_path: resolved_path.into(),
38            bytes,
39            content_digest: DisplayDigestV1::for_text(content),
40            allowed: true,
41            reason_codes: vec!["sandbox-read-allowed".into()],
42            read_at: Utc::now(),
43        }
44    }
45
46    pub fn denied(
47        sandbox_root: impl Into<String>,
48        requested_path: impl Into<String>,
49        reason: impl Into<String>,
50    ) -> Self {
51        let reason = reason.into();
52        Self {
53            receipt_id: display_only_unstable_id("repo-read"),
54            kind: ArtifactKindV1::RepoRead,
55            tool_id: "aidens:repo-read:1".into(),
56            sandbox_root: sandbox_root.into(),
57            requested_path: requested_path.into(),
58            resolved_path: String::new(),
59            bytes: 0,
60            content_digest: DisplayDigestV1::for_text(""),
61            allowed: false,
62            reason_codes: vec![reason],
63            read_at: Utc::now(),
64        }
65    }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
69pub struct RepoListEntryV1 {
70    pub path: String,
71    pub entry_kind: String,
72    pub bytes: Option<u64>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
76pub struct RepoListReportV1 {
77    pub receipt_id: ArtifactId,
78    pub kind: ArtifactKindV1,
79    pub tool_id: String,
80    pub sandbox_root: String,
81    pub requested_path: String,
82    pub entries: Vec<RepoListEntryV1>,
83    pub listing_digest: DisplayDigestV1,
84    #[serde(default)]
85    pub total_entries: usize,
86    #[serde(default)]
87    pub returned_entries: usize,
88    #[serde(default)]
89    pub truncated: bool,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub full_listing_digest: Option<DisplayDigestV1>,
92    pub allowed: bool,
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub reason_codes: Vec<String>,
95    pub listed_at: DateTime<Utc>,
96}
97
98impl RepoListReportV1 {
99    pub fn allowed(
100        sandbox_root: impl Into<String>,
101        requested_path: impl Into<String>,
102        entries: Vec<RepoListEntryV1>,
103    ) -> Self {
104        Self::allowed_with_full_listing(
105            sandbox_root,
106            requested_path,
107            entries.clone(),
108            entries.len(),
109            DisplayDigestV1::for_json_value(
110                &serde_json::to_value(&entries).unwrap_or(serde_json::Value::Null),
111            ),
112        )
113    }
114
115    pub fn allowed_with_full_listing(
116        sandbox_root: impl Into<String>,
117        requested_path: impl Into<String>,
118        entries: Vec<RepoListEntryV1>,
119        total_entries: usize,
120        full_listing_digest: DisplayDigestV1,
121    ) -> Self {
122        let listing = serde_json::to_value(&entries).unwrap_or(serde_json::Value::Null);
123        let returned_entries = entries.len();
124        let truncated = returned_entries < total_entries;
125        Self {
126            receipt_id: display_only_unstable_id("repo-list"),
127            kind: ArtifactKindV1::RepoList,
128            tool_id: "aidens:repo-list:1".into(),
129            sandbox_root: sandbox_root.into(),
130            requested_path: requested_path.into(),
131            entries,
132            listing_digest: DisplayDigestV1::for_json_value(&listing),
133            total_entries,
134            returned_entries,
135            truncated,
136            full_listing_digest: Some(full_listing_digest),
137            allowed: true,
138            reason_codes: if truncated {
139                vec![
140                    "sandbox-list-allowed".into(),
141                    "repo-list-truncated-with-full-digest".into(),
142                ]
143            } else {
144                vec!["sandbox-list-allowed".into()]
145            },
146            listed_at: Utc::now(),
147        }
148    }
149
150    pub fn denied(
151        sandbox_root: impl Into<String>,
152        requested_path: impl Into<String>,
153        reason: impl Into<String>,
154    ) -> Self {
155        Self {
156            receipt_id: display_only_unstable_id("repo-list"),
157            kind: ArtifactKindV1::RepoList,
158            tool_id: "aidens:repo-list:1".into(),
159            sandbox_root: sandbox_root.into(),
160            requested_path: requested_path.into(),
161            entries: Vec::new(),
162            listing_digest: DisplayDigestV1::for_json_value(&serde_json::json!([])),
163            total_entries: 0,
164            returned_entries: 0,
165            truncated: false,
166            full_listing_digest: Some(DisplayDigestV1::for_json_value(&serde_json::json!([]))),
167            allowed: false,
168            reason_codes: vec![reason.into()],
169            listed_at: Utc::now(),
170        }
171    }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
175pub struct PatchProposalV1 {
176    pub proposal_id: ArtifactId,
177    pub kind: ArtifactKindV1,
178    pub summary: String,
179    pub unified_diff: String,
180    pub touched_paths: Vec<String>,
181    pub diff_digest: DisplayDigestV1,
182    pub mutates_files: bool,
183    #[serde(default, skip_serializing_if = "Vec::is_empty")]
184    pub reason_codes: Vec<String>,
185    pub proposed_at: DateTime<Utc>,
186}
187
188impl PatchProposalV1 {
189    pub fn new(
190        summary: impl Into<String>,
191        unified_diff: impl Into<String>,
192        touched_paths: Vec<String>,
193    ) -> Self {
194        let unified_diff = unified_diff.into();
195        Self {
196            proposal_id: display_only_unstable_id("patch-proposal"),
197            kind: ArtifactKindV1::PatchProposal,
198            summary: summary.into(),
199            diff_digest: DisplayDigestV1::for_text(&unified_diff),
200            unified_diff,
201            touched_paths: sorted_unique_strings(touched_paths),
202            mutates_files: false,
203            reason_codes: vec!["proposal-only-no-file-mutation".into()],
204            proposed_at: Utc::now(),
205        }
206    }
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
210pub struct PatchApplyReportV1 {
211    pub receipt_id: ArtifactId,
212    pub kind: ArtifactKindV1,
213    pub tool_id: String,
214    pub sandbox_root: String,
215    pub proposal_id: Option<ArtifactId>,
216    pub permit_grant_id: Option<ArtifactId>,
217    pub permit_use_receipt_id: Option<ArtifactId>,
218    pub touched_paths: Vec<String>,
219    pub input_digest: DisplayDigestV1,
220    pub before_digests: BTreeMap<String, DisplayDigestV1>,
221    pub after_digests: BTreeMap<String, DisplayDigestV1>,
222    pub applied: bool,
223    #[serde(default, skip_serializing_if = "Vec::is_empty")]
224    pub changed_files: Vec<String>,
225    #[serde(default)]
226    pub dry_run_checked: bool,
227    #[serde(default, skip_serializing_if = "String::is_empty")]
228    pub semantic_status: String,
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub failure_kind: Option<String>,
231    #[serde(default, skip_serializing_if = "Vec::is_empty")]
232    pub rollback_advice: Vec<String>,
233    #[serde(default, skip_serializing_if = "Vec::is_empty")]
234    pub reason_codes: Vec<String>,
235    pub applied_at: DateTime<Utc>,
236}
237
238impl PatchApplyReportV1 {
239    pub fn new(
240        sandbox_root: impl Into<String>,
241        input: &serde_json::Value,
242        touched_paths: Vec<String>,
243        before_digests: BTreeMap<String, DisplayDigestV1>,
244        after_digests: BTreeMap<String, DisplayDigestV1>,
245        permit_grant_id: Option<ArtifactId>,
246        permit_use_receipt_id: Option<ArtifactId>,
247    ) -> Self {
248        Self {
249            receipt_id: display_only_unstable_id("patch-apply"),
250            kind: ArtifactKindV1::PatchApply,
251            tool_id: "aidens:patch-apply:1".into(),
252            sandbox_root: sandbox_root.into(),
253            proposal_id: None,
254            permit_grant_id,
255            permit_use_receipt_id,
256            touched_paths: sorted_unique_strings(touched_paths.clone()),
257            input_digest: DisplayDigestV1::for_json_value(input),
258            before_digests,
259            after_digests,
260            applied: true,
261            changed_files: sorted_unique_strings(touched_paths),
262            dry_run_checked: true,
263            semantic_status: "exact_check".into(),
264            failure_kind: None,
265            rollback_advice: vec![
266                "Use before_digests to verify the pre-apply state before manual rollback.".into(),
267                "Reconstruct prior file contents from version control or the original sandbox fixture.".into(),
268            ],
269            reason_codes: vec!["patch-applied-with-explicit-permit".into()],
270            applied_at: Utc::now(),
271        }
272    }
273
274    pub fn checked(
275        sandbox_root: impl Into<String>,
276        input: &serde_json::Value,
277        touched_paths: Vec<String>,
278        before_digests: BTreeMap<String, DisplayDigestV1>,
279        after_digests: BTreeMap<String, DisplayDigestV1>,
280        permit_grant_id: Option<ArtifactId>,
281        permit_use_receipt_id: Option<ArtifactId>,
282    ) -> Self {
283        Self {
284            receipt_id: display_only_unstable_id("patch-apply"),
285            kind: ArtifactKindV1::PatchApply,
286            tool_id: "aidens:patch-apply:1".into(),
287            sandbox_root: sandbox_root.into(),
288            proposal_id: None,
289            permit_grant_id,
290            permit_use_receipt_id,
291            touched_paths: sorted_unique_strings(touched_paths.clone()),
292            input_digest: DisplayDigestV1::for_json_value(input),
293            before_digests,
294            after_digests,
295            applied: false,
296            changed_files: sorted_unique_strings(touched_paths),
297            dry_run_checked: true,
298            semantic_status: "exact_check".into(),
299            failure_kind: None,
300            rollback_advice: vec![
301                "No rollback required; check_only mode did not mutate files.".into(),
302            ],
303            reason_codes: vec!["patch-validated-without-application".into()],
304            applied_at: Utc::now(),
305        }
306    }
307
308    pub fn denied(
309        sandbox_root: impl Into<String>,
310        input: &serde_json::Value,
311        reason: impl Into<String>,
312    ) -> Self {
313        Self::denied_with_details(
314            sandbox_root,
315            input,
316            reason,
317            "invalid-patch",
318            Vec::new(),
319            None,
320            None,
321        )
322    }
323
324    pub fn denied_with_details(
325        sandbox_root: impl Into<String>,
326        input: &serde_json::Value,
327        reason: impl Into<String>,
328        failure_kind: impl Into<String>,
329        touched_paths: Vec<String>,
330        permit_grant_id: Option<ArtifactId>,
331        permit_use_receipt_id: Option<ArtifactId>,
332    ) -> Self {
333        Self {
334            receipt_id: display_only_unstable_id("patch-apply"),
335            kind: ArtifactKindV1::PatchApply,
336            tool_id: "aidens:patch-apply:1".into(),
337            sandbox_root: sandbox_root.into(),
338            proposal_id: None,
339            permit_grant_id,
340            permit_use_receipt_id,
341            touched_paths: sorted_unique_strings(touched_paths.clone()),
342            input_digest: DisplayDigestV1::for_json_value(input),
343            before_digests: BTreeMap::new(),
344            after_digests: BTreeMap::new(),
345            applied: false,
346            changed_files: sorted_unique_strings(touched_paths),
347            dry_run_checked: true,
348            semantic_status: "failed_exact_check".into(),
349            failure_kind: Some(failure_kind.into()),
350            rollback_advice: vec![
351                "No files were written by this failed-closed patch attempt.".into(),
352                "Regenerate a single-file unified diff with unique removal context before retrying.".into(),
353            ],
354            reason_codes: vec![reason.into()],
355            applied_at: Utc::now(),
356        }
357    }
358}
359
360#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
361pub struct CommandRunReportV1 {
362    pub receipt_id: ArtifactId,
363    pub kind: ArtifactKindV1,
364    pub tool_id: String,
365    pub sandbox_root: String,
366    pub command: Vec<String>,
367    pub permit_grant_id: Option<ArtifactId>,
368    pub permit_use_receipt_id: Option<ArtifactId>,
369    pub allowed_by_policy: bool,
370    pub exit_code: Option<i32>,
371    pub stdout_digest: DisplayDigestV1,
372    pub stderr_digest: DisplayDigestV1,
373    pub stdout_bytes: usize,
374    pub stderr_bytes: usize,
375    pub timed_out: bool,
376    pub succeeded: bool,
377    #[serde(default, skip_serializing_if = "Vec::is_empty")]
378    pub reason_codes: Vec<String>,
379    pub started_at: DateTime<Utc>,
380    pub completed_at: DateTime<Utc>,
381}
382
383impl CommandRunReportV1 {
384    pub fn completed(
385        sandbox_root: impl Into<String>,
386        command: Vec<String>,
387        permit_grant_id: Option<ArtifactId>,
388        permit_use_receipt_id: Option<ArtifactId>,
389        exit_code: Option<i32>,
390        stdout: &str,
391        stderr: &str,
392    ) -> Self {
393        let succeeded = exit_code == Some(0);
394        Self {
395            receipt_id: display_only_unstable_id("command-run"),
396            kind: ArtifactKindV1::CommandRun,
397            tool_id: "aidens:run-checks:1".into(),
398            sandbox_root: sandbox_root.into(),
399            command,
400            permit_grant_id,
401            permit_use_receipt_id,
402            allowed_by_policy: true,
403            exit_code,
404            stdout_digest: DisplayDigestV1::for_text(stdout),
405            stderr_digest: DisplayDigestV1::for_text(stderr),
406            stdout_bytes: stdout.len(),
407            stderr_bytes: stderr.len(),
408            timed_out: false,
409            succeeded,
410            reason_codes: if succeeded {
411                vec!["allowed-check-command-succeeded".into()]
412            } else {
413                vec!["allowed-check-command-failed".into()]
414            },
415            started_at: Utc::now(),
416            completed_at: Utc::now(),
417        }
418    }
419
420    pub fn blocked(
421        sandbox_root: impl Into<String>,
422        command: Vec<String>,
423        reason: impl Into<String>,
424    ) -> Self {
425        let now = Utc::now();
426        Self {
427            receipt_id: display_only_unstable_id("command-run"),
428            kind: ArtifactKindV1::CommandRun,
429            tool_id: "aidens:run-checks:1".into(),
430            sandbox_root: sandbox_root.into(),
431            command,
432            permit_grant_id: None,
433            permit_use_receipt_id: None,
434            allowed_by_policy: false,
435            exit_code: None,
436            stdout_digest: DisplayDigestV1::for_text(""),
437            stderr_digest: DisplayDigestV1::for_text(""),
438            stdout_bytes: 0,
439            stderr_bytes: 0,
440            timed_out: false,
441            succeeded: false,
442            reason_codes: vec![reason.into()],
443            started_at: now,
444            completed_at: now,
445        }
446    }
447}
448
449#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
450pub struct SandboxCapabilityTruthV1 {
451    pub truth_id: ArtifactId,
452    pub kind: ArtifactKindV1,
453    pub sandbox_root: String,
454    pub denied_prefixes: Vec<String>,
455    pub symlink_policy: String,
456    pub env_allowlist: Vec<String>,
457    pub network_policy: String,
458    pub process_timeout_millis: u64,
459    pub write_requires_permit: bool,
460    pub shell_requires_permit: bool,
461    #[serde(default, skip_serializing_if = "Vec::is_empty")]
462    pub reason_codes: Vec<String>,
463    pub checked_at: DateTime<Utc>,
464}
465
466impl SandboxCapabilityTruthV1 {
467    pub fn coding_default(sandbox_root: impl Into<String>) -> Self {
468        Self {
469            truth_id: display_only_unstable_id("sandbox-truth"),
470            kind: ArtifactKindV1::SandboxTruth,
471            sandbox_root: sandbox_root.into(),
472            denied_prefixes: vec![
473                ".ssh".into(),
474                ".gnupg".into(),
475                ".cargo".into(),
476                ".recall".into(),
477                ".password-store".into(),
478            ],
479            symlink_policy: "canonicalize-and-require-within-root".into(),
480            env_allowlist: vec!["PATH".into(), "CARGO_HOME".into(), "RUSTUP_HOME".into()],
481            network_policy: "disabled".into(),
482            process_timeout_millis: 120_000,
483            write_requires_permit: true,
484            shell_requires_permit: true,
485            reason_codes: vec![
486                "no-shell-file-write-or-network-by-default".into(),
487                "side-effects-require-scoped-permit".into(),
488            ],
489            checked_at: Utc::now(),
490        }
491    }
492}
493
494#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
495pub struct CodexPacketV1 {
496    pub packet_id: ArtifactId,
497    pub kind: ArtifactKindV1,
498    pub current_pass: String,
499    pub next_pass: String,
500    pub source_basis: String,
501    pub issue: String,
502    pub source_map: Vec<String>,
503    pub changed_files: Vec<String>,
504    pub commands_run: Vec<CommandRunReportV1>,
505    pub receipt_ids: Vec<ArtifactId>,
506    pub blockers: Vec<String>,
507    pub notes: Vec<String>,
508    pub generated_at: DateTime<Utc>,
509}
510
511#[derive(Debug, Clone, PartialEq, Eq)]
512pub struct CodexPacketInputV1 {
513    pub current_pass: String,
514    pub next_pass: String,
515    pub issue: String,
516    pub source_map: Vec<String>,
517    pub changed_files: Vec<String>,
518    pub commands_run: Vec<CommandRunReportV1>,
519    pub receipt_ids: Vec<ArtifactId>,
520    pub blockers: Vec<String>,
521    pub notes: Vec<String>,
522}
523
524impl CodexPacketV1 {
525    pub fn new(input: CodexPacketInputV1) -> Self {
526        Self {
527            packet_id: display_only_unstable_id("codex-packet"),
528            kind: ArtifactKindV1::CodexPacket,
529            current_pass: input.current_pass,
530            next_pass: input.next_pass,
531            source_basis: "SOURCE_BASIS.md".into(),
532            issue: input.issue,
533            source_map: sorted_unique_strings(input.source_map),
534            changed_files: sorted_unique_strings(input.changed_files),
535            commands_run: input.commands_run,
536            receipt_ids: input.receipt_ids,
537            blockers: input.blockers,
538            notes: input.notes,
539            generated_at: Utc::now(),
540        }
541    }
542
543    pub fn has_resume_context(&self) -> bool {
544        !self.current_pass.trim().is_empty()
545            && !self.next_pass.trim().is_empty()
546            && !self.source_basis.trim().is_empty()
547            && !self.issue.trim().is_empty()
548            && !self.source_map.is_empty()
549    }
550}