Skip to main content

speccoder_runtime/attest/
statement.rs

1//! in-toto Statements v1 for a ticked task: `test-result` per test phase, a
2//! SLSA verification summary (`vsa`), and speccoder's `task-verification`.
3
4use serde_json::{Map, Value, json};
5
6/// Statement v1's `_type`.
7pub const STATEMENT: &str = "https://in-toto.io/Statement/v1";
8/// in-toto's test-result predicate.
9pub const TEST_RESULT: &str = "https://in-toto.io/attestation/test-result/v0.1";
10/// SLSA's verification summary.
11pub const VSA: &str = "https://slsa.dev/verification_summary/v1";
12/// speccoder's own: this task was verified against this spec.
13pub const TASK_VERIFICATION: &str = "https://speccoder.dev/task-verification/v0";
14/// Who verifies, in a VSA.
15pub const VERIFIER: &str = "https://speccoder.dev/verifier/v0";
16/// The policy a VSA names: the judge's configuration.
17pub const POLICY: &str = "https://speccoder.dev/policy/judge-config/v0";
18
19/// What every statement of one tick is about: the task commit.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Subject {
22    /// `<feature>/<task>`.
23    pub name: String,
24    /// The commit's object id (40 or 64 hex).
25    pub commit: String,
26}
27
28impl Subject {
29    fn value(&self) -> Value {
30        json!([{ "name": self.name, "digest": { "gitCommit": self.commit } }])
31    }
32}
33
34/// A statement about `subject`.
35#[must_use]
36pub fn statement(subject: &Subject, predicate_type: &str, predicate: Value) -> Value {
37    json!({
38        "_type": STATEMENT,
39        "subject": subject.value(),
40        "predicateType": predicate_type,
41        "predicate": predicate,
42    })
43}
44
45/// The canonical bytes of `v`: keys sorted at every level, no whitespace.
46/// What is signed, so it must not depend on how a map was built.
47#[must_use]
48pub fn canonical(v: &Value) -> Vec<u8> {
49    fn sorted(v: &Value) -> Value {
50        match v {
51            Value::Object(m) => {
52                let mut keys: Vec<&String> = m.keys().collect();
53                keys.sort();
54                let mut out = Map::new();
55                for k in keys {
56                    out.insert(k.clone(), sorted(&m[k]));
57                }
58                Value::Object(out)
59            }
60            Value::Array(a) => Value::Array(a.iter().map(sorted).collect()),
61            other => other.clone(),
62        }
63    }
64    serde_json::to_vec(&sorted(v)).unwrap_or_default()
65}
66
67/// A test phase of one tick.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum Phase {
70    /// The checks before the attempt (pre-existing failures are warnings).
71    Baseline,
72    /// The acceptance tests before the attempt: red is expected.
73    Red,
74    /// The acceptance tests and the checks after the attempt.
75    Green,
76    /// The tests the implementer never saw.
77    HeldOut,
78}
79
80impl Phase {
81    /// The phase's name in `configuration[0].name`.
82    #[must_use]
83    pub const fn name(self) -> &'static str {
84        match self {
85            Self::Baseline => "baseline",
86            Self::Red => "red",
87            Self::Green => "green",
88            Self::HeldOut => "held_out",
89        }
90    }
91}
92
93/// One phase's result.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct PhaseResult {
96    /// Which.
97    pub phase: Phase,
98    /// `PASSED`, `WARNED` or `FAILED`.
99    pub result: &'static str,
100    /// Tests that passed.
101    pub passed: Vec<String>,
102    /// Tests that failed before and still do (baseline).
103    pub warned: Vec<String>,
104    /// Tests that failed.
105    pub failed: Vec<String>,
106    /// sha256 of what ran (the checks' or acceptance tests' argv).
107    pub config_sha256: String,
108}
109
110/// The `test-result` predicate of `r`.
111#[must_use]
112pub fn test_result(r: &PhaseResult) -> Value {
113    let mut p = json!({
114        "result": r.result,
115        "configuration": [{
116            "name": format!("speccoder:phase/{}", r.phase.name()),
117            "digest": { "sha256": r.config_sha256 },
118        }],
119        "passedTests": r.passed,
120        "failedTests": r.failed,
121    });
122    if !r.warned.is_empty() {
123        p["warnedTests"] = json!(r.warned);
124    }
125    p
126}
127
128/// What a VSA summarises.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct Summary {
131    /// speccoder's version.
132    pub speccoder: String,
133    /// agentplane's version.
134    pub agentplane: String,
135    /// RFC 3339 UTC.
136    pub time: String,
137    /// `git+<origin>@<commit>` or `git:<commit>`.
138    pub resource_uri: String,
139    /// The judge-config hash.
140    pub policy_sha256: String,
141    /// `(name, sha256 of that line's payload)` of every other line of the tick.
142    pub inputs: Vec<(String, String)>,
143    /// `verified` or `regression_only`.
144    pub verdict: String,
145}
146
147/// The VSA predicate of `s`.
148#[must_use]
149pub fn vsa(s: &Summary) -> Value {
150    let level = if s.verdict == "verified" {
151        "SPECCODER_VERIFIED"
152    } else {
153        "SPECCODER_REGRESSION_ONLY"
154    };
155    json!({
156        "verifier": {
157            "id": VERIFIER,
158            "version": { "speccoder": s.speccoder, "agentplane": s.agentplane },
159        },
160        "timeVerified": s.time,
161        "resourceUri": s.resource_uri,
162        "policy": { "uri": POLICY, "digest": { "sha256": s.policy_sha256 } },
163        "inputAttestations": s
164            .inputs
165            .iter()
166            .map(|(name, sha)| json!({ "name": name, "digest": { "sha256": sha } }))
167            .collect::<Vec<_>>(),
168        "verificationResult": "PASSED",
169        "verifiedLevels": [level],
170    })
171}
172
173/// Why `v` may not go into a statement: an absolute local path, a URL with
174/// credentials, or something shaped like a secret.
175pub fn check_clean(v: &Value) -> Result<(), String> {
176    let mut problem = None;
177    walk(v, &mut |s| {
178        if problem.is_none() {
179            problem = unclean(s);
180        }
181    });
182    problem.map_or(Ok(()), Err)
183}
184
185fn walk(v: &Value, f: &mut impl FnMut(&str)) {
186    match v {
187        Value::String(s) => f(s),
188        Value::Array(a) => a.iter().for_each(|x| walk(x, f)),
189        Value::Object(m) => m.values().for_each(|x| walk(x, f)),
190        _ => {}
191    }
192}
193
194fn unclean(s: &str) -> Option<String> {
195    let absolute = [
196        "/Users/",
197        "/home/",
198        "/private/",
199        "/var/folders/",
200        "/tmp/",
201        "/root/",
202    ];
203    if s.split_whitespace().any(|w| {
204        let w = w.trim_start_matches(['"', '\'', '(', '`']);
205        absolute.iter().any(|p| w.starts_with(p))
206            || (w.len() > 2 && w.as_bytes()[1] == b':' && w.as_bytes()[2] == b'\\')
207    }) {
208        return Some("an absolute local path".to_owned());
209    }
210    if let Some(i) = s.find("://") {
211        let rest = &s[i + 3..];
212        let host_end = rest.find(['/', ' ']).unwrap_or(rest.len());
213        if rest[..host_end].contains('@') {
214            return Some("a URL with credentials".to_owned());
215        }
216    }
217    let secret = [
218        "sk-ant-",
219        "sk-proj-",
220        "ghp_",
221        "github_pat_",
222        "AKIA",
223        "xoxb-",
224        "-----BEGIN",
225    ];
226    if secret.iter().any(|p| s.contains(p)) {
227        return Some("something shaped like a secret".to_owned());
228    }
229    None
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    fn subject() -> Subject {
237        Subject {
238            name: "001-demo/T004".to_owned(),
239            commit: "3f1c".repeat(10),
240        }
241    }
242
243    #[test]
244    fn statements_are_in_toto_v1_about_the_commit() {
245        let s = statement(&subject(), TEST_RESULT, json!({}));
246        assert_eq!(s["_type"], STATEMENT);
247        assert_eq!(s["subject"][0]["name"], "001-demo/T004");
248        assert_eq!(s["subject"][0]["digest"]["gitCommit"], "3f1c".repeat(10));
249    }
250
251    #[test]
252    fn canonical_bytes_sort_keys_at_every_level() {
253        let a = json!({ "b": 1, "a": { "d": 2, "c": [ { "z": 0, "y": 1 } ] } });
254        assert_eq!(
255            String::from_utf8(canonical(&a)).unwrap(),
256            r#"{"a":{"c":[{"y":1,"z":0}],"d":2},"b":1}"#
257        );
258    }
259
260    #[test]
261    fn a_phase_names_itself_and_lists_its_tests() {
262        let p = test_result(&PhaseResult {
263            phase: Phase::Baseline,
264            result: "WARNED",
265            passed: vec!["a".to_owned()],
266            warned: vec!["flaky::old".to_owned()],
267            failed: Vec::new(),
268            config_sha256: "00".repeat(32),
269        });
270        assert_eq!(p["configuration"][0]["name"], "speccoder:phase/baseline");
271        assert_eq!(p["warnedTests"][0], "flaky::old");
272        assert_eq!(p["result"], "WARNED");
273    }
274
275    #[test]
276    fn a_summary_names_its_level_and_inputs() {
277        let v = vsa(&Summary {
278            speccoder: "0.1.0".to_owned(),
279            agentplane: "0.52.0".to_owned(),
280            time: "2026-10-24T10:00:00Z".to_owned(),
281            resource_uri: "git:abc".to_owned(),
282            policy_sha256: "11".repeat(32),
283            inputs: vec![("test-result/green".to_owned(), "22".repeat(32))],
284            verdict: "regression_only".to_owned(),
285        });
286        assert_eq!(v["verifiedLevels"][0], "SPECCODER_REGRESSION_ONLY");
287        assert_eq!(v["inputAttestations"][0]["name"], "test-result/green");
288        assert_eq!(v["policy"]["uri"], POLICY);
289    }
290
291    #[test]
292    fn no_secret_or_local_path_goes_into_a_statement() {
293        assert!(check_clean(&json!({ "t": ["cargo-test::parse::empty"] })).is_ok());
294        for bad in [
295            json!({ "p": "/Users/ada/src/x.rs" }),
296            json!({ "p": ["see (/home/ada/x)"] }),
297            json!({ "u": "https://ada:hunter2@github.com/o/r" }),
298            json!({ "k": "token sk-ant-api03-xxxx" }),
299            json!({ "w": r"C:\Users\ada" }),
300        ] {
301            assert!(check_clean(&bad).is_err(), "{bad}");
302        }
303        // A remote without credentials is fine.
304        assert!(check_clean(&json!({ "u": "git+https://github.com/o/r@abc" })).is_ok());
305    }
306}