Skip to main content

speccoder_runtime/attest/
mod.rs

1//! Signed, portable evidence per ticked task.
2//!
3//! After a tick commit, the engine writes an in-toto Bundle as the commit's
4//! git note (`refs/notes/speccoder`): a `test-result` statement per test
5//! phase that ran, one SLSA verification summary and speccoder's
6//! `task-verification`, each a DSSE envelope signed with the person's git
7//! signing key. `accept` and `skip` write a `task-verification` alone. The
8//! model never sees, writes or signs any of it.
9
10pub 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/// What a tick's attestation came to, for the run report.
27#[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/// Everything a tick's statements are made of besides the run report.
37#[derive(Debug)]
38pub struct Tick<'a> {
39    /// The repository (a worktree or the checkout: refs are shared).
40    pub repo: &'a Path,
41    /// `speccoder.toml` as the attempt read it.
42    pub config: &'a str,
43    /// `[attest]`.
44    pub attest: &'a AttestConfig,
45    /// The spec format, e.g. `speckit`.
46    pub format: &'a str,
47    /// The unit (feature) and its directory.
48    pub feature: &'a str,
49    /// The unit's directory, relative to the project.
50    pub feature_dir: &'a str,
51    /// Task ID.
52    pub task: &'a str,
53    /// Task title.
54    pub title: &'a str,
55    /// The task line's hash before the tick.
56    pub line_hash: &'a str,
57    /// The requirement the task belongs to, e.g. `US1`.
58    pub section: Option<&'a str>,
59    /// That requirement's text, as the attempt was given it.
60    pub section_text: &'a str,
61    /// The unit's requirement identifiers with their line hashes (the
62    /// format's; the trace names those the title cites).
63    pub requirements: &'a [(String, String)],
64    /// The tick commit.
65    pub commit: &'a str,
66    /// The verdict word.
67    pub verdict: &'a str,
68    /// `{ mode, backend, network }`.
69    pub sandbox: Value,
70    /// `{ kind, agent? }`.
71    pub executor: Value,
72    /// `{ provider, model }`, when one judged.
73    pub model: Value,
74}
75
76/// Whether a task title cites requirement `id`, as a word of its own.
77#[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
84/// The `trace[]` rows of a task's statement: each requirement its title
85/// cites, with the defining line's hash; then each acceptance test with
86/// the scenario it checks (none: the author cited none). All with the
87/// verdict.
88fn 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/// RFC 3339 UTC, to the second.
129#[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    // Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
137    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/// The test phases a run report shows.
159#[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
265/// The decisions people made on the attempt's calls.
266fn 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                // Through what: the decision's own record (`approver`).
279                "via": if c["via"].is_null() { &c["approver"]["via"] } else { &c["via"] },
280            })
281        })
282        .collect()
283}
284
285/// The acceptance set's strength as evidence: shares, counts,
286/// what got through, the permitted alternatives and the rulings — never the
287/// local patch paths or the variants' code.
288fn 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
303/// The monitor's result, `unreviewed` when it did not finish, `off` when
304/// not configured.
305fn 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
316/// `git+<origin without credentials>@<commit>`, or `git:<commit>`.
317fn 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            // Credentials in a remote URL never leave the machine.
325            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/// What `object`'s note says, signatures unchecked: `signed <kind>
342/// <keyid>`, `unsigned` or `missing`.
343#[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(&note);
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
373/// Signs every envelope that is not signed yet; the first failure's reason
374/// when some stay unsigned.
375pub fn sign_all(lines: &mut [Envelope], signer: &sign::Signer) -> Option<String> {
376    let mut failure = None;
377    // Keyless Sigstore signs the summary alone — it names every other line
378    // by digest — so a tick costs one identity login, not one per line.
379    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
408/// Writes `lines` as `commit`'s note, signed as `[attest]` says; what it
409/// came to (`signed`/`unsigned`, signer, reason).
410fn 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/// Records a passed tick: `test-result` per phase, `task-verification`,
433/// then the `vsa` naming every other line. Never fails the tick.
434#[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/// Moves `from`'s statements to `to`, the commit that carries the same
509/// task's work onto the feature branch (a parallel lane's commit,
510/// cherry-picked): [`reissue`], then `from` loses its note.
511#[must_use]
512pub fn carry(repo: &Path, from: &str, to: &str, attest: &AttestConfig) -> Value {
513    if from == to {
514        // The lane started at the branch tip: the pick is the same commit.
515        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/// Re-issues `from`'s statements for `to`, a commit with the same task's
529/// work: the subject, tree and resource move to `to`, the summary names
530/// the new lines, and everything is signed afresh. What the evidence says
531/// is unchanged.
532#[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(&note);
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/// Records a person's decision on a task — `attested` (accept) or
583/// `skipped` (skip) — as one `task-verification`, no summary: a decision
584/// is not a verification.
585#[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/// `speccoder attest sign`: signs the unsigned speccoder statements of the
619/// selected task objects with the configured signer, payloads unchanged.
620#[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(&note);
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}