Skip to main content

a3s_code_core/
completion_attestor.rs

1//! Host-only completion attestation (#160).
2//!
3//! The completion gate stays non-bypassable. Hosts that orchestrate third-party
4//! AgentDirs cannot predict [`MutationLedger::digest`](crate::harness_loop::MutationLedger::digest)
5//! at session build, so exact waivers are unreachable. A [`CompletionAttestor`]
6//! is invoked with the live effect digest and mutated paths **after** they exist
7//! and **before** [`decide_with_observations`](crate::harness_loop::decide_with_observations).
8//!
9//! Each path is paired with the ledger's content digest so a host can re-read
10//! the file and compare like-for-like without reaching into harness internals
11//! (follow-up shape from community PR #172).
12//!
13//! The attestor may only supply a [`VerificationReport`].
14//! The gate still requires a Passed, digest-bound report. The attestor is not a
15//! tool and is not model-grantable. There is no `Observe` policy that lets an
16//! unverified mutation complete.
17
18use std::sync::Arc;
19
20use crate::harness_loop::MutationLedger;
21use crate::read_only_verifier::{accept_report, ReportAuthor};
22use crate::verification::VerificationReport;
23
24/// One mutated path with the content digest the ledger recorded for it.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct MutatedPathRecord {
27    /// Workspace-relative path as recorded on the ledger.
28    pub path: String,
29    /// Content digest from [`MutationLedger::content_digest_for_path`].
30    pub content_digest: String,
31}
32
33/// Host-supplied evidence binder for the completion gate.
34///
35/// Return `None` to leave the report list unchanged. A returned report still
36/// must [`report_binds_pass`](crate::harness_loop) (digest match + required
37/// checks Passed) for the gate to allow the run.
38pub trait CompletionAttestor: Send + Sync {
39    /// Called once the mutation ledger digest and paths exist.
40    fn attest(
41        &self,
42        effect_digest: &str,
43        paths: &[MutatedPathRecord],
44    ) -> Option<VerificationReport>;
45}
46
47/// Invoke the host attestor (if any) and append an accepted Host report.
48///
49/// No-op when the ledger is empty, the attestor is absent, or attestation
50/// returns `None` / is rejected as non-Host.
51pub fn merge_attested_report(
52    attestor: Option<&Arc<dyn CompletionAttestor>>,
53    ledger: &MutationLedger,
54    reports: &mut Vec<VerificationReport>,
55) {
56    let Some(attestor) = attestor else {
57        return;
58    };
59    if ledger.is_empty() {
60        return;
61    }
62    let digest = ledger.digest();
63    if digest.trim().is_empty() {
64        return;
65    }
66    let paths: Vec<MutatedPathRecord> = ledger
67        .paths()
68        .map(|path| MutatedPathRecord {
69            path: path.to_string(),
70            content_digest: ledger
71                .content_digest_for_path(path)
72                .unwrap_or("")
73                .to_string(),
74        })
75        .collect();
76    let Some(report) = attestor.attest(digest, &paths) else {
77        return;
78    };
79    let Some(report) = accept_report(ReportAuthor::Host, report) else {
80        return;
81    };
82    reports.push(report);
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use crate::harness_loop::{decide_with_observations, MutationLedger};
89    use crate::verification::{VerificationCheck, VerificationStatus};
90
91    struct BindingAttestor;
92
93    impl CompletionAttestor for BindingAttestor {
94        fn attest(
95            &self,
96            effect_digest: &str,
97            paths: &[MutatedPathRecord],
98        ) -> Option<VerificationReport> {
99            assert!(!paths.is_empty());
100            assert!(paths.iter().any(|p| !p.content_digest.is_empty()));
101            Some(
102                VerificationReport::new(
103                    "host:attestor",
104                    vec![VerificationCheck::required(
105                        "host:effect",
106                        "host_attestation",
107                        "host reconciled mutated paths",
108                    )
109                    .with_status(VerificationStatus::Passed)],
110                )
111                .with_effect_digest(effect_digest.to_string()),
112            )
113        }
114    }
115
116    struct SilentAttestor;
117
118    impl CompletionAttestor for SilentAttestor {
119        fn attest(
120            &self,
121            _effect_digest: &str,
122            _paths: &[MutatedPathRecord],
123        ) -> Option<VerificationReport> {
124            None
125        }
126    }
127
128    fn ledger_with_write(path: &str) -> MutationLedger {
129        let mut ledger = MutationLedger::default();
130        ledger.observe_tool(
131            "write",
132            0,
133            Some(&serde_json::json!({"file_path": path, "after": "fn main() {}"})),
134        );
135        ledger
136    }
137
138    #[test]
139    fn attestor_binding_report_closes_gate() {
140        let ledger = ledger_with_write("out.txt");
141        let mut reports = Vec::new();
142        merge_attested_report(
143            Some(&(Arc::new(BindingAttestor) as Arc<dyn CompletionAttestor>)),
144            &ledger,
145            &mut reports,
146        );
147        assert_eq!(reports.len(), 1);
148        let gate = decide_with_observations(&ledger, &reports, &[], false, &[]);
149        assert!(matches!(
150            gate,
151            crate::harness_loop::CompletionGate::Allow(
152                crate::harness_loop::CompletionTerminal::Verified { .. }
153            )
154        ));
155    }
156
157    #[test]
158    fn silent_attestor_leaves_gate_incomplete() {
159        let ledger = ledger_with_write("out.txt");
160        let mut reports = Vec::new();
161        merge_attested_report(
162            Some(&(Arc::new(SilentAttestor) as Arc<dyn CompletionAttestor>)),
163            &ledger,
164            &mut reports,
165        );
166        assert!(reports.is_empty());
167        let gate = decide_with_observations(&ledger, &reports, &[], false, &[]);
168        assert!(matches!(
169            gate,
170            crate::harness_loop::CompletionGate::Incomplete { .. }
171        ));
172    }
173
174    #[test]
175    fn empty_ledger_skips_attestor() {
176        struct PanicAttestor;
177        impl CompletionAttestor for PanicAttestor {
178            fn attest(&self, _: &str, _: &[MutatedPathRecord]) -> Option<VerificationReport> {
179                panic!("must not run on empty ledger");
180            }
181        }
182        let mut reports = Vec::new();
183        merge_attested_report(
184            Some(&(Arc::new(PanicAttestor) as Arc<dyn CompletionAttestor>)),
185            &MutationLedger::default(),
186            &mut reports,
187        );
188        assert!(reports.is_empty());
189    }
190
191    #[test]
192    fn mismatched_digest_report_cannot_bypass_gate() {
193        struct WrongDigestAttestor;
194        impl CompletionAttestor for WrongDigestAttestor {
195            fn attest(
196                &self,
197                _effect_digest: &str,
198                paths: &[MutatedPathRecord],
199            ) -> Option<VerificationReport> {
200                assert!(!paths.is_empty());
201                Some(
202                    VerificationReport::new(
203                        "host:attestor",
204                        vec![VerificationCheck::required(
205                            "host:effect",
206                            "host_attestation",
207                            "wrong digest on purpose",
208                        )
209                        .with_status(VerificationStatus::Passed)],
210                    )
211                    .with_effect_digest("sha256:not-the-ledger-digest".to_string()),
212                )
213            }
214        }
215        let ledger = ledger_with_write("out.txt");
216        let mut reports = Vec::new();
217        merge_attested_report(
218            Some(&(Arc::new(WrongDigestAttestor) as Arc<dyn CompletionAttestor>)),
219            &ledger,
220            &mut reports,
221        );
222        assert_eq!(reports.len(), 1);
223        let gate = decide_with_observations(&ledger, &reports, &[], false, &[]);
224        assert!(matches!(
225            gate,
226            crate::harness_loop::CompletionGate::Incomplete { .. }
227        ));
228    }
229}