1pub mod dsse;
11pub mod notes;
12pub mod policy;
13pub mod sign;
14pub mod statement;
15pub mod verify;
16
17use std::path::Path;
18
19use serde_json::{Value, json};
20use speccoder_core::RunReport;
21
22use self::dsse::Envelope;
23use self::statement::{Phase, PhaseResult, Subject};
24use crate::substrate::run_config::AttestConfig;
25
26#[must_use]
28pub fn outcome(signed: bool, signer: Option<&str>, reason: Option<&str>) -> Value {
29 json!({
30 "state": if signed { "signed" } else { "unsigned" },
31 "signer": signer,
32 "reason": reason,
33 })
34}
35
36#[derive(Debug)]
38pub struct Tick<'a> {
39 pub repo: &'a Path,
41 pub config: &'a str,
43 pub attest: &'a AttestConfig,
45 pub format: &'a str,
47 pub feature: &'a str,
49 pub feature_dir: &'a str,
51 pub task: &'a str,
53 pub title: &'a str,
55 pub line_hash: &'a str,
57 pub section: Option<&'a str>,
59 pub section_text: &'a str,
61 pub requirements: &'a [(String, String)],
64 pub commit: &'a str,
66 pub verdict: &'a str,
68 pub sandbox: Value,
70 pub executor: Value,
72 pub model: Value,
74}
75
76#[must_use]
78pub fn cites(title: &str, id: &str) -> bool {
79 title
80 .split(|c: char| !(c.is_ascii_alphanumeric() || c == '-'))
81 .any(|word| word == id)
82}
83
84fn trace(t: &Tick<'_>, report: &RunReport) -> Vec<Value> {
89 let mut rows: Vec<Value> = t
90 .requirements
91 .iter()
92 .filter(|(id, _)| cites(t.title, id))
93 .map(|(id, hash)| {
94 json!({ "requirement": id, "requirement_hash": hash, "phase": "green", "result": t.verdict })
95 })
96 .collect();
97 let tests = report
98 .acceptance
99 .as_ref()
100 .and_then(|a| a["tests"].as_array())
101 .into_iter()
102 .flatten();
103 for test in tests {
104 let path = test["path"].as_str().unwrap_or_default();
105 let cited: Vec<&str> = test["scenarios"]
106 .as_array()
107 .into_iter()
108 .flatten()
109 .filter_map(Value::as_str)
110 .collect();
111 let row = |scenario: Option<&str>| {
112 json!({
113 "requirement": t.section,
114 "scenario": scenario,
115 "test": path,
116 "phase": "green",
117 "result": t.verdict,
118 })
119 };
120 if cited.is_empty() {
121 rows.push(row(None));
122 }
123 rows.extend(cited.into_iter().map(|s| row(Some(s))));
124 }
125 rows
126}
127
128#[must_use]
130pub fn now_rfc3339() -> String {
131 let secs = std::time::SystemTime::now()
132 .duration_since(std::time::UNIX_EPOCH)
133 .map_or(0, |d| d.as_secs());
134 let days = i64::try_from(secs / 86_400).unwrap_or(0);
135 let rem = secs % 86_400;
136 let z = days + 719_468;
138 let era = z.div_euclid(146_097);
139 let doe = z.rem_euclid(146_097);
140 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
141 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
142 let mp = (5 * doy + 2) / 153;
143 let day = doy - (153 * mp + 2) / 5 + 1;
144 let month = if mp < 10 { mp + 3 } else { mp - 9 };
145 let year = yoe + era * 400 + i64::from(month <= 2);
146 format!(
147 "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
148 rem / 3600,
149 rem % 3600 / 60,
150 rem % 60
151 )
152}
153
154fn sha(text: &str) -> String {
155 crate::integrity::sha256_hex(text.as_bytes())
156}
157
158#[must_use]
160pub fn phases(report: &RunReport) -> Vec<PhaseResult> {
161 let mut out = Vec::new();
162 let checks_config = report
163 .verification
164 .as_ref()
165 .map(|v| {
166 v.checks
167 .iter()
168 .map(|c| c.argv.join(" "))
169 .collect::<Vec<_>>()
170 .join("\n")
171 })
172 .unwrap_or_default();
173 if let Some(cmp) = report
174 .verification
175 .as_ref()
176 .and_then(|v| v.comparison.as_ref())
177 {
178 out.push(PhaseResult {
179 phase: Phase::Baseline,
180 result: if cmp.pre_existing.is_empty() {
181 "PASSED"
182 } else {
183 "WARNED"
184 },
185 passed: Vec::new(),
186 warned: cmp.pre_existing.clone(),
187 failed: Vec::new(),
188 config_sha256: sha(&checks_config),
189 });
190 }
191 let acceptance = report.acceptance.as_ref();
192 let acceptance_config = acceptance
193 .map(|a| a["argv"].to_string())
194 .unwrap_or_default();
195 let acceptance_files: Vec<String> = acceptance
196 .and_then(|a| a["files"].as_array())
197 .map(|f| {
198 f.iter()
199 .filter_map(|x| x.as_str().map(str::to_owned))
200 .collect()
201 })
202 .unwrap_or_default();
203 if acceptance.is_some_and(|a| a["red"] == true) {
204 out.push(PhaseResult {
205 phase: Phase::Red,
206 result: "FAILED",
207 passed: Vec::new(),
208 warned: Vec::new(),
209 failed: acceptance_files.clone(),
210 config_sha256: sha(&acceptance_config),
211 });
212 }
213 if let Some(v) = &report.verification {
214 let mut passed = Vec::new();
215 let mut failed = Vec::new();
216 for c in &v.checks {
217 match &c.tests {
218 Some(tests) => {
219 for t in tests {
220 let id = format!("{}::{}", c.name, t.id);
221 match t.outcome {
222 speccoder_core::verify::TestOutcome::Passed => passed.push(id),
223 speccoder_core::verify::TestOutcome::Failed
224 | speccoder_core::verify::TestOutcome::Error => failed.push(id),
225 speccoder_core::verify::TestOutcome::Skipped => {}
226 }
227 }
228 }
229 None if c.status == speccoder_core::verify::CheckStatus::Passed => {
230 passed.push(c.name.clone());
231 }
232 None => failed.push(c.name.clone()),
233 }
234 }
235 passed.extend(acceptance_files);
236 out.push(PhaseResult {
237 phase: Phase::Green,
238 result: if v.passed { "PASSED" } else { "FAILED" },
239 passed,
240 warned: Vec::new(),
241 failed,
242 config_sha256: sha(&format!("{checks_config}\n{acceptance_config}")),
243 });
244 }
245 if let Some(h) = acceptance
246 .and_then(|a| a.get("held_out"))
247 .filter(|h| h["state"] == "on")
248 {
249 out.push(PhaseResult {
250 phase: Phase::HeldOut,
251 result: "PASSED",
252 passed: Vec::new(),
253 warned: Vec::new(),
254 failed: Vec::new(),
255 config_sha256: h["manifest"]
256 .as_str()
257 .unwrap_or_default()
258 .trim_start_matches("sha256:")
259 .to_owned(),
260 });
261 }
262 out
263}
264
265fn decisions(tool_calls: &[Value]) -> Vec<Value> {
267 tool_calls
268 .iter()
269 .filter(|c| c["by"] == "person" || c["by"] == "expiry")
270 .map(|c| {
271 json!({
272 "verb": if c["approved_by"] == "person" { "approve" } else if c["expired"] == true { "expired" } else { "reject" },
273 "grant": crate::tools::policy::Grant::of(c["tool"].as_str().unwrap_or_default(), &c["arguments"]).map(|g| g.describe()),
274 "scope": c["scope"],
275 "reason": c["rejection"],
276 "by": c["operator"]["actor"],
277 "basis": c["operator"]["basis"],
278 "via": if c["via"].is_null() { &c["approver"]["via"] } else { &c["via"] },
280 })
281 })
282 .collect()
283}
284
285fn strength(report: &RunReport) -> Value {
289 let Some(a) = &report.strength else {
290 return Value::Null;
291 };
292 json!({
293 "visible": a.visible,
294 "combined": a.combined,
295 "held_out": a.held_out,
296 "gate": a.gate,
297 "near_misses": a.near_misses.iter().map(|n| json!({ "id": n.id, "scenario": n.scenario, "visible": n.visible, "combined": n.combined })).collect::<Vec<_>>(),
298 "permitted": a.permitted.iter().map(|p| json!({ "id": p.id, "scenario": p.scenario, "digest": p.digest, "outcome": p.outcome })).collect::<Vec<_>>(),
299 "rulings": a.rulings,
300 })
301}
302
303fn monitor(report: &RunReport) -> Value {
306 match &report.monitor {
307 None => json!({ "state": "off", "findings": [] }),
308 Some(m) if m.get("error").is_some() => json!({ "state": "unreviewed", "findings": [] }),
309 Some(m) => json!({
310 "state": if m["suspected"] == true { "suspected" } else { "clean" },
311 "findings": m.get("findings").cloned().unwrap_or_else(|| json!([])),
312 }),
313 }
314}
315
316fn resource_uri(repo: &Path, commit: &str) -> String {
318 let origin = crate::git::Git::at(repo)
319 .line(&["remote", "get-url", "origin"])
320 .ok()
321 .filter(|u| !u.is_empty());
322 match origin {
323 Some(url) => {
324 let clean = match url.split_once("://") {
326 Some((scheme, rest)) => {
327 let host_end = rest.find('/').unwrap_or(rest.len());
328 match rest[..host_end].rfind('@') {
329 Some(at) => format!("{scheme}://{}", &rest[at + 1..]),
330 None => url.clone(),
331 }
332 }
333 None => url.clone(),
334 };
335 format!("git+{clean}@{commit}")
336 }
337 None => format!("git:{commit}"),
338 }
339}
340
341#[must_use]
344pub fn note_state(repo: &Path, object: &str) -> String {
345 use base64::Engine as _;
346 let Some(note) = notes::read(repo, object) else {
347 return "missing".to_owned();
348 };
349 let (lines, _) = dsse::decode(¬e);
350 if lines.is_empty() {
351 return "missing".to_owned();
352 }
353 let Some(sig) = lines
354 .iter()
355 .find_map(|l| l.signatures.first().map(|s| (s, l)))
356 else {
357 return "unsigned".to_owned();
358 };
359 let armored = base64::engine::general_purpose::STANDARD
360 .decode(&sig.0.sig)
361 .map(|b| String::from_utf8_lossy(&b).into_owned())
362 .unwrap_or_default();
363 let kind = if sig.1.sigstore.is_some() {
364 "sigstore"
365 } else if armored.contains("SSH SIGNATURE") {
366 "ssh"
367 } else {
368 "openpgp"
369 };
370 format!("signed {kind} {}", sig.0.keyid)
371}
372
373pub fn sign_all(lines: &mut [Envelope], signer: &sign::Signer) -> Option<String> {
376 let mut failure = None;
377 let summary_only = matches!(signer, sign::Signer::Sigstore)
380 && lines.iter().any(|l| {
381 l.statement()
382 .is_some_and(|s| s["predicateType"] == statement::VSA)
383 });
384 for line in lines.iter_mut().filter(|l| l.signatures.is_empty()) {
385 if summary_only
386 && !line
387 .statement()
388 .is_some_and(|s| s["predicateType"] == statement::VSA)
389 {
390 continue;
391 }
392 let Some(bytes) = line.signed_bytes() else {
393 continue;
394 };
395 match sign::sign(signer, &bytes) {
396 Ok((sig, material)) => {
397 line.signatures.push(sig);
398 line.sigstore = material;
399 }
400 Err(e) => {
401 failure.get_or_insert(e);
402 }
403 }
404 }
405 failure
406}
407
408fn write(repo: &Path, commit: &str, attest: &AttestConfig, mut lines: Vec<Envelope>) -> Value {
411 let (signed, signer, reason) = if attest.sign_later {
412 (
413 false,
414 None,
415 Some("[attest] sign_at = \"later\": `speccoder attest sign`".to_owned()),
416 )
417 } else {
418 match sign::signer(repo, attest) {
419 Ok(s) => match sign_all(&mut lines, &s) {
420 None => (true, Some(s.kind()), None),
421 Some(e) => (false, Some(s.kind()), Some(e)),
422 },
423 Err(e) => (false, None, Some(e)),
424 }
425 };
426 if let Err(e) = notes::write(repo, commit, &dsse::encode(&lines)) {
427 return json!({ "state": "missing", "reason": format!("writing the note failed: {e}") });
428 }
429 outcome(signed, signer, reason.as_deref())
430}
431
432#[must_use]
435pub fn record_tick(t: &Tick<'_>, report: &RunReport) -> Value {
436 let subject = Subject {
437 name: format!("{}/{}", t.feature, t.task),
438 commit: t.commit.to_owned(),
439 };
440 let tree = crate::git::Git::at(t.repo)
441 .line(&["rev-parse", &format!("{}^{{tree}}", t.commit)])
442 .unwrap_or_default();
443 let policy = policy::judge_config_sha256(t.config).unwrap_or_default();
444 let time = now_rfc3339();
445 let mut named: Vec<(String, Value)> = phases(report)
446 .iter()
447 .map(|p| {
448 (
449 format!("test-result/{}", p.phase.name()),
450 statement::statement(&subject, statement::TEST_RESULT, statement::test_result(p)),
451 )
452 })
453 .collect();
454 let verification = json!({
455 "verdict": t.verdict,
456 "unit": { "format": t.format, "feature": t.feature, "dir": t.feature_dir },
457 "task": { "id": t.task, "line_hash": t.line_hash, "title": t.title },
458 "tree": tree,
459 "spec": { "section": t.section, "section_hash": sha(t.section_text) },
460 "trace": trace(t, report),
461 "integrity": { "findings": report.integrity },
462 "monitor": monitor(report),
463 "mutation": report.mutation,
464 "sandbox": t.sandbox,
465 "executor": t.executor,
466 "model": t.model,
467 "run": report.run_id,
468 "decisions": decisions(&report.tool_calls),
469 "strength": strength(report),
470 "instructions": report.instructions,
471 "policy_sha256": policy,
472 "time": time,
473 });
474 named.push((
475 "task-verification".to_owned(),
476 statement::statement(&subject, statement::TASK_VERIFICATION, verification),
477 ));
478 let mut lines = Vec::new();
479 let mut inputs = Vec::new();
480 for (name, s) in &named {
481 if let Err(why) = statement::check_clean(s) {
482 return json!({ "state": "missing", "reason": format!("a statement would carry {why}; none was written") });
483 }
484 let bytes = statement::canonical(s);
485 inputs.push((name.clone(), crate::integrity::sha256_hex(&bytes)));
486 lines.push(Envelope::unsigned(&bytes));
487 }
488 let summary = statement::statement(
489 &subject,
490 statement::VSA,
491 statement::vsa(&statement::Summary {
492 speccoder: env!("CARGO_PKG_VERSION").to_owned(),
493 agentplane: crate::substrate::AGENTPLANE_VERSION.to_owned(),
494 time,
495 resource_uri: resource_uri(t.repo, t.commit),
496 policy_sha256: policy,
497 inputs,
498 verdict: t.verdict.to_owned(),
499 }),
500 );
501 if let Err(why) = statement::check_clean(&summary) {
502 return json!({ "state": "missing", "reason": format!("a statement would carry {why}; none was written") });
503 }
504 lines.push(Envelope::unsigned(&statement::canonical(&summary)));
505 write(t.repo, t.commit, t.attest, lines)
506}
507
508#[must_use]
512pub fn carry(repo: &Path, from: &str, to: &str, attest: &AttestConfig) -> Value {
513 if from == to {
514 return match note_state(repo, to).split_whitespace().collect::<Vec<_>>()[..] {
516 ["signed", kind, ..] => outcome(true, Some(kind), None),
517 [state, ..] => json!({ "state": state }),
518 [] => json!({ "state": "missing" }),
519 };
520 }
521 let out = reissue(repo, from, to, attest);
522 if out["state"] != "missing" {
523 notes::remove(repo, from);
524 }
525 out
526}
527
528#[must_use]
533pub fn reissue(repo: &Path, from: &str, to: &str, attest: &AttestConfig) -> Value {
534 let Some(note) = notes::read(repo, from) else {
535 return json!({ "state": "missing", "reason": "the lane's commit had no attestation" });
536 };
537 let (envelopes, unreadable) = dsse::decode(¬e);
538 let statements: Option<Vec<Value>> = envelopes.iter().map(Envelope::statement).collect();
539 let Some(mut statements) = statements.filter(|_| unreadable == 0) else {
540 return json!({ "state": "missing", "reason": "the lane's attestation was unreadable" });
541 };
542 let tree = crate::git::Git::at(repo)
543 .line(&["rev-parse", &format!("{to}^{{tree}}")])
544 .unwrap_or_default();
545 let mut renamed = std::collections::BTreeMap::new();
546 let mut lines = Vec::new();
547 for s in statements
548 .iter_mut()
549 .filter(|s| s["predicateType"] != statement::VSA)
550 {
551 let old = crate::integrity::sha256_hex(&statement::canonical(s));
552 s["subject"][0]["digest"]["gitCommit"] = json!(to);
553 if s["predicateType"] == statement::TASK_VERIFICATION {
554 s["predicate"]["tree"] = json!(tree);
555 }
556 let bytes = statement::canonical(s);
557 renamed.insert(old, crate::integrity::sha256_hex(&bytes));
558 lines.push(Envelope::unsigned(&bytes));
559 }
560 for s in statements
561 .iter_mut()
562 .filter(|s| s["predicateType"] == statement::VSA)
563 {
564 s["subject"][0]["digest"]["gitCommit"] = json!(to);
565 s["predicate"]["resourceUri"] = json!(resource_uri(repo, to));
566 if let Some(inputs) = s["predicate"]["inputAttestations"].as_array_mut() {
567 for input in inputs {
568 let old = input["digest"]["sha256"]
569 .as_str()
570 .unwrap_or_default()
571 .to_owned();
572 if let Some(new) = renamed.get(&old) {
573 input["digest"]["sha256"] = json!(new);
574 }
575 }
576 }
577 lines.push(Envelope::unsigned(&statement::canonical(s)));
578 }
579 write(repo, to, attest, lines)
580}
581
582#[must_use]
586pub fn record_decision(t: &Tick<'_>, by: &str, reason: &str) -> Value {
587 let subject = Subject {
588 name: format!("{}/{}", t.feature, t.task),
589 commit: t.commit.to_owned(),
590 };
591 let tree = crate::git::Git::at(t.repo)
592 .line(&["rev-parse", &format!("{}^{{tree}}", t.commit)])
593 .unwrap_or_default();
594 let s = statement::statement(
595 &subject,
596 statement::TASK_VERIFICATION,
597 json!({
598 "verdict": t.verdict,
599 "unit": { "format": t.format, "feature": t.feature, "dir": t.feature_dir },
600 "task": { "id": t.task, "line_hash": t.line_hash, "title": t.title },
601 "tree": tree,
602 "decider": { "by": by, "reason": reason },
603 "policy_sha256": policy::judge_config_sha256(t.config).unwrap_or_default(),
604 "time": now_rfc3339(),
605 }),
606 );
607 if let Err(why) = statement::check_clean(&s) {
608 return json!({ "state": "missing", "reason": format!("the statement would carry {why}; none was written") });
609 }
610 write(
611 t.repo,
612 t.commit,
613 t.attest,
614 vec![Envelope::unsigned(&statement::canonical(&s))],
615 )
616}
617
618#[must_use]
621pub fn sign_at(
622 path: &Path,
623 feature: Option<&str>,
624 rev: Option<&str>,
625) -> speccoder_core::CommandResult {
626 use speccoder_core::{CommandResult, CommandStatus};
627 let inspected = crate::inspect(path, feature);
628 let Some(speccoder_core::CommandData::Inspect(snap)) = inspected.data() else {
629 return CommandResult::carried("attest", &inspected);
630 };
631 let Some(f) = snap.feature.as_ref() else {
632 return CommandResult::new(
633 "attest",
634 CommandStatus::SelectionNeeded,
635 inspected.message(),
636 );
637 };
638 let root = std::path::PathBuf::from(&snap.root);
639 let config = crate::verify::find_config(&root)
640 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
641 .unwrap_or_default();
642 let attest = match crate::substrate::run_config::parse(&config) {
643 Ok(c) => c.attest,
644 Err(e) => return CommandResult::new("attest", CommandStatus::ConfigError, &e),
645 };
646 let signer = match sign::signer(&root, &attest) {
647 Ok(s) => s,
648 Err(e) => {
649 return CommandResult::new(
650 "attest",
651 CommandStatus::ConfigError,
652 &format!("cannot sign: {e}"),
653 );
654 }
655 };
656 let targets = match verify::targets(&root, &f.feature.name, rev) {
657 Ok(t) => t,
658 Err(e) => return CommandResult::new("attest", CommandStatus::GitPrecondition, &e),
659 };
660 let (mut signed, mut objects, mut failures) = (0usize, 0usize, Vec::new());
661 for t in &targets {
662 let Some(note) = notes::read(&root, &t.object) else {
663 continue;
664 };
665 let (mut lines, unreadable) = dsse::decode(¬e);
666 let before = lines.iter().filter(|l| l.signatures.is_empty()).count();
667 if before == 0 || unreadable > 0 {
668 continue;
669 }
670 if let Some(e) = sign_all(&mut lines, &signer) {
671 failures.push(format!("{}: {e}", t.task));
672 }
673 let after = lines.iter().filter(|l| l.signatures.is_empty()).count();
674 if after < before {
675 if let Err(e) = notes::write(&root, &t.object, &dsse::encode(&lines)) {
676 failures.push(format!("{}: {e}", t.task));
677 continue;
678 }
679 signed += before - after;
680 objects += 1;
681 }
682 }
683 let mut message = format!(
684 "signed {signed} statement(s) on {objects} task object(s) with {}",
685 signer.kind()
686 );
687 if !failures.is_empty() {
688 message.push_str(&format!("; not signed: {}", failures.join("; ")));
689 }
690 CommandResult::new(
691 "attest",
692 if failures.is_empty() {
693 CommandStatus::Ok
694 } else {
695 CommandStatus::Error
696 },
697 &message,
698 )
699}
700
701#[cfg(test)]
702mod tests {
703 use super::*;
704
705 #[test]
706 fn the_clock_is_rfc3339_utc() {
707 let t = now_rfc3339();
708 assert_eq!(t.len(), 20, "{t}");
709 assert!(t.ends_with('Z') && t.as_bytes()[10] == b'T', "{t}");
710 assert!(t.starts_with("20"), "{t}");
711 }
712
713 #[test]
714 fn a_decision_names_who_and_through_what() {
715 let calls = vec![json!({
716 "tool": "write_file",
717 "arguments": { "path": "package.json", "content": "{}" },
718 "by": "person",
719 "approved_by": "person",
720 "scope": "once",
721 "approver": { "name": "Dana", "via": "dashboard", "basis": "asserted" },
722 "operator": { "actor": "Dana", "basis": "asserted" },
723 })];
724 let d = &decisions(&calls)[0];
725 assert_eq!(d["verb"], "approve");
726 assert_eq!(d["by"], "Dana");
727 assert_eq!(d["via"], "dashboard");
728 }
729}