1use crate::error::MurkError;
14use crate::types::{Murk, Policy, Vault};
15
16pub fn check_agent_keys(vault: &Vault, keys: &[String]) -> Result<(), MurkError> {
24 let Some(policy) = &vault.policy else {
25 return Ok(());
26 };
27
28 let forbidden: Vec<&String> = keys
29 .iter()
30 .filter(|key| !key_allowed(vault, policy, key))
31 .collect();
32
33 if forbidden.is_empty() {
34 return Ok(());
35 }
36
37 let names: Vec<&str> = forbidden.iter().map(|s| s.as_str()).collect();
38 let allowed = if policy.agent_allow_tags.is_empty() {
39 "none — this vault's policy locks agents out entirely".to_string()
40 } else {
41 policy.agent_allow_tags.join(", ")
42 };
43 Err(MurkError::Policy(format!(
44 "policy forbids {} in agent mode (allowed tags: {allowed}) — tag the key with `murk describe` or update the policy with `murk policy`",
45 names.join(", "),
46 )))
47}
48
49pub fn is_agent_identity(murk: &Murk, pubkey: &str) -> bool {
56 murk.grants.values().any(|g| g.pubkey == pubkey)
57}
58
59pub fn enforce_agent_policy(
75 vault: &Vault,
76 murk: &Murk,
77 pubkey: &str,
78 keys: &[String],
79) -> Result<(), MurkError> {
80 if is_agent_identity(murk, pubkey) || crate::hardening::self_scope() {
81 check_agent_keys(vault, keys)?;
82 }
83 Ok(())
84}
85
86fn key_allowed(vault: &Vault, policy: &Policy, key: &str) -> bool {
88 vault.schema.get(key).is_some_and(|entry| {
89 entry
90 .tags
91 .iter()
92 .any(|t| policy.agent_allow_tags.contains(t))
93 })
94}
95
96pub fn is_agent_key_allowed(vault: &Vault, key: &str) -> bool {
101 match &vault.policy {
102 None => true,
103 Some(policy) => key_allowed(vault, policy, key),
104 }
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110 use crate::types::{GrantEntry, Murk, Policy, SchemaEntry, Vault};
111 use std::collections::BTreeMap;
112
113 fn agent_murk(pubkey: &str) -> Murk {
114 let mut grants = BTreeMap::new();
115 grants.insert(
116 "codex".to_string(),
117 GrantEntry {
118 pubkey: pubkey.to_string(),
119 ..Default::default()
120 },
121 );
122 Murk {
123 grants,
124 ..Default::default()
125 }
126 }
127
128 fn vault_with(tags: &[(&str, &[&str])], policy: Option<Policy>) -> Vault {
129 let mut schema = BTreeMap::new();
130 for (key, key_tags) in tags {
131 schema.insert(
132 (*key).to_string(),
133 SchemaEntry {
134 tags: key_tags.iter().map(|t| (*t).to_string()).collect(),
135 ..Default::default()
136 },
137 );
138 }
139 Vault {
140 version: "2.0".into(),
141 created: "2026-06-16T00:00:00Z".into(),
142 vault_name: ".murk".into(),
143 repo: String::new(),
144 recipients: vec![],
145 schema,
146 policy,
147 secrets: BTreeMap::new(),
148 meta: String::new(),
149 }
150 }
151
152 fn policy(tags: &[&str]) -> Policy {
153 Policy {
154 agent_allow_tags: tags.iter().map(|t| (*t).to_string()).collect(),
155 }
156 }
157
158 #[test]
159 fn no_policy_allows_everything() {
160 let v = vault_with(&[("PROD_DB", &["production"])], None);
161 assert!(check_agent_keys(&v, &["PROD_DB".into()]).is_ok());
162 }
163
164 #[test]
165 fn allow_tag_permits_matching_key() {
166 let v = vault_with(&[("TEST_KEY", &["agents"])], Some(policy(&["agents"])));
167 assert!(check_agent_keys(&v, &["TEST_KEY".into()]).is_ok());
168 }
169
170 #[test]
171 fn missing_tag_is_refused() {
172 let v = vault_with(
173 &[("PROD_DB", &["production"]), ("TEST_KEY", &["agents"])],
174 Some(policy(&["agents"])),
175 );
176 let err = check_agent_keys(&v, &["PROD_DB".into()]).unwrap_err();
177 assert!(err.to_string().contains("PROD_DB"));
178 assert!(err.to_string().contains("agents"));
179 let err = check_agent_keys(&v, &["TEST_KEY".into(), "PROD_DB".into()]).unwrap_err();
181 assert!(err.to_string().contains("PROD_DB"));
182 assert!(!err.to_string().contains("TEST_KEY,"));
183 }
184
185 #[test]
186 fn unknown_key_is_refused_under_policy() {
187 let v = vault_with(&[], Some(policy(&["agents"])));
188 assert!(check_agent_keys(&v, &["NOPE".into()]).is_err());
189 }
190
191 #[test]
192 fn empty_allow_list_locks_agents_out() {
193 let v = vault_with(&[("TEST_KEY", &["agents"])], Some(policy(&[])));
194 let err = check_agent_keys(&v, &["TEST_KEY".into()]).unwrap_err();
195 assert!(err.to_string().contains("locks agents out"));
196 }
197
198 #[test]
199 fn is_agent_identity_matches_granted_pubkey() {
200 let murk = agent_murk("age1agent");
201 assert!(is_agent_identity(&murk, "age1agent"));
202 assert!(!is_agent_identity(&murk, "age1operator"));
203 assert!(!is_agent_identity(&Murk::default(), "age1agent"));
204 }
205
206 #[test]
207 fn enforce_agent_policy_is_noop_for_operator() {
208 let v = vault_with(&[("PROD_DB", &["production"])], Some(policy(&["agents"])));
210 let operator = Murk::default();
211 assert!(enforce_agent_policy(&v, &operator, "age1operator", &["PROD_DB".into()]).is_ok());
212 }
213
214 #[test]
215 fn enforce_agent_policy_applies_to_agents() {
216 let v = vault_with(
217 &[("PROD_DB", &["production"]), ("TEST_KEY", &["agents"])],
218 Some(policy(&["agents"])),
219 );
220 let agent = agent_murk("age1agent");
221 assert!(enforce_agent_policy(&v, &agent, "age1agent", &["TEST_KEY".into()]).is_ok());
223 let err = enforce_agent_policy(&v, &agent, "age1agent", &["PROD_DB".into()]).unwrap_err();
225 assert!(err.to_string().contains("PROD_DB"));
226 }
227
228 #[test]
229 fn enforce_agent_policy_noop_without_policy() {
230 let v = vault_with(&[("PROD_DB", &["production"])], None);
232 let agent = agent_murk("age1agent");
233 assert!(enforce_agent_policy(&v, &agent, "age1agent", &["PROD_DB".into()]).is_ok());
234 }
235
236 #[test]
237 fn is_agent_key_allowed_no_policy_allows_any_key() {
238 let v = vault_with(&[("PROD_DB", &["production"])], None);
239 assert!(is_agent_key_allowed(&v, "PROD_DB"));
240 assert!(is_agent_key_allowed(&v, "UNKNOWN"));
242 }
243
244 #[test]
245 fn is_agent_key_allowed_checks_tags_under_policy() {
246 let v = vault_with(
247 &[
248 ("TEST_KEY", &["agents"]),
249 ("UNTAGGED", &[]),
250 ("OTHER_KEY", &["other"]),
251 ],
252 Some(policy(&["agents"])),
253 );
254 assert!(is_agent_key_allowed(&v, "TEST_KEY"));
255 assert!(!is_agent_key_allowed(&v, "UNTAGGED"));
256 assert!(!is_agent_key_allowed(&v, "OTHER_KEY"));
257 }
258}