use crate::error::{NonoError, Result};
use globset::{Glob, GlobSet, GlobSetBuilder};
use serde::{Deserialize, Serialize};
use std::path::Path;
pub const TRUST_POLICY_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrustPolicy {
pub version: u32,
pub instruction_patterns: Vec<String>,
pub publishers: Vec<Publisher>,
pub blocklist: Blocklist,
pub enforcement: Enforcement,
}
impl Default for TrustPolicy {
fn default() -> Self {
Self {
version: 1,
instruction_patterns: Vec::new(),
publishers: Vec::new(),
blocklist: Blocklist::default(),
enforcement: Enforcement::default(),
}
}
}
impl TrustPolicy {
const MAX_BLOCKLIST_ENTRIES: usize = 10_000;
const MAX_INSTRUCTION_PATTERNS: usize = 100;
const MAX_PUBLISHERS: usize = 1_000;
pub fn validate_version(&self) -> Result<()> {
if self.version != TRUST_POLICY_VERSION {
return Err(NonoError::TrustPolicy(format!(
"unsupported trust policy version {} (expected {})",
self.version, TRUST_POLICY_VERSION
)));
}
if self.blocklist.digests.len() > Self::MAX_BLOCKLIST_ENTRIES {
return Err(NonoError::TrustPolicy(format!(
"blocklist has {} entries (max {})",
self.blocklist.digests.len(),
Self::MAX_BLOCKLIST_ENTRIES
)));
}
if self.instruction_patterns.len() > Self::MAX_INSTRUCTION_PATTERNS {
return Err(NonoError::TrustPolicy(format!(
"instruction_patterns has {} entries (max {})",
self.instruction_patterns.len(),
Self::MAX_INSTRUCTION_PATTERNS
)));
}
if self.publishers.len() > Self::MAX_PUBLISHERS {
return Err(NonoError::TrustPolicy(format!(
"publishers has {} entries (max {})",
self.publishers.len(),
Self::MAX_PUBLISHERS
)));
}
Ok(())
}
pub fn instruction_matcher(&self) -> Result<InstructionPatterns> {
InstructionPatterns::new(&self.instruction_patterns)
}
#[must_use]
pub fn check_blocklist(&self, digest_hex: &str) -> Option<&BlocklistEntry> {
self.blocklist
.digests
.iter()
.find(|entry| entry.sha256 == digest_hex)
}
#[must_use]
pub fn matching_publishers(&self, identity: &SignerIdentity) -> Vec<&Publisher> {
self.publishers
.iter()
.filter(|p| p.matches(identity))
.collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Publisher {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub issuer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repository: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workflow: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ref_pattern: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub key_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub public_key: Option<String>,
}
impl Publisher {
#[must_use]
pub fn is_keyed(&self) -> bool {
self.key_id.is_some()
}
#[must_use]
pub fn is_keyless(&self) -> bool {
self.issuer.is_some()
}
#[must_use]
pub fn matches(&self, identity: &SignerIdentity) -> bool {
match identity {
SignerIdentity::Keyed { key_id } => self.key_id.as_deref() == Some(key_id.as_str()),
SignerIdentity::Keyless {
issuer,
repository,
workflow,
git_ref,
} => {
if issuer.is_empty()
|| repository.is_empty()
|| workflow.is_empty()
|| git_ref.is_empty()
{
return false;
}
let issuer_match = self.issuer.as_deref().is_some_and(|i| i == issuer);
let repo_match = self
.repository
.as_deref()
.is_some_and(|pattern| wildcard_match(pattern, repository));
let workflow_match = self
.workflow
.as_deref()
.is_some_and(|pattern| wildcard_match(pattern, workflow));
let ref_match = self
.ref_pattern
.as_deref()
.is_some_and(|pattern| wildcard_match(pattern, git_ref));
issuer_match && repo_match && workflow_match && ref_match
}
}
}
}
fn wildcard_match(pattern: &str, value: &str) -> bool {
if pattern == "*" {
return true;
}
if !pattern.contains('*') {
return pattern == value;
}
let parts: Vec<&str> = pattern.split('*').collect();
let mut pos = 0usize;
for (i, part) in parts.iter().enumerate() {
if part.is_empty() {
continue;
}
if i == 0 {
if !value[pos..].starts_with(part) {
return false;
}
pos = pos.saturating_add(part.len());
} else if i == parts.len().saturating_sub(1) {
if !value[pos..].ends_with(part) {
return false;
}
pos = value.len();
} else {
match value[pos..].find(part) {
Some(found) => {
pos = pos.saturating_add(found).saturating_add(part.len());
}
None => return false,
}
}
}
true
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Blocklist {
pub digests: Vec<BlocklistEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub publishers: Vec<BlockedPublisher>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlocklistEntry {
pub sha256: String,
pub description: String,
pub added: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlockedPublisher {
pub identity: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository: Option<String>,
pub reason: String,
pub added: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Enforcement {
Audit = 0,
Warn = 1,
#[default]
Deny = 2,
}
impl Enforcement {
#[must_use]
pub fn strictest(self, other: Self) -> Self {
if self >= other {
self
} else {
other
}
}
#[must_use]
pub fn is_blocking(&self) -> bool {
matches!(self, Self::Deny)
}
}
#[derive(Debug, Clone)]
pub struct InstructionPatterns {
glob_set: GlobSet,
patterns: Vec<String>,
}
impl InstructionPatterns {
pub fn new(patterns: &[String]) -> Result<Self> {
let mut builder = GlobSetBuilder::new();
for pattern in patterns {
let glob = Glob::new(pattern).map_err(|e| {
NonoError::TrustPolicy(format!("invalid instruction pattern '{pattern}': {e}"))
})?;
builder.add(glob);
}
let glob_set = builder.build().map_err(|e| {
NonoError::TrustPolicy(format!("failed to build instruction pattern matcher: {e}"))
})?;
Ok(Self {
glob_set,
patterns: patterns.to_vec(),
})
}
#[must_use]
pub fn is_match<P: AsRef<Path>>(&self, path: P) -> bool {
self.glob_set.is_match(path)
}
#[must_use]
pub fn patterns(&self) -> &[String] {
&self.patterns
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SignerIdentity {
Keyed {
key_id: String,
},
Keyless {
issuer: String,
repository: String,
workflow: String,
git_ref: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum VerificationOutcome {
Verified {
publisher: String,
},
Blocked {
reason: String,
},
Unsigned,
InvalidSignature {
detail: String,
},
UntrustedPublisher {
identity: SignerIdentity,
},
DigestMismatch {
expected: String,
actual: String,
},
}
impl VerificationOutcome {
#[must_use]
pub fn is_verified(&self) -> bool {
matches!(self, Self::Verified { .. })
}
#[must_use]
pub fn should_block(&self, enforcement: Enforcement) -> bool {
if self.is_verified() {
return false;
}
if matches!(self, Self::Blocked { .. }) {
return true;
}
enforcement.is_blocking()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerificationResult {
pub path: std::path::PathBuf,
pub digest: String,
pub outcome: VerificationOutcome,
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn sample_policy() -> TrustPolicy {
TrustPolicy {
version: 1,
instruction_patterns: vec![
"SKILLS*.md".to_string(),
"CLAUDE*.md".to_string(),
"AGENT*.md".to_string(),
".github/copilot-instructions.md".to_string(),
".claude/**/*.md".to_string(),
],
publishers: vec![
Publisher {
name: "ci-publisher".to_string(),
issuer: Some("https://token.actions.githubusercontent.com".to_string()),
repository: Some("org/repo".to_string()),
workflow: Some(".github/workflows/sign.yml".to_string()),
ref_pattern: Some("refs/tags/v*".to_string()),
key_id: None,
public_key: None,
},
Publisher {
name: "local-dev".to_string(),
issuer: None,
repository: None,
workflow: None,
ref_pattern: None,
key_id: Some("nono-keystore:default".to_string()),
public_key: None,
},
],
blocklist: Blocklist {
digests: vec![BlocklistEntry {
sha256: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
.to_string(),
description: "Known malicious SKILLS.md".to_string(),
added: "2026-02-20".to_string(),
}],
publishers: vec![],
},
enforcement: Enforcement::Deny,
}
}
#[test]
fn instruction_patterns_match() {
let policy = sample_policy();
let matcher = policy.instruction_matcher().unwrap();
assert!(matcher.is_match("SKILLS.md"));
assert!(matcher.is_match("SKILLS-custom.md"));
assert!(matcher.is_match("CLAUDE.md"));
assert!(matcher.is_match("AGENT.md"));
assert!(matcher.is_match("AGENTS.md"));
assert!(matcher.is_match(".github/copilot-instructions.md"));
assert!(matcher.is_match(".claude/projects/foo/MEMORY.md"));
assert!(!matcher.is_match("claude"));
assert!(!matcher.is_match("CLAUDErc"));
}
#[test]
fn instruction_patterns_returns_originals() {
let policy = sample_policy();
let matcher = policy.instruction_matcher().unwrap();
assert_eq!(matcher.patterns().len(), 5);
assert_eq!(matcher.patterns()[0], "SKILLS*.md");
}
#[test]
fn instruction_patterns_invalid_glob() {
let result = InstructionPatterns::new(&["[invalid".to_string()]);
assert!(result.is_err());
}
#[test]
fn blocklist_check_hit() {
let policy = sample_policy();
let result = policy
.check_blocklist("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
assert!(result.is_some());
assert_eq!(result.unwrap().description, "Known malicious SKILLS.md");
}
#[test]
fn blocklist_check_miss() {
let policy = sample_policy();
let result = policy
.check_blocklist("0000000000000000000000000000000000000000000000000000000000000000");
assert!(result.is_none());
}
#[test]
fn publisher_matches_keyed() {
let policy = sample_policy();
let identity = SignerIdentity::Keyed {
key_id: "nono-keystore:default".to_string(),
};
let matches = policy.matching_publishers(&identity);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].name, "local-dev");
}
#[test]
fn publisher_matches_keyless() {
let policy = sample_policy();
let identity = SignerIdentity::Keyless {
issuer: "https://token.actions.githubusercontent.com".to_string(),
repository: "org/repo".to_string(),
workflow: ".github/workflows/sign.yml".to_string(),
git_ref: "refs/tags/v1.0.0".to_string(),
};
let matches = policy.matching_publishers(&identity);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].name, "ci-publisher");
}
#[test]
fn publisher_no_match_wrong_repo() {
let policy = sample_policy();
let identity = SignerIdentity::Keyless {
issuer: "https://token.actions.githubusercontent.com".to_string(),
repository: "evil/repo".to_string(),
workflow: ".github/workflows/sign.yml".to_string(),
git_ref: "refs/tags/v1.0.0".to_string(),
};
let matches = policy.matching_publishers(&identity);
assert!(matches.is_empty());
}
#[test]
fn publisher_no_match_wrong_ref() {
let policy = sample_policy();
let identity = SignerIdentity::Keyless {
issuer: "https://token.actions.githubusercontent.com".to_string(),
repository: "org/repo".to_string(),
workflow: ".github/workflows/sign.yml".to_string(),
git_ref: "refs/heads/main".to_string(),
};
let matches = policy.matching_publishers(&identity);
assert!(matches.is_empty());
}
#[test]
fn publisher_keyed_vs_keyless_no_cross_match() {
let policy = sample_policy();
let keyed_identity = SignerIdentity::Keyed {
key_id: "wrong-key".to_string(),
};
assert!(policy.matching_publishers(&keyed_identity).is_empty());
let keyless_identity = SignerIdentity::Keyless {
issuer: "https://other.issuer.com".to_string(),
repository: "org/repo".to_string(),
workflow: ".github/workflows/sign.yml".to_string(),
git_ref: "refs/tags/v1.0.0".to_string(),
};
assert!(policy.matching_publishers(&keyless_identity).is_empty());
}
#[test]
fn wildcard_publisher_matching() {
let publisher = Publisher {
name: "wildcard-org".to_string(),
issuer: Some("https://token.actions.githubusercontent.com".to_string()),
repository: Some("my-org/*".to_string()),
workflow: Some("*".to_string()),
ref_pattern: Some("*".to_string()),
key_id: None,
public_key: None,
};
let identity = SignerIdentity::Keyless {
issuer: "https://token.actions.githubusercontent.com".to_string(),
repository: "my-org/any-repo".to_string(),
workflow: ".github/workflows/anything.yml".to_string(),
git_ref: "refs/heads/main".to_string(),
};
assert!(publisher.matches(&identity));
}
#[test]
fn wildcard_match_interior() {
assert!(wildcard_match("org/*/repo", "org/team/repo"));
assert!(!wildcard_match("org/*/repo", "org/team/other"));
}
#[test]
fn wildcard_match_prefix() {
assert!(wildcard_match("*.example.com", "sub.example.com"));
assert!(wildcard_match("*.example.com", "deep.sub.example.com"));
assert!(!wildcard_match("*.example.com", "example.org"));
}
#[test]
fn wildcard_match_multiple() {
assert!(wildcard_match("org/*/sub/*", "org/a/sub/b"));
assert!(wildcard_match("org/*/sub/*", "org/team/sub/anything"));
assert!(!wildcard_match("org/*/sub/*", "org/team/other/b"));
}
#[test]
fn wildcard_match_exact() {
assert!(wildcard_match("exact", "exact"));
assert!(!wildcard_match("exact", "other"));
}
#[test]
fn wildcard_match_all() {
assert!(wildcard_match("*", "anything"));
assert!(wildcard_match("*", ""));
}
#[test]
fn publisher_rejects_empty_identity_fields() {
let publisher = Publisher {
name: "wildcard-all".to_string(),
issuer: Some("https://token.actions.githubusercontent.com".to_string()),
repository: Some("*".to_string()),
workflow: Some("*".to_string()),
ref_pattern: Some("*".to_string()),
key_id: None,
public_key: None,
};
let empty_issuer = SignerIdentity::Keyless {
issuer: String::new(),
repository: "org/repo".to_string(),
workflow: "wf.yml".to_string(),
git_ref: "refs/heads/main".to_string(),
};
assert!(!publisher.matches(&empty_issuer));
let empty_repo = SignerIdentity::Keyless {
issuer: "https://token.actions.githubusercontent.com".to_string(),
repository: String::new(),
workflow: "wf.yml".to_string(),
git_ref: "refs/heads/main".to_string(),
};
assert!(!publisher.matches(&empty_repo));
let empty_wf = SignerIdentity::Keyless {
issuer: "https://token.actions.githubusercontent.com".to_string(),
repository: "org/repo".to_string(),
workflow: String::new(),
git_ref: "refs/heads/main".to_string(),
};
assert!(!publisher.matches(&empty_wf));
let empty_ref = SignerIdentity::Keyless {
issuer: "https://token.actions.githubusercontent.com".to_string(),
repository: "org/repo".to_string(),
workflow: "wf.yml".to_string(),
git_ref: String::new(),
};
assert!(!publisher.matches(&empty_ref));
}
#[test]
fn wildcard_match_suffix() {
assert!(wildcard_match("org/*", "org/repo"));
assert!(wildcard_match("org/*", "org/any/thing"));
assert!(!wildcard_match("org/*", "other/repo"));
}
#[test]
fn enforcement_ordering() {
assert!(Enforcement::Deny > Enforcement::Warn);
assert!(Enforcement::Warn > Enforcement::Audit);
assert_eq!(
Enforcement::Audit.strictest(Enforcement::Deny),
Enforcement::Deny
);
assert_eq!(
Enforcement::Deny.strictest(Enforcement::Audit),
Enforcement::Deny
);
assert_eq!(
Enforcement::Warn.strictest(Enforcement::Warn),
Enforcement::Warn
);
}
#[test]
fn enforcement_is_blocking() {
assert!(Enforcement::Deny.is_blocking());
assert!(!Enforcement::Warn.is_blocking());
assert!(!Enforcement::Audit.is_blocking());
}
#[test]
fn verification_outcome_verified() {
let outcome = VerificationOutcome::Verified {
publisher: "test".to_string(),
};
assert!(outcome.is_verified());
assert!(!outcome.should_block(Enforcement::Deny));
}
#[test]
fn verification_outcome_blocked_always_blocks() {
let outcome = VerificationOutcome::Blocked {
reason: "malicious".to_string(),
};
assert!(!outcome.is_verified());
assert!(outcome.should_block(Enforcement::Deny));
assert!(outcome.should_block(Enforcement::Warn));
assert!(outcome.should_block(Enforcement::Audit));
}
#[test]
fn verification_outcome_unsigned_respects_enforcement() {
let outcome = VerificationOutcome::Unsigned;
assert!(outcome.should_block(Enforcement::Deny));
assert!(!outcome.should_block(Enforcement::Warn));
assert!(!outcome.should_block(Enforcement::Audit));
}
#[test]
fn verification_outcome_untrusted_respects_enforcement() {
let outcome = VerificationOutcome::UntrustedPublisher {
identity: SignerIdentity::Keyed {
key_id: "unknown".to_string(),
},
};
assert!(outcome.should_block(Enforcement::Deny));
assert!(!outcome.should_block(Enforcement::Warn));
}
#[test]
fn verification_outcome_digest_mismatch() {
let outcome = VerificationOutcome::DigestMismatch {
expected: "aaa".to_string(),
actual: "bbb".to_string(),
};
assert!(!outcome.is_verified());
assert!(outcome.should_block(Enforcement::Deny));
}
#[test]
fn publisher_is_keyed_and_keyless() {
let keyed = Publisher {
name: "k".to_string(),
issuer: None,
repository: None,
workflow: None,
ref_pattern: None,
key_id: Some("id".to_string()),
public_key: None,
};
assert!(keyed.is_keyed());
assert!(!keyed.is_keyless());
let keyless = Publisher {
name: "kl".to_string(),
issuer: Some("https://issuer".to_string()),
repository: Some("org/repo".to_string()),
workflow: Some("*".to_string()),
ref_pattern: Some("*".to_string()),
key_id: None,
public_key: None,
};
assert!(!keyless.is_keyed());
assert!(keyless.is_keyless());
}
#[test]
fn validate_version_rejects_unsupported() {
let mut policy = sample_policy();
policy.version = 99;
let result = policy.validate_version();
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("unsupported trust policy version 99"));
}
#[test]
fn validate_version_accepts_current() {
let policy = sample_policy();
assert!(policy.validate_version().is_ok());
}
#[test]
fn trust_policy_serde_roundtrip() {
let policy = sample_policy();
let json = serde_json::to_string_pretty(&policy).unwrap();
let parsed: TrustPolicy = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.version, 1);
assert_eq!(parsed.publishers.len(), 2);
assert_eq!(parsed.blocklist.digests.len(), 1);
assert_eq!(parsed.enforcement, Enforcement::Deny);
}
#[test]
fn verification_result_serde_roundtrip() {
let result = VerificationResult {
path: std::path::PathBuf::from("SKILLS.md"),
digest: "abcd1234".to_string(),
outcome: VerificationOutcome::Verified {
publisher: "test-pub".to_string(),
},
};
let json = serde_json::to_string(&result).unwrap();
let parsed: VerificationResult = serde_json::from_str(&json).unwrap();
assert!(parsed.outcome.is_verified());
}
}