speccoder_runtime/attest/
statement.rs1use serde_json::{Map, Value, json};
5
6pub const STATEMENT: &str = "https://in-toto.io/Statement/v1";
8pub const TEST_RESULT: &str = "https://in-toto.io/attestation/test-result/v0.1";
10pub const VSA: &str = "https://slsa.dev/verification_summary/v1";
12pub const TASK_VERIFICATION: &str = "https://speccoder.dev/task-verification/v0";
14pub const VERIFIER: &str = "https://speccoder.dev/verifier/v0";
16pub const POLICY: &str = "https://speccoder.dev/policy/judge-config/v0";
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Subject {
22 pub name: String,
24 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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum Phase {
70 Baseline,
72 Red,
74 Green,
76 HeldOut,
78}
79
80impl Phase {
81 #[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#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct PhaseResult {
96 pub phase: Phase,
98 pub result: &'static str,
100 pub passed: Vec<String>,
102 pub warned: Vec<String>,
104 pub failed: Vec<String>,
106 pub config_sha256: String,
108}
109
110#[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#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct Summary {
131 pub speccoder: String,
133 pub agentplane: String,
135 pub time: String,
137 pub resource_uri: String,
139 pub policy_sha256: String,
141 pub inputs: Vec<(String, String)>,
143 pub verdict: String,
145}
146
147#[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
173pub 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 assert!(check_clean(&json!({ "u": "git+https://github.com/o/r@abc" })).is_ok());
305 }
306}