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