use nono::trust::{self, TrustPolicy, VerificationOutcome};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::{debug, warn};
#[derive(Debug, Clone)]
struct CacheEntry {
inode: u64,
mtime_nanos: u128,
size: u64,
outcome: CachedOutcome,
}
#[derive(Debug, Clone)]
enum CachedOutcome {
Verified { publisher: String, digest: String },
Failed { reason: String },
}
#[derive(Debug, Clone)]
pub struct TrustVerified {
pub publisher: String,
pub digest: String,
}
pub struct TrustInterceptor {
policy: TrustPolicy,
matcher: trust::IncludePatterns,
cache: HashMap<PathBuf, CacheEntry>,
project_root: PathBuf,
}
impl TrustInterceptor {
pub fn new(policy: TrustPolicy, project_root: PathBuf) -> nono::Result<Self> {
let matcher = policy.include_matcher()?;
Ok(Self {
policy,
matcher,
cache: HashMap::new(),
project_root,
})
}
pub fn check_path(
&mut self,
path: &Path,
) -> Option<std::result::Result<TrustVerified, String>> {
if path.to_string_lossy().ends_with(".bundle") {
return None;
}
let relative = path.strip_prefix(&self.project_root).unwrap_or(path);
if !self.matcher.is_match(relative) {
let file_name = path.file_name().map(Path::new)?;
if !self.matcher.is_match(file_name) {
return None;
}
}
debug!(
"Trust interceptor: checking instruction file {}",
path.display()
);
if let Some(cached) = self.check_cache(path) {
return Some(cached);
}
let result = self.verify_and_cache(path);
Some(result)
}
fn check_cache(&self, path: &Path) -> Option<std::result::Result<TrustVerified, String>> {
let entry = self.cache.get(path)?;
let meta = std::fs::metadata(path).ok()?;
#[cfg(unix)]
let inode = {
use std::os::unix::fs::MetadataExt;
meta.ino()
};
#[cfg(not(unix))]
let inode = 0u64;
let mtime_nanos = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_nanos())
.unwrap_or(0);
let size = meta.len();
if entry.inode != inode || entry.mtime_nanos != mtime_nanos || entry.size != size {
debug!(
"Trust interceptor: cache invalidated for {} (metadata changed)",
path.display()
);
return None;
}
debug!("Trust interceptor: cache hit for {}", path.display());
match &entry.outcome {
CachedOutcome::Verified { publisher, digest } => Some(Ok(TrustVerified {
publisher: publisher.clone(),
digest: digest.clone(),
})),
CachedOutcome::Failed { reason } => Some(Err(reason.clone())),
}
}
fn verify_and_cache(&mut self, path: &Path) -> std::result::Result<TrustVerified, String> {
let digest = match trust::file_digest(path) {
Ok(d) => d,
Err(e) => {
let reason = format!("failed to compute digest: {e}");
warn!("Trust interceptor: {reason} for {}", path.display());
return Err(reason);
}
};
if let Some(entry) = self.policy.check_blocklist(&digest) {
let reason = format!("blocked by trust policy: {}", entry.description);
self.store_cache(
path,
CachedOutcome::Failed {
reason: reason.clone(),
},
);
return Err(reason);
}
let bundle_path = trust::bundle_path_for(path);
let signer = if bundle_path.exists() {
match load_signer(path, &bundle_path, &digest, &self.policy) {
Ok(identity) => Some(identity),
Err(reason) => {
self.store_cache(
path,
CachedOutcome::Failed {
reason: reason.clone(),
},
);
return Err(reason);
}
}
} else {
None
};
let result = trust::evaluate_file(&self.policy, path, &digest, signer.as_ref());
match &result.outcome {
VerificationOutcome::Verified { publisher } => {
let pub_name = publisher.clone();
self.store_cache(
path,
CachedOutcome::Verified {
publisher: pub_name.clone(),
digest: digest.clone(),
},
);
debug!(
"Trust interceptor: verified {} (publisher: {})",
path.display(),
pub_name
);
Ok(TrustVerified {
publisher: pub_name,
digest,
})
}
outcome => {
let reason = format_outcome(outcome);
let should_block = outcome.should_block(self.policy.enforcement);
self.store_cache(
path,
CachedOutcome::Failed {
reason: reason.clone(),
},
);
if should_block {
warn!(
"Trust interceptor: blocking {} ({})",
path.display(),
reason
);
Err(reason)
} else {
debug!(
"Trust interceptor: warning for {} ({}) - enforcement allows",
path.display(),
reason
);
Ok(TrustVerified {
publisher: format!(
"(unverified, enforcement={:?})",
self.policy.enforcement
),
digest,
})
}
}
}
}
fn store_cache(&mut self, path: &Path, outcome: CachedOutcome) {
let meta = match std::fs::metadata(path) {
Ok(m) => m,
Err(_) => return,
};
#[cfg(unix)]
let inode = {
use std::os::unix::fs::MetadataExt;
meta.ino()
};
#[cfg(not(unix))]
let inode = 0u64;
let mtime_nanos = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_nanos())
.unwrap_or(0);
let size = meta.len();
self.cache.insert(
path.to_path_buf(),
CacheEntry {
inode,
mtime_nanos,
size,
outcome,
},
);
}
}
fn load_signer(
file_path: &Path,
bundle_path: &Path,
file_digest: &str,
policy: &trust::TrustPolicy,
) -> std::result::Result<trust::SignerIdentity, String> {
let bundle = trust::load_bundle(bundle_path).map_err(|e| format!("invalid bundle: {e}"))?;
let predicate_type = trust::extract_predicate_type(&bundle, bundle_path)
.map_err(|e| format!("failed to extract predicate type: {e}"))?;
if predicate_type != trust::NONO_PREDICATE_TYPE {
return Err(format!(
"wrong bundle type: expected instruction file attestation, got {predicate_type}"
));
}
trust::verify_bundle_subject_name(&bundle, file_path)
.map_err(|e| format!("subject name mismatch: {e}"))?;
let identity = trust::extract_signer_identity(&bundle, bundle_path)
.map_err(|e| format!("no signer identity: {e}"))?;
let bundle_digest = trust::extract_bundle_digest(&bundle, bundle_path)
.map_err(|e| format!("malformed bundle: {e}"))?;
if bundle_digest != file_digest {
return Err("bundle digest does not match file content".to_string());
}
match &identity {
trust::SignerIdentity::Keyed { .. } => {
let matching = policy.matching_publishers(&identity);
match matching.iter().find_map(|p| p.public_key.as_ref()) {
Some(b64) => {
let key_bytes = nono::trust::base64::base64_decode(b64)
.map_err(|_| "invalid base64 in publisher public_key".to_string())?;
trust::verify_keyed_signature(&bundle, &key_bytes, bundle_path)
.map_err(|e| format!("signature verification failed: {e}"))?;
}
None => {
return Err("keyed bundle but no public_key in matching publisher".to_string());
}
}
}
trust::SignerIdentity::Keyless { .. } => {
let trusted_root = trust::load_production_trusted_root()
.map_err(|e| format!("failed to load Sigstore trusted root: {e}"))?;
let sigstore_policy = trust::VerificationPolicy::default();
trust::verify_bundle_with_digest(
file_digest,
&bundle,
&trusted_root,
&sigstore_policy,
file_path,
)
.map_err(|e| format!("Sigstore verification failed: {e}"))?;
}
}
Ok(identity)
}
fn format_outcome(outcome: &VerificationOutcome) -> String {
match outcome {
VerificationOutcome::Verified { publisher } => format!("verified ({publisher})"),
VerificationOutcome::Blocked { reason } => format!("blocklisted: {reason}"),
VerificationOutcome::Unsigned => "unsigned (no .bundle file)".to_string(),
VerificationOutcome::InvalidSignature { detail } => format!("invalid signature: {detail}"),
VerificationOutcome::UntrustedPublisher { identity } => {
format!("untrusted publisher: {identity:?}")
}
VerificationOutcome::DigestMismatch { .. } => {
"file content does not match bundle".to_string()
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn interceptor_ignores_non_instruction_files() {
let dir = tempfile::tempdir().unwrap();
let policy = TrustPolicy::default();
let mut interceptor = TrustInterceptor::new(policy, dir.path().to_path_buf()).unwrap();
assert!(interceptor
.check_path(Path::new("/tmp/README.md"))
.is_none());
assert!(interceptor
.check_path(Path::new("/tmp/src/main.rs"))
.is_none());
}
#[test]
fn interceptor_ignores_bundle_sidecars() {
let dir = tempfile::tempdir().unwrap();
let policy = TrustPolicy::default();
let mut interceptor = TrustInterceptor::new(policy, dir.path().to_path_buf()).unwrap();
assert!(interceptor
.check_path(Path::new("/tmp/SKILLS.md.bundle"))
.is_none());
assert!(interceptor
.check_path(Path::new("/tmp/CLAUDE.md.bundle"))
.is_none());
}
#[test]
fn interceptor_checks_instruction_files() {
let dir = tempfile::tempdir().unwrap();
let skills = dir.path().join("SKILLS.md");
std::fs::write(&skills, "# Skills").unwrap();
let policy = TrustPolicy {
includes: vec!["SKILLS.md".to_string()],
..TrustPolicy::default()
};
let mut interceptor = TrustInterceptor::new(policy, dir.path().to_path_buf()).unwrap();
let result = interceptor.check_path(&skills);
assert!(result.is_some());
}
#[test]
fn interceptor_caches_results() {
let dir = tempfile::tempdir().unwrap();
let skills = dir.path().join("SKILLS.md");
std::fs::write(&skills, "# Skills").unwrap();
let policy = TrustPolicy {
includes: vec!["SKILLS.md".to_string()],
enforcement: trust::Enforcement::Warn,
..TrustPolicy::default()
};
let mut interceptor = TrustInterceptor::new(policy, dir.path().to_path_buf()).unwrap();
let _result1 = interceptor.check_path(&skills);
assert!(interceptor.cache.contains_key(&skills));
let _result2 = interceptor.check_path(&skills);
}
#[test]
fn interceptor_cache_invalidates_on_modify() {
let dir = tempfile::tempdir().unwrap();
let skills = dir.path().join("SKILLS.md");
std::fs::write(&skills, "# Skills v1").unwrap();
let policy = TrustPolicy {
includes: vec!["SKILLS.md".to_string()],
enforcement: trust::Enforcement::Warn,
..TrustPolicy::default()
};
let mut interceptor = TrustInterceptor::new(policy, dir.path().to_path_buf()).unwrap();
let _ = interceptor.check_path(&skills);
std::fs::write(&skills, "# Skills v2 with extra content").unwrap();
let cached = interceptor.check_cache(&skills);
assert!(cached.is_none());
}
#[test]
fn interceptor_blocklist_blocks_regardless_of_enforcement() {
let dir = tempfile::tempdir().unwrap();
let content = b"evil content";
let skills = dir.path().join("SKILLS.md");
std::fs::write(&skills, content).unwrap();
let digest = trust::bytes_digest(content);
let policy = TrustPolicy {
includes: vec!["SKILLS.md".to_string()],
enforcement: trust::Enforcement::Audit,
blocklist: trust::Blocklist {
digests: vec![trust::BlocklistEntry {
sha256: digest,
description: "malicious".to_string(),
added: "2026-01-01".to_string(),
}],
publishers: Vec::new(),
},
..TrustPolicy::default()
};
let mut interceptor = TrustInterceptor::new(policy, dir.path().to_path_buf()).unwrap();
let result = interceptor.check_path(&skills);
assert!(result.is_some());
assert!(result.unwrap().is_err());
}
#[test]
fn format_outcome_variants() {
assert!(format_outcome(&VerificationOutcome::Unsigned).contains("unsigned"));
assert!(format_outcome(&VerificationOutcome::Blocked {
reason: "test".to_string()
})
.contains("blocklisted"));
assert!(format_outcome(&VerificationOutcome::InvalidSignature {
detail: "bad sig".to_string()
})
.contains("bad sig"));
assert!(format_outcome(&VerificationOutcome::DigestMismatch {
expected: "a".to_string(),
actual: "b".to_string()
})
.contains("does not match"));
}
}