Skip to main content

mur_common/agent/
entitlements.rs

1use super::*;
2
3#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
4pub struct Entitlements {
5    pub network: NetworkEntitlement,
6    pub filesystem: FilesystemEntitlement,
7    pub processes: ProcessesEntitlement,
8    #[serde(default)]
9    pub syscalls: SyscallsEntitlement,
10    #[serde(default)]
11    pub limits: LimitsEntitlement,
12    /// LLM call permission. Default = Allowed (back-compat). Bridges set to Off
13    /// so the supervisor refuses to construct an LLM client.
14    #[serde(default)]
15    pub llm: crate::bridge::llm_entitlement::LlmEntitlement,
16    /// Per-tool allow/ask/deny policy. Empty = all tools use default (Ask).
17    #[serde(default, skip_serializing_if = "Vec::is_empty")]
18    pub tools: Vec<ToolRule>,
19    /// When `true` (the default), a sandbox apply failure is fatal: the agent
20    /// refuses to start rather than running advisory-only (unconfined).
21    /// Set to `false` only for development or trusted-workstation agents that
22    /// intentionally run without kernel sandbox enforcement.
23    #[serde(default = "default_true")]
24    pub fail_closed_on_sandbox_error: bool,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
28pub struct NetworkEntitlement {
29    pub inbound: InboundNetwork,
30    pub outbound: OutboundNetwork,
31}
32
33#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
34pub struct InboundNetwork {
35    #[serde(default)]
36    pub ports: Vec<u16>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
40pub struct OutboundNetwork {
41    pub mode: NetworkOutboundMode,
42    #[serde(default)]
43    pub allow_hosts: Vec<String>,
44    /// Extra outbound TCP ports granted on top of the built-in web set
45    /// (`RESTRICTED_GENERAL_PORTS`: 80/443/8080/8443). Issue #006: without
46    /// this, a non-web port (ssh 2222, vite 5173, ollama 11434) was
47    /// unreachable under `restricted` and the only escape was
48    /// `unrestricted`, which opens EVERY port.
49    ///
50    /// Honored under `Restricted` ONLY. `Off` stays air-gapped and
51    /// `ProxyOnly` keeps denying general TCP — a stale entry in a profile
52    /// whose mode was later tightened must never silently reopen it.
53    ///
54    /// This is a PORT grant, not a host grant: like the base set, the port
55    /// opens to host `*`, because macOS SBPL's `remote tcp` accepts only
56    /// `*` or `localhost` as the host. Bounding WHICH host is reached on
57    /// that port remains HostGuard's job via `allow_hosts`.
58    #[serde(default, skip_serializing_if = "Vec::is_empty")]
59    pub allow_ports: Vec<u16>,
60    #[serde(default = "default_protocols")]
61    pub protocols: Vec<String>,
62    #[serde(default)]
63    pub resolve_dns: ResolveDnsConfig,
64}
65fn default_protocols() -> Vec<String> {
66    vec!["tcp".to_string()]
67}
68
69/// Record of who authorized a broad egress grant, and when. Attached to a
70/// per-MCP-server `McpServerNetwork` when its mode is `BroadAudited`, so the
71/// grant is persisted, portable, and re-approvable on import.
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
73pub struct EgressAuthorization {
74    pub authorized_by: String,
75    pub authorized_at_ms: u64,
76}
77
78#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
79#[serde(rename_all = "lowercase")]
80pub enum NetworkOutboundMode {
81    Unrestricted,
82    Restricted,
83    /// Deny all general outbound TCP; egress is ONLY via loopback proxies
84    /// (the agent's cc-proxy LLM port + the egress proxy). Hostnames are still
85    /// governed by `allow_hosts` (HostGuard) — unlike `Off`, which blocks all.
86    ProxyOnly,
87    Off,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
91pub struct ResolveDnsConfig {
92    #[serde(default = "default_dns_mode")]
93    pub mode: String,
94    #[serde(default)]
95    pub servers: Vec<String>,
96}
97impl Default for ResolveDnsConfig {
98    fn default() -> Self {
99        Self {
100            mode: default_dns_mode(),
101            servers: vec![],
102        }
103    }
104}
105fn default_dns_mode() -> String {
106    "system".to_string()
107}
108
109/// Dirs under `<mur_home>` where MUR objects are authored.
110///
111/// The seeded concierge gets read+write on these; without them the one agent a
112/// fresh host has can describe a skill or workflow but cannot create one, and
113/// every answer ends in "run this command yourself".
114///
115/// Deliberately excludes `agents/`: `self_protected()` only covers an agent's
116/// OWN `profile.yaml` + `identity.key`, so write access there would let an
117/// agent author a sibling with unrestricted entitlements and start it, and
118/// read access would expose every other agent's Ed25519 signing key.
119///
120/// Deliberately excludes [`crate::paths::FLEETS`] for the same reason one
121/// level up: `fleet.yaml` names a fleet's members, limits and HITL
122/// pre-approvals, and `.stopped` is the operator's kill-switch. An agent that
123/// can write there can widen what a `fleet_run` it triggers is allowed to do,
124/// or clear the stop on it. Fleets are created with `mur fleet create`; the
125/// runtime already reads `fleets/` on its own (sandbox policy), so dropping
126/// the grant costs the concierge nothing it needs to *use* a fleet.
127pub const AUTHORING_DIRS: [&str; 3] = ["skills", "workflows", "artifacts"];
128
129/// The user's own credential directories, denied in every profile MUR writes.
130///
131/// SSH keys, cloud credentials and the GPG keyring: an agent has no reason to
132/// read any of them, and holding one is acting as the user on every host it
133/// unlocks. The agent wizard presets and the seeded concierge template both
134/// start from this list, so a new agent never ships with an empty deny.
135///
136/// `~`-relative on purpose — the CLI expands it at write time, and this list
137/// must mean the same thing on every install.
138pub const DEFAULT_DENY_PATHS: [&str; 3] = ["~/.ssh", "~/.aws", "~/.gnupg"];
139
140#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
141pub struct FilesystemEntitlement {
142    #[serde(default)]
143    pub read: Vec<String>,
144    #[serde(default)]
145    pub write: Vec<String>,
146    #[serde(default)]
147    pub deny: Vec<String>,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
151pub struct ProcessesEntitlement {
152    pub spawn: SpawnEntitlement,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
156pub struct SpawnEntitlement {
157    pub mode: SpawnMode,
158    #[serde(default)]
159    pub allowed: Vec<String>,
160    /// Directories whose entire subtree may be exec'd — the "build lane".
161    ///
162    /// `allowed` cannot express a toolchain that compiles its own
163    /// executables: a Rust build execs `target/debug/build/<crate>-<hash>/
164    /// build-script-build`, proc-macro shims, and freshly linked test
165    /// binaries, all at paths that do not exist until the build creates them
166    /// and change on every dependency bump. Without this an agent granted
167    /// `cargo` could compile nothing and could never verify its own work.
168    ///
169    /// Grant narrowly — a build-output directory, not a source tree or a
170    /// home directory. Everything under it becomes exec'able, so the tree
171    /// should be one the agent already has write access to and nothing else
172    /// depends on. Filesystem and network entitlements still bound what the
173    /// executed code can reach.
174    #[serde(default)]
175    pub allowed_dirs: Vec<String>,
176}
177
178#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
179#[serde(rename_all = "lowercase")]
180pub enum SpawnMode {
181    Allowlist,
182    Any,
183    None,
184    /// Shell-only: fences the system exec paths (`/bin`, `/usr/bin`,
185    /// `/usr/lib`) that `Allowlist` mode exempts by default, so only the
186    /// resolved shell binary the `bash` tool itself spawns plus the
187    /// profile's own `spawn_allowed_paths`/`spawn_allowed_prefixes` may be
188    /// exec'd -- no other system binary (coreutils, `git`, etc.) is implied.
189    Strict,
190}
191
192#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
193pub struct SyscallsEntitlement {
194    #[serde(default = "default_syscalls_mode")]
195    pub mode: String,
196    #[serde(default)]
197    pub extra_deny: Vec<String>,
198}
199fn default_syscalls_mode() -> String {
200    "default".to_string()
201}
202
203#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
204pub struct LimitsEntitlement {
205    #[serde(default)]
206    pub cpu_seconds: Option<u64>,
207    #[serde(default = "default_memory_mb")]
208    pub memory_mb: u64,
209    #[serde(default = "default_fds")]
210    pub file_descriptors: u32,
211    #[serde(default = "default_procs")]
212    pub processes: u32,
213}
214fn default_memory_mb() -> u64 {
215    512
216}
217fn default_fds() -> u32 {
218    1024
219}
220fn default_procs() -> u32 {
221    32
222}
223
224#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
225#[serde(rename_all = "lowercase")]
226pub enum ToolPolicy {
227    Allow,
228    #[default]
229    Ask,
230    Deny,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
234pub struct ToolRule {
235    pub pattern: String,
236    pub policy: ToolPolicy,
237    /// Intrinsic risk tier of this tool (v3c). Resolved most-restrictive-wins
238    /// across every matching rule (see [`resolve_tool_risk`]); a tier above
239    /// `Write` turns `policy: allow` into a pre-execution approval (#1600).
240    /// `deny` still wins outright.
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub risk: Option<crate::hitl::RiskTier>,
243}
244
245/// Resolve the effective policy for `tool_name` against an ordered rule list.
246///
247/// Precedence: exact-name match > longest-prefix glob (trailing `*`) > default (`Ask`).
248pub fn resolve_tool_policy(rules: &[ToolRule], tool_name: &str) -> ToolPolicy {
249    resolve_tool_policy_opt(rules, tool_name).unwrap_or_default()
250}
251
252/// Like [`resolve_tool_policy`] but distinguishes "no rule matched" (`None`)
253/// from an explicit rule — for tools whose registration is already gated
254/// elsewhere (e.g. `fleet_run`'s config allowlist) and that therefore want a
255/// different default than `Ask` while still honoring explicit rules.
256pub fn resolve_tool_policy_opt(rules: &[ToolRule], tool_name: &str) -> Option<ToolPolicy> {
257    for rule in rules {
258        if rule.pattern == tool_name {
259            return Some(rule.policy);
260        }
261    }
262    let mut best: Option<(&ToolRule, usize)> = None;
263    for rule in rules {
264        if let Some(prefix) = rule.pattern.strip_suffix('*')
265            && tool_name.starts_with(prefix)
266        {
267            let len = prefix.len();
268            if best.is_none_or(|(_, best_len)| len > best_len) {
269                best = Some((rule, len));
270            }
271        }
272    }
273    best.map(|(rule, _)| rule.policy)
274}
275
276/// The declared `risk:` that applies to `tool_name` — the STRICTEST across
277/// every matching rule (exact or trailing-`*` glob), so the field comment's
278/// most-restrictive-wins holds even when a narrower rule decided the policy.
279/// `None` = no matching rule declares a risk; the caller's own classification
280/// stands alone.
281pub fn resolve_tool_risk(rules: &[ToolRule], tool_name: &str) -> Option<crate::hitl::RiskTier> {
282    rules
283        .iter()
284        .filter(|rule| {
285            rule.pattern == tool_name
286                || rule
287                    .pattern
288                    .strip_suffix('*')
289                    .is_some_and(|prefix| tool_name.starts_with(prefix))
290        })
291        .filter_map(|rule| rule.risk)
292        .max()
293}
294
295#[cfg(test)]
296mod tool_policy_tests {
297    use super::*;
298
299    fn rules() -> Vec<ToolRule> {
300        vec![
301            ToolRule {
302                pattern: "mcp__github__merge_pr".into(),
303                policy: ToolPolicy::Ask,
304                risk: None,
305            },
306            ToolRule {
307                pattern: "mcp__github__*".into(),
308                policy: ToolPolicy::Allow,
309                risk: None,
310            },
311            ToolRule {
312                pattern: "mcp__*".into(),
313                policy: ToolPolicy::Deny,
314                risk: None,
315            },
316            ToolRule {
317                pattern: "bash".into(),
318                policy: ToolPolicy::Allow,
319                risk: None,
320            },
321        ]
322    }
323
324    fn risky(pattern: &str, policy: ToolPolicy, risk: RiskTier) -> ToolRule {
325        ToolRule {
326            pattern: pattern.into(),
327            policy,
328            risk: Some(risk),
329        }
330    }
331
332    use crate::hitl::RiskTier;
333
334    /// #1600: most-restrictive-wins across EVERY matching rule, not just the
335    /// one whose policy won — a narrow `allow` must not shed a broad rule's
336    /// `destructive`.
337    #[test]
338    fn tool_risk_is_the_strictest_matching_rule() {
339        let rules = vec![
340            risky("mcp__browser__*", ToolPolicy::Allow, RiskTier::Destructive),
341            risky("mcp__browser__click", ToolPolicy::Allow, RiskTier::Write),
342            risky("mcp__other__*", ToolPolicy::Allow, RiskTier::Privileged),
343        ];
344        assert_eq!(
345            resolve_tool_risk(&rules, "mcp__browser__click"),
346            Some(RiskTier::Destructive)
347        );
348        assert_eq!(
349            resolve_tool_risk(&rules, "mcp__browser__navigate"),
350            Some(RiskTier::Destructive)
351        );
352    }
353
354    #[test]
355    fn tool_risk_is_none_without_a_declaring_rule() {
356        assert_eq!(resolve_tool_risk(&rules(), "mcp__github__merge_pr"), None);
357        assert_eq!(resolve_tool_risk(&[], "bash"), None);
358    }
359
360    #[test]
361    fn exact_beats_glob() {
362        assert_eq!(
363            resolve_tool_policy(&rules(), "mcp__github__merge_pr"),
364            ToolPolicy::Ask
365        );
366    }
367
368    #[test]
369    fn longer_glob_wins() {
370        assert_eq!(
371            resolve_tool_policy(&rules(), "mcp__github__create_issue"),
372            ToolPolicy::Allow
373        );
374    }
375
376    #[test]
377    fn shorter_glob_fallback() {
378        assert_eq!(
379            resolve_tool_policy(&rules(), "mcp__slack__send"),
380            ToolPolicy::Deny
381        );
382    }
383
384    #[test]
385    fn exact_bash() {
386        assert_eq!(resolve_tool_policy(&rules(), "bash"), ToolPolicy::Allow);
387    }
388
389    #[test]
390    fn unknown_tool_defaults_ask() {
391        assert_eq!(
392            resolve_tool_policy(&rules(), "unknown_tool"),
393            ToolPolicy::Ask
394        );
395    }
396
397    #[test]
398    fn empty_rules_defaults_ask() {
399        assert_eq!(resolve_tool_policy(&[], "bash"), ToolPolicy::Ask);
400    }
401
402    fn minimal_entitlements_yaml() -> &'static str {
403        "network:\n  inbound: {}\n  outbound:\n    mode: off\nfilesystem: {}\nprocesses:\n  spawn:\n    mode: none\n"
404    }
405
406    #[test]
407    fn entitlements_tools_defaults_empty() {
408        let e: Entitlements = serde_yaml_ng::from_str(minimal_entitlements_yaml()).unwrap();
409        assert!(e.tools.is_empty());
410    }
411
412    #[test]
413    fn entitlements_tools_roundtrip() {
414        let base = minimal_entitlements_yaml();
415        let yaml = format!("{base}tools:\n  - pattern: \"mcp__github__*\"\n    policy: allow\n");
416        let e: Entitlements = serde_yaml_ng::from_str(&yaml).unwrap();
417        assert_eq!(e.tools.len(), 1);
418        assert_eq!(e.tools[0].policy, ToolPolicy::Allow);
419        let y = serde_yaml_ng::to_string(&e).unwrap();
420        let back: Entitlements = serde_yaml_ng::from_str(&y).unwrap();
421        assert_eq!(back.tools.len(), 1);
422        assert_eq!(back.tools[0].policy, ToolPolicy::Allow);
423    }
424    #[test]
425    fn denylist_membership_and_mutation() {
426        let mut list: Vec<String> = vec![];
427        assert!(name_enabled(&list, "a"), "empty denylist => enabled");
428
429        set_denylist(&mut list, "a", false); // disable
430        assert!(!name_enabled(&list, "a"));
431        assert_eq!(list, ["a"]);
432
433        set_denylist(&mut list, "a", false); // idempotent disable
434        assert_eq!(list, ["a"], "no duplicate entries");
435
436        set_denylist(&mut list, "a", true); // enable removes
437        assert!(name_enabled(&list, "a"));
438        assert!(list.is_empty());
439
440        set_denylist(&mut list, "b", true); // enabling an absent name is a no-op
441        assert!(list.is_empty());
442    }
443
444    #[test]
445    fn addon_group_rule_truth_table() {
446        let mut p = crate::agent::AgentProfile::default_for_tests();
447        p.addons.push(AddonRef {
448            id: "grp".into(),
449            source: "claude-local:grp@1.0.0".into(),
450            enabled: false,
451            skills: vec!["g_skill".into()],
452            mcp: vec!["g_mcp".into()],
453            commands: vec!["g_cmd".into()],
454            content_hash: None,
455            fetch_ref: None,
456            fetch_plugin: None,
457        });
458
459        // 1. standalone item, no entry anywhere => enabled (back-compat)
460        assert!(p.skill_enabled("standalone"));
461        assert!(p.mcp_enabled("standalone_mcp"));
462
463        // 2. grouped item, group disabled => off (cannot enable one member of a disabled group)
464        assert!(!p.skill_enabled("g_skill"));
465        assert!(!p.mcp_enabled("g_mcp"));
466
467        // 3. grouped item, group enabled, name not denied => on
468        assert!(p.set_addon_enabled("grp", true));
469        assert!(p.skill_enabled("g_skill"));
470        assert!(p.mcp_enabled("g_mcp"));
471
472        // 4. name in denylist overrides an enabled group => off (silence one member)
473        p.set_skill_enabled("g_skill", false);
474        assert!(!p.skill_enabled("g_skill"));
475
476        // set_addon_enabled on a missing id reports false
477        assert!(!p.set_addon_enabled("nope", true));
478
479        // kill-switch: only flips group flags — no denylist push
480        p.disable_all_addons();
481        assert!(p.addons.iter().all(|g| !g.enabled));
482        assert!(!p.skill_enabled("g_skill"));
483        assert!(!p.skill_enabled("g_cmd"));
484        assert!(!p.mcp_enabled("g_mcp")); // mcp kill-switch asserted
485
486        // re-enable restores members — kill-switch is NOT sticky
487        // (g_skill was individually denied in step 4 above and stays off;
488        //  g_cmd and g_mcp were never individually denied so they come back on)
489        assert!(p.set_addon_enabled("grp", true));
490        assert!(!p.skill_enabled("g_skill")); // still individually denied from step 4
491        assert!(p.skill_enabled("g_cmd")); // restored: never individually denied
492        assert!(p.mcp_enabled("g_mcp")); // restored: never individually denied
493
494        // clearing the individual deny fully restores g_skill too
495        p.set_skill_enabled("g_skill", true);
496        assert!(p.skill_enabled("g_skill"));
497    }
498
499    #[test]
500    fn addon_ref_content_hash_and_fetch_ref_default_none_and_round_trip() {
501        // legacy AddonRef (no new fields) → None
502        let legacy = "id: a\nsource: claude-local:a@1\nenabled: false\n";
503        let r: AddonRef = serde_yaml_ng::from_str(legacy).unwrap();
504        assert_eq!(r.content_hash, None);
505        assert_eq!(r.fetch_ref, None);
506
507        // with the new fields → round-trips
508        let full = "id: a\nsource: claude-local:a@1\nenabled: true\ncontent_hash: abc123\nfetch_ref: owner/repo\n";
509        let r2: AddonRef = serde_yaml_ng::from_str(full).unwrap();
510        assert_eq!(r2.content_hash.as_deref(), Some("abc123"));
511        assert_eq!(r2.fetch_ref.as_deref(), Some("owner/repo"));
512        let back = serde_yaml_ng::to_string(&r2).unwrap();
513        let r3: AddonRef = serde_yaml_ng::from_str(&back).unwrap();
514        assert_eq!(r2, r3);
515    }
516}