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`) and on operator reads under self-scope
6//! (`MURK_SELF_SCOPE`/`MURK_AGENT`). It lives in the plaintext header and is
7//! MAC-covered (see [`crate::compute_mac`]) so it can't be silently weakened.
8//!
9//! The only policy today is a tag allow-list: in agent mode a secret may be
10//! injected or granted only if it carries at least one allowed tag. Once a
11//! policy is set it is default-deny — untagged or wrong-tagged keys are refused.
12
13use crate::error::MurkError;
14use crate::types::{Murk, Policy, Vault};
15
16/// Check that every key in `keys` is permitted to agents by the vault's policy.
17///
18/// No policy → all keys allowed (backward compatible). With a policy, a key is
19/// allowed only if its schema carries at least one of the policy's
20/// `agent_allow_tags`. Fails closed: an unknown key (no schema entry) or a key
21/// with no matching tag is refused. Returns an error naming every forbidden key
22/// and the allowed tags, so the caller's message is actionable.
23pub 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
49/// True when `pubkey` identifies a granted agent for this decrypted vault state.
50///
51/// Agent grants live in the encrypted meta and are carried into [`Murk::grants`]
52/// after decryption, so this is the same "am I an agent" test the CLI makes when
53/// it decrypts as an agent (`lib::decrypt_vault`). An operator (or any plain
54/// recipient) is not in `grants`, so this returns `false` for them.
55pub fn is_agent_identity(murk: &Murk, pubkey: &str) -> bool {
56    murk.grants.values().any(|g| g.pubkey == pubkey)
57}
58
59/// Apply [`check_agent_keys`] when the caller is a granted agent, or when the
60/// operator has opted into self-scope ([`crate::hardening::self_scope`]).
61///
62/// The library bindings (Python/Node) load a vault and read secrets directly,
63/// without the CLI's `agent exec` policy gate. This is that gate for them: when
64/// the loaded identity is an agent grant — or the caller is self-scoping — the
65/// same policy the CLI enforces at `agent exec` applies here too, so a policy
66/// vault is enforced from every entry point. For a plain operator identity with
67/// no self-scope it is a no-op, matching the CLI's ungated `get`/`export`.
68///
69/// The real boundary is cryptographic: an agent's ephemeral key is not a
70/// recipient of out-of-scope secrets, so it cannot decrypt them regardless. This
71/// check is defense-in-depth, and it makes a later policy or tag change apply to
72/// agents retroactively at read time (the agent's old scoped ciphertext lingers,
73/// but the binding refuses to hand it over).
74pub 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
86/// True if `key` carries at least one of the policy's allowed tags.
87fn 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
96/// Whether `key` may be read under the agent allow-tag policy: always true when
97/// the vault has no policy, otherwise true only if the key carries an allowed
98/// tag. The public, per-key form of [`check_agent_keys`], used by self-scope
99/// filtering (e.g. `murk export`).
100pub 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        // A mix reports only the forbidden one.
180        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        // A policy that would forbid PROD_DB, but the caller is not an agent.
209        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        // Allowed key passes.
222        assert!(enforce_agent_policy(&v, &agent, "age1agent", &["TEST_KEY".into()]).is_ok());
223        // Forbidden key is refused for the agent.
224        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        // No policy set: even an agent reads anything (backward compatible).
231        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        // Even a key with no schema entry at all is allowed absent a policy.
241        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}