lean_ctx/core/policy/org/
mod.rs1pub mod model;
20pub mod store;
21pub mod trust;
22
23use std::path::PathBuf;
24
25pub use model::{OrgPolicyV1, OrgVerifyResult};
26pub use trust::{TrustStore, TrustedKey};
27
28use crate::core::policy::ResolvedPolicy;
29
30#[must_use]
33pub fn org_key_id(org: &str) -> String {
34 let safe: String = org
35 .chars()
36 .map(|c| {
37 if c.is_ascii_alphanumeric() || c == '-' {
38 c.to_ascii_lowercase()
39 } else {
40 '-'
41 }
42 })
43 .collect();
44 format!("org-{safe}")
45}
46
47#[must_use]
52pub fn active_resolved() -> Option<ResolvedPolicy> {
53 let artifact = store::load_active()?;
54
55 let verdict = artifact.verify();
56 if !verdict.signature_valid {
57 tracing::warn!(
58 "org policy: signature invalid ({}); not applied",
59 verdict.error.as_deref().unwrap_or("unknown")
60 );
61 return None;
62 }
63
64 let signer = verdict.signer_public_key.as_deref().unwrap_or_default();
65 if !trust::is_trusted(signer) {
66 tracing::warn!(
67 "org policy: signer key not pinned; not applied \
68 (pin it with `lean-ctx policy org trust <key>`)"
69 );
70 return None;
71 }
72
73 if !artifact.enforced {
77 return None;
78 }
79
80 match artifact.resolved() {
81 Ok(resolved) => Some(resolved),
82 Err(e) => {
83 tracing::warn!("org policy: pack does not resolve ({e}); not applied");
84 None
85 }
86 }
87}
88
89#[derive(Debug, Clone, Default)]
91pub struct OrgStatus {
92 pub present: bool,
94 pub source: Option<PathBuf>,
95 pub org: Option<String>,
96 pub policy_version: Option<String>,
97 pub enforced: bool,
98 pub issued_at: Option<String>,
99 pub signer_public_key: Option<String>,
100 pub signature_valid: bool,
102 pub trusted: bool,
104 pub applied: bool,
107 pub resolve_error: Option<String>,
109 pub pinned_anchors: usize,
111}
112
113#[must_use]
115pub fn status() -> OrgStatus {
116 let mut s = OrgStatus {
117 pinned_anchors: trust::trusted_keys().len(),
118 ..OrgStatus::default()
119 };
120 let Some(source) = store::source_path() else {
121 return s;
122 };
123 s.present = true;
124 s.source = Some(source.clone());
125 let Ok(artifact) = store::read(&source) else {
126 return s;
127 };
128 s.org = Some(artifact.org.clone());
129 s.policy_version = Some(artifact.policy_version.clone());
130 s.enforced = artifact.enforced;
131 s.issued_at = Some(artifact.issued_at.clone());
132
133 let verdict = artifact.verify();
134 s.signature_valid = verdict.signature_valid;
135 s.signer_public_key.clone_from(&verdict.signer_public_key);
136 s.trusted = verdict
137 .signer_public_key
138 .as_deref()
139 .is_some_and(trust::is_trusted);
140
141 if s.signature_valid && s.trusted {
142 match artifact.resolved() {
143 Ok(_) => s.applied = artifact.enforced,
146 Err(e) => s.resolve_error = Some(e.to_string()),
147 }
148 }
149 s
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155 use crate::core::data_dir::isolated_data_dir;
156
157 const PACK: &str = r#"
158name = "acme-floor"
159version = "1.0.0"
160description = "ACME org floor"
161
162[context]
163deny_tools = ["ctx_url_read"]
164"#;
165
166 fn install_signed(enforced: bool) -> String {
167 let key = crate::core::agent_identity::get_or_create_keypair(&org_key_id("acme")).unwrap();
168 let mut a = OrgPolicyV1::build("acme", "2026.06.1", enforced, PACK).unwrap();
169 a.sign_with_key(&key);
170 store::install(&a).unwrap();
171 crate::core::agent_identity::hex_encode(&key.verifying_key().to_bytes())
172 }
173
174 #[test]
175 fn org_key_id_is_sanitised() {
176 assert_eq!(org_key_id("ACME Corp"), "org-acme-corp");
177 assert_eq!(org_key_id("acme"), "org-acme");
178 }
179
180 #[test]
181 fn not_applied_without_trust_even_if_signed() {
182 let _iso = isolated_data_dir();
183 install_signed(true);
184 assert!(active_resolved().is_none());
186 let s = status();
187 assert!(s.present);
188 assert!(s.signature_valid);
189 assert!(!s.trusted);
190 assert!(!s.applied);
191 }
192
193 #[test]
194 fn applied_once_signer_is_pinned() {
195 let _iso = isolated_data_dir();
196 let pk = install_signed(true);
197 trust::pin("acme", &pk).unwrap();
198 let resolved = active_resolved().expect("trusted + signed → applied");
199 assert!(resolved.deny_tools.contains(&"ctx_url_read".to_string()));
200 let s = status();
201 assert!(s.applied);
202 assert!(s.trusted);
203 assert_eq!(s.org.as_deref(), Some("acme"));
204 }
205
206 #[test]
207 fn advisory_artifact_is_trusted_but_not_enforced() {
208 let _iso = isolated_data_dir();
209 let pk = install_signed(false); trust::pin("acme", &pk).unwrap();
211 assert!(
212 active_resolved().is_none(),
213 "advisory policy must not be enforced"
214 );
215 let s = status();
216 assert!(s.present && s.signature_valid && s.trusted);
217 assert!(!s.enforced);
218 assert!(!s.applied, "advisory ⇒ not applied");
219 }
220
221 #[test]
222 fn tampered_artifact_is_not_applied() {
223 let _iso = isolated_data_dir();
224 let pk = install_signed(true);
225 trust::pin("acme", &pk).unwrap();
226 let path = store::installed_path().unwrap();
228 let text = std::fs::read_to_string(&path).unwrap();
229 std::fs::write(&path, text.replace("ctx_url_read", "ctx_read")).unwrap();
230 assert!(active_resolved().is_none(), "tampered body must not apply");
231 let s = status();
232 assert!(!s.signature_valid);
233 assert!(!s.applied);
234 }
235}