Skip to main content

speccoder_runtime/attest/
policy.rs

1//! The judge's configuration as one hash: the policy a VSA names.
2
3use serde_json::{Value, json};
4
5/// The sections of `speccoder.toml` that decide a verdict.
6const JUDGE: &[&str] = &[
7    "checks",
8    "acceptance",
9    "tests",
10    "mutation",
11    "monitor",
12    "sandbox",
13    "executor",
14];
15
16/// sha256 over the canonical JSON of what decides a verdict: speccoder's
17/// version, the judge's sections, the model (provider and name only) and
18/// the signer. Comments, prices and key-variable names do not change it.
19pub fn judge_config_sha256(config: &str) -> Result<String, String> {
20    judge_config_sha256_as(config, env!("CARGO_PKG_VERSION"))
21}
22
23/// [`judge_config_sha256`] as speccoder `version` computed it: a verifier
24/// recomputes a tick's with the version that made the tick, never its own
25/// (else no release could verify an earlier one's evidence).
26///
27/// # Errors
28///
29/// `config` is not TOML.
30pub fn judge_config_sha256_as(config: &str, version: &str) -> Result<String, String> {
31    let table: toml::Table = toml::from_str(config).map_err(|e| e.to_string())?;
32    let section = |k: &str| {
33        table
34            .get(k)
35            .and_then(|v| serde_json::to_value(v).ok())
36            .unwrap_or(Value::Null)
37    };
38    let mut judge = serde_json::Map::new();
39    for k in JUDGE {
40        judge.insert((*k).to_owned(), section(k));
41    }
42    let model = table.get("model");
43    let field = |t: Option<&toml::Value>, k: &str| {
44        t.and_then(|m| m.get(k))
45            .and_then(toml::Value::as_str)
46            .map(str::to_owned)
47    };
48    judge.insert(
49        "model".to_owned(),
50        json!({ "provider": field(model, "provider"), "model": field(model, "model") }),
51    );
52    judge.insert(
53        "attest".to_owned(),
54        json!({ "signer": field(table.get("attest"), "signer").unwrap_or_else(|| "git".to_owned()) }),
55    );
56    judge.insert("speccoder_version".to_owned(), json!(version));
57    Ok(crate::integrity::sha256_hex(&super::statement::canonical(
58        &Value::Object(judge),
59    )))
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65
66    const CONFIG: &str = "[model]\nprovider = \"openai\"\nmodel = \"m\"\nprice_input_per_mtok = 1.0\n\n[checks.test]\nargv = [\"cargo\", \"test\"]\n";
67
68    #[test]
69    fn comments_and_prices_keep_the_hash_and_a_check_changes_it() {
70        let base = judge_config_sha256(CONFIG).unwrap();
71        let commented = format!("# a note\n{}", CONFIG.replace("1.0", "2.5"));
72        assert_eq!(judge_config_sha256(&commented).unwrap(), base);
73        let keyed = CONFIG.replace(
74            "model = \"m\"\n",
75            "model = \"m\"\napi_key_env = \"MY_KEY\"\n",
76        );
77        assert_eq!(judge_config_sha256(&keyed).unwrap(), base);
78        let other = CONFIG.replace("\"test\"]", "\"test\", \"--all\"]");
79        assert_ne!(judge_config_sha256(&other).unwrap(), base);
80        let model = CONFIG.replace("model = \"m\"", "model = \"n\"");
81        assert_ne!(judge_config_sha256(&model).unwrap(), base);
82    }
83
84    /// The md2csv example (2026-11-04): ticks of 0.9.7 and 0.9.8 were all
85    /// "tampered" to the 0.10.0 verifier, which hashed its own version in.
86    /// The tick's version recomputes the tick's hash.
87    #[test]
88    fn a_tick_is_recomputed_as_the_version_that_made_it() {
89        let ours = judge_config_sha256(CONFIG).unwrap();
90        assert_eq!(
91            judge_config_sha256_as(CONFIG, env!("CARGO_PKG_VERSION")).unwrap(),
92            ours
93        );
94        let older = judge_config_sha256_as(CONFIG, "0.9.7").unwrap();
95        assert_ne!(older, ours);
96        assert_eq!(judge_config_sha256_as(CONFIG, "0.9.7").unwrap(), older);
97    }
98}