pub mod catalog_pin;
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash, Ord, PartialOrd)]
pub struct ToolFingerprint {
pub name: String,
pub schema_sha256: [u8; 32],
}
impl ToolFingerprint {
pub fn schema_hex(&self) -> String {
hex::encode(self.schema_sha256)
}
}
pub type SignerId = String;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PinnedToolCatalog {
pub signer: SignerId,
pub signed_at: SystemTime,
pub tools: Vec<ToolFingerprint>,
}
impl PinnedToolCatalog {
pub fn catalog_sha256(&self) -> [u8; 32] {
let mut sorted = self.tools.clone();
sorted.sort();
let mut h = Sha256::new();
for t in &sorted {
h.update(t.name.as_bytes());
h.update(b"|");
h.update(t.schema_sha256);
h.update(b"\n");
}
h.finalize().into()
}
#[allow(dead_code)] pub fn names(&self) -> Vec<&str> {
self.tools.iter().map(|t| t.name.as_str()).collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AttestationVerdict {
Match,
Drift {
added: Vec<ToolFingerprint>,
removed: Vec<ToolFingerprint>,
mutated: Vec<ToolFingerprint>,
},
Reject {
reason: String,
},
}
impl AttestationVerdict {
#[allow(dead_code)] pub fn is_safe(&self) -> bool {
matches!(self, AttestationVerdict::Match)
}
pub fn is_removed_only_drift(&self) -> bool {
matches!(
self,
AttestationVerdict::Drift { added, mutated, .. }
if added.is_empty() && mutated.is_empty()
)
}
}
pub trait CatalogAttestor: Send + Sync {
fn attest(&self, advertised: &[ToolFingerprint]) -> Result<AttestationVerdict, AttestError>;
}
#[derive(Debug, Error, PartialEq)]
pub enum AttestError {
#[allow(dead_code)]
#[error("attestor has no pinned baseline; the manifest must include `[tool_catalog_pin]`")]
NoBaseline,
#[error("baseline is empty — refusing to attest a vacuous catalog")]
EmptyBaseline,
}
pub struct PinnedAttestor {
baseline: PinnedToolCatalog,
}
impl PinnedAttestor {
pub fn new(baseline: PinnedToolCatalog) -> Self {
Self { baseline }
}
pub fn baseline(&self) -> &PinnedToolCatalog {
&self.baseline
}
}
impl CatalogAttestor for PinnedAttestor {
fn attest(&self, advertised: &[ToolFingerprint]) -> Result<AttestationVerdict, AttestError> {
if self.baseline.tools.is_empty() {
return Err(AttestError::EmptyBaseline);
}
if self.baseline.catalog_sha256() == catalog_sha_of(advertised) {
return Ok(AttestationVerdict::Match);
}
let mut by_name_baseline: std::collections::BTreeMap<&str, &ToolFingerprint> = self
.baseline
.tools
.iter()
.map(|t| (t.name.as_str(), t))
.collect();
let mut added = Vec::new();
let mut mutated = Vec::new();
for t in advertised {
match by_name_baseline.remove(t.name.as_str()) {
None => added.push(t.clone()),
Some(base) if base.schema_sha256 != t.schema_sha256 => mutated.push(t.clone()),
Some(_) => {}
}
}
let removed: Vec<ToolFingerprint> =
by_name_baseline.values().map(|t| (*t).clone()).collect();
if added.is_empty() && mutated.is_empty() && removed.is_empty() {
return Ok(AttestationVerdict::Reject {
reason: "catalog_sha mismatch with empty diff".into(),
});
}
Ok(AttestationVerdict::Drift {
added,
removed,
mutated,
})
}
}
fn catalog_sha_of(tools: &[ToolFingerprint]) -> [u8; 32] {
let mut sorted = tools.to_vec();
sorted.sort();
let mut h = Sha256::new();
for t in &sorted {
h.update(t.name.as_bytes());
h.update(b"|");
h.update(t.schema_sha256);
h.update(b"\n");
}
h.finalize().into()
}
pub fn fingerprint_tool(name: &str, description: &str, input_schema_json: &str) -> ToolFingerprint {
let mut h = Sha256::new();
h.update(b"name=");
h.update(name.as_bytes());
h.update(b"\ndescription=");
h.update(description.as_bytes());
h.update(b"\nschema=");
h.update(input_schema_json.as_bytes());
let schema_sha256: [u8; 32] = h.finalize().into();
ToolFingerprint {
name: name.to_string(),
schema_sha256,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fp(name: &str, schema: &str) -> ToolFingerprint {
fingerprint_tool(name, "desc", schema)
}
fn baseline_six() -> PinnedToolCatalog {
PinnedToolCatalog {
signer: "test-pin".into(),
signed_at: SystemTime::UNIX_EPOCH,
tools: vec![
fp("mnemo.remember", r#"{"type":"object"}"#),
fp("mnemo.recall", r#"{"type":"object"}"#),
fp("mnemo.forget", r#"{"type":"object"}"#),
fp("mnemo.share", r#"{"type":"object"}"#),
fp("mnemo.checkpoint", r#"{"type":"object"}"#),
fp("mnemo.verify", r#"{"type":"object"}"#),
],
}
}
#[test]
fn baseline_match_is_safe() {
let pin = baseline_six();
let attestor = PinnedAttestor::new(pin.clone());
let v = attestor.attest(&pin.tools).unwrap();
assert_eq!(v, AttestationVerdict::Match);
assert!(v.is_safe());
}
#[test]
fn empty_baseline_rejected() {
let attestor = PinnedAttestor::new(PinnedToolCatalog {
signer: "x".into(),
signed_at: SystemTime::UNIX_EPOCH,
tools: vec![],
});
assert_eq!(
attestor.attest(&[fp("a", "{}")]).unwrap_err(),
AttestError::EmptyBaseline
);
}
#[test]
fn appended_secret_exfil_is_drift_with_added() {
let pin = baseline_six();
let attestor = PinnedAttestor::new(pin.clone());
let mut hostile = pin.tools.clone();
hostile.push(fp("secret_exfil", r#"{"type":"object"}"#));
let v = attestor.attest(&hostile).unwrap();
match v {
AttestationVerdict::Drift {
added,
removed,
mutated,
} => {
assert_eq!(added.len(), 1);
assert_eq!(added[0].name, "secret_exfil");
assert!(removed.is_empty());
assert!(mutated.is_empty());
}
other => panic!("expected Drift, got {other:?}"),
}
}
#[test]
fn schema_mutation_is_classified_as_mutated() {
let pin = baseline_six();
let attestor = PinnedAttestor::new(pin.clone());
let mut hostile = pin.tools.clone();
hostile[1] = fp("mnemo.recall", r#"{"properties":{"instructions":{}}}"#);
let v = attestor.attest(&hostile).unwrap();
match v {
AttestationVerdict::Drift {
added,
removed,
mutated,
} => {
assert!(added.is_empty());
assert!(removed.is_empty());
assert_eq!(mutated.len(), 1);
assert_eq!(mutated[0].name, "mnemo.recall");
}
other => panic!("expected mutated drift, got {other:?}"),
}
}
#[test]
fn removed_only_drift_is_recoverable() {
let pin = baseline_six();
let attestor = PinnedAttestor::new(pin.clone());
let downgraded: Vec<_> = pin.tools.iter().take(5).cloned().collect();
let v = attestor.attest(&downgraded).unwrap();
assert!(v.is_removed_only_drift());
assert!(!v.is_safe());
}
#[test]
fn added_drift_is_not_removed_only() {
let pin = baseline_six();
let attestor = PinnedAttestor::new(pin.clone());
let mut h = pin.tools.clone();
h.push(fp("evil", "{}"));
let v = attestor.attest(&h).unwrap();
assert!(!v.is_removed_only_drift());
}
#[test]
fn property_any_added_or_mutated_blocks_safe() {
let pin = baseline_six();
let attestor = PinnedAttestor::new(pin.clone());
for seed in 0..50u8 {
let mut h = pin.tools.clone();
if seed % 3 == 0 {
h.push(fp(&format!("evil_{seed}"), "{}"));
}
if seed % 3 == 1 && !h.is_empty() {
let i = (seed as usize) % h.len();
h[i] = fp(&h[i].name.clone(), &format!(r#"{{"v":{seed}}}"#));
}
if seed % 3 == 2 && !h.is_empty() {
let i = (seed as usize) % h.len();
h.remove(i);
}
let v = attestor.attest(&h).unwrap();
assert!(
!v.is_safe(),
"seed {seed} produced safe verdict for hostile diff"
);
}
}
}