Skip to main content

murk_cli/
policy.rs

1//! Agent access policy: machine-enforceable guardrails embedded in the vault
2//! header. Policy is NOT access control — every recipient can read every shared
3//! secret by design. Its value is constraining what the murk binary will expose
4//! to *agents* (CI, AI coding agents), enforced at the agent entry points
5//! (`agent exec`, `agent grant`). It lives in the plaintext header and is
6//! MAC-covered (see [`crate::compute_mac`]) so it can't be silently weakened.
7//!
8//! The only policy today is a tag allow-list: in agent mode a secret may be
9//! injected or granted only if it carries at least one allowed tag. Once a
10//! policy is set it is default-deny — untagged or wrong-tagged keys are refused.
11
12use crate::error::MurkError;
13use crate::types::{Murk, Policy, Vault};
14
15/// Check that every key in `keys` is permitted to agents by the vault's policy.
16///
17/// No policy → all keys allowed (backward compatible). With a policy, a key is
18/// allowed only if its schema carries at least one of the policy's
19/// `agent_allow_tags`. Fails closed: an unknown key (no schema entry) or a key
20/// with no matching tag is refused. Returns an error naming every forbidden key
21/// and the allowed tags, so the caller's message is actionable.
22pub 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
48/// True when `pubkey` identifies a granted agent for this decrypted vault state.
49///
50/// Agent grants live in the encrypted meta and are carried into [`Murk::grants`]
51/// after decryption, so this is the same "am I an agent" test the CLI makes when
52/// it decrypts as an agent (`lib::decrypt_vault`). An operator (or any plain
53/// recipient) is not in `grants`, so this returns `false` for them.
54pub fn is_agent_identity(murk: &Murk, pubkey: &str) -> bool {
55    murk.grants.values().any(|g| g.pubkey == pubkey)
56}
57
58/// Apply [`check_agent_keys`], but only when the caller is a granted agent.
59///
60/// The library bindings (Python/Node) load a vault and read secrets directly,
61/// without the CLI's `agent exec` policy gate. This is that gate for them: when
62/// the loaded identity is an agent grant, the same policy the CLI enforces at
63/// `agent exec` applies here too — so a policy vault is strict from every entry
64/// point. For an operator identity it is a no-op, matching the CLI, where plain
65/// `get`/`export` are never policy-gated.
66///
67/// The real boundary is cryptographic: an agent's ephemeral key is not a
68/// recipient of out-of-scope secrets, so it cannot decrypt them regardless. This
69/// check is defense-in-depth, and it makes a later policy or tag change apply to
70/// agents retroactively at read time (the agent's old scoped ciphertext lingers,
71/// but the binding refuses to hand it over).
72pub 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
84/// True if `key` carries at least one of the policy's allowed tags.
85fn 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        // A mix reports only the forbidden one.
167        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        // A policy that would forbid PROD_DB, but the caller is not an agent.
196        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        // Allowed key passes.
209        assert!(enforce_agent_policy(&v, &agent, "age1agent", &["TEST_KEY".into()]).is_ok());
210        // Forbidden key is refused for the agent.
211        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        // No policy set: even an agent reads anything (backward compatible).
218        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}