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