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#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
130pub struct FilesystemEntitlement {
131    #[serde(default)]
132    pub read: Vec<String>,
133    #[serde(default)]
134    pub write: Vec<String>,
135    #[serde(default)]
136    pub deny: Vec<String>,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
140pub struct ProcessesEntitlement {
141    pub spawn: SpawnEntitlement,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
145pub struct SpawnEntitlement {
146    pub mode: SpawnMode,
147    #[serde(default)]
148    pub allowed: Vec<String>,
149    /// Directories whose entire subtree may be exec'd — the "build lane".
150    ///
151    /// `allowed` cannot express a toolchain that compiles its own
152    /// executables: a Rust build execs `target/debug/build/<crate>-<hash>/
153    /// build-script-build`, proc-macro shims, and freshly linked test
154    /// binaries, all at paths that do not exist until the build creates them
155    /// and change on every dependency bump. Without this an agent granted
156    /// `cargo` could compile nothing and could never verify its own work.
157    ///
158    /// Grant narrowly — a build-output directory, not a source tree or a
159    /// home directory. Everything under it becomes exec'able, so the tree
160    /// should be one the agent already has write access to and nothing else
161    /// depends on. Filesystem and network entitlements still bound what the
162    /// executed code can reach.
163    #[serde(default)]
164    pub allowed_dirs: Vec<String>,
165}
166
167#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
168#[serde(rename_all = "lowercase")]
169pub enum SpawnMode {
170    Allowlist,
171    Any,
172    None,
173    /// Shell-only: fences the system exec paths (`/bin`, `/usr/bin`,
174    /// `/usr/lib`) that `Allowlist` mode exempts by default, so only the
175    /// resolved shell binary the `bash` tool itself spawns plus the
176    /// profile's own `spawn_allowed_paths`/`spawn_allowed_prefixes` may be
177    /// exec'd -- no other system binary (coreutils, `git`, etc.) is implied.
178    Strict,
179}
180
181#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
182pub struct SyscallsEntitlement {
183    #[serde(default = "default_syscalls_mode")]
184    pub mode: String,
185    #[serde(default)]
186    pub extra_deny: Vec<String>,
187}
188fn default_syscalls_mode() -> String {
189    "default".to_string()
190}
191
192#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
193pub struct LimitsEntitlement {
194    #[serde(default)]
195    pub cpu_seconds: Option<u64>,
196    #[serde(default = "default_memory_mb")]
197    pub memory_mb: u64,
198    #[serde(default = "default_fds")]
199    pub file_descriptors: u32,
200    #[serde(default = "default_procs")]
201    pub processes: u32,
202}
203fn default_memory_mb() -> u64 {
204    512
205}
206fn default_fds() -> u32 {
207    1024
208}
209fn default_procs() -> u32 {
210    32
211}
212
213#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
214#[serde(rename_all = "lowercase")]
215pub enum ToolPolicy {
216    Allow,
217    #[default]
218    Ask,
219    Deny,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
223pub struct ToolRule {
224    pub pattern: String,
225    pub policy: ToolPolicy,
226    /// Intrinsic risk tier of this tool (v3c). Resolved most-restrictive-wins
227    /// against per-step risk + channel policy; gates pre-execution when not Read.
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub risk: Option<crate::hitl::RiskTier>,
230}
231
232/// Resolve the effective policy for `tool_name` against an ordered rule list.
233///
234/// Precedence: exact-name match > longest-prefix glob (trailing `*`) > default (`Ask`).
235pub fn resolve_tool_policy(rules: &[ToolRule], tool_name: &str) -> ToolPolicy {
236    resolve_tool_policy_opt(rules, tool_name).unwrap_or_default()
237}
238
239/// Like [`resolve_tool_policy`] but distinguishes "no rule matched" (`None`)
240/// from an explicit rule — for tools whose registration is already gated
241/// elsewhere (e.g. `fleet_run`'s config allowlist) and that therefore want a
242/// different default than `Ask` while still honoring explicit rules.
243pub fn resolve_tool_policy_opt(rules: &[ToolRule], tool_name: &str) -> Option<ToolPolicy> {
244    for rule in rules {
245        if rule.pattern == tool_name {
246            return Some(rule.policy);
247        }
248    }
249    let mut best: Option<(&ToolRule, usize)> = None;
250    for rule in rules {
251        if let Some(prefix) = rule.pattern.strip_suffix('*')
252            && tool_name.starts_with(prefix)
253        {
254            let len = prefix.len();
255            if best.is_none_or(|(_, best_len)| len > best_len) {
256                best = Some((rule, len));
257            }
258        }
259    }
260    best.map(|(rule, _)| rule.policy)
261}
262
263#[cfg(test)]
264mod tool_policy_tests {
265    use super::*;
266
267    fn rules() -> Vec<ToolRule> {
268        vec![
269            ToolRule {
270                pattern: "mcp__github__merge_pr".into(),
271                policy: ToolPolicy::Ask,
272                risk: None,
273            },
274            ToolRule {
275                pattern: "mcp__github__*".into(),
276                policy: ToolPolicy::Allow,
277                risk: None,
278            },
279            ToolRule {
280                pattern: "mcp__*".into(),
281                policy: ToolPolicy::Deny,
282                risk: None,
283            },
284            ToolRule {
285                pattern: "bash".into(),
286                policy: ToolPolicy::Allow,
287                risk: None,
288            },
289        ]
290    }
291
292    #[test]
293    fn exact_beats_glob() {
294        assert_eq!(
295            resolve_tool_policy(&rules(), "mcp__github__merge_pr"),
296            ToolPolicy::Ask
297        );
298    }
299
300    #[test]
301    fn longer_glob_wins() {
302        assert_eq!(
303            resolve_tool_policy(&rules(), "mcp__github__create_issue"),
304            ToolPolicy::Allow
305        );
306    }
307
308    #[test]
309    fn shorter_glob_fallback() {
310        assert_eq!(
311            resolve_tool_policy(&rules(), "mcp__slack__send"),
312            ToolPolicy::Deny
313        );
314    }
315
316    #[test]
317    fn exact_bash() {
318        assert_eq!(resolve_tool_policy(&rules(), "bash"), ToolPolicy::Allow);
319    }
320
321    #[test]
322    fn unknown_tool_defaults_ask() {
323        assert_eq!(
324            resolve_tool_policy(&rules(), "unknown_tool"),
325            ToolPolicy::Ask
326        );
327    }
328
329    #[test]
330    fn empty_rules_defaults_ask() {
331        assert_eq!(resolve_tool_policy(&[], "bash"), ToolPolicy::Ask);
332    }
333
334    fn minimal_entitlements_yaml() -> &'static str {
335        "network:\n  inbound: {}\n  outbound:\n    mode: off\nfilesystem: {}\nprocesses:\n  spawn:\n    mode: none\n"
336    }
337
338    #[test]
339    fn entitlements_tools_defaults_empty() {
340        let e: Entitlements = serde_yaml_ng::from_str(minimal_entitlements_yaml()).unwrap();
341        assert!(e.tools.is_empty());
342    }
343
344    #[test]
345    fn entitlements_tools_roundtrip() {
346        let base = minimal_entitlements_yaml();
347        let yaml = format!("{base}tools:\n  - pattern: \"mcp__github__*\"\n    policy: allow\n");
348        let e: Entitlements = serde_yaml_ng::from_str(&yaml).unwrap();
349        assert_eq!(e.tools.len(), 1);
350        assert_eq!(e.tools[0].policy, ToolPolicy::Allow);
351        let y = serde_yaml_ng::to_string(&e).unwrap();
352        let back: Entitlements = serde_yaml_ng::from_str(&y).unwrap();
353        assert_eq!(back.tools.len(), 1);
354        assert_eq!(back.tools[0].policy, ToolPolicy::Allow);
355    }
356    #[test]
357    fn denylist_membership_and_mutation() {
358        let mut list: Vec<String> = vec![];
359        assert!(name_enabled(&list, "a"), "empty denylist => enabled");
360
361        set_denylist(&mut list, "a", false); // disable
362        assert!(!name_enabled(&list, "a"));
363        assert_eq!(list, ["a"]);
364
365        set_denylist(&mut list, "a", false); // idempotent disable
366        assert_eq!(list, ["a"], "no duplicate entries");
367
368        set_denylist(&mut list, "a", true); // enable removes
369        assert!(name_enabled(&list, "a"));
370        assert!(list.is_empty());
371
372        set_denylist(&mut list, "b", true); // enabling an absent name is a no-op
373        assert!(list.is_empty());
374    }
375
376    #[test]
377    fn addon_group_rule_truth_table() {
378        let mut p = crate::agent::AgentProfile::default_for_tests();
379        p.addons.push(AddonRef {
380            id: "grp".into(),
381            source: "claude-local:grp@1.0.0".into(),
382            enabled: false,
383            skills: vec!["g_skill".into()],
384            mcp: vec!["g_mcp".into()],
385            commands: vec!["g_cmd".into()],
386            content_hash: None,
387            fetch_ref: None,
388            fetch_plugin: None,
389        });
390
391        // 1. standalone item, no entry anywhere => enabled (back-compat)
392        assert!(p.skill_enabled("standalone"));
393        assert!(p.mcp_enabled("standalone_mcp"));
394
395        // 2. grouped item, group disabled => off (cannot enable one member of a disabled group)
396        assert!(!p.skill_enabled("g_skill"));
397        assert!(!p.mcp_enabled("g_mcp"));
398
399        // 3. grouped item, group enabled, name not denied => on
400        assert!(p.set_addon_enabled("grp", true));
401        assert!(p.skill_enabled("g_skill"));
402        assert!(p.mcp_enabled("g_mcp"));
403
404        // 4. name in denylist overrides an enabled group => off (silence one member)
405        p.set_skill_enabled("g_skill", false);
406        assert!(!p.skill_enabled("g_skill"));
407
408        // set_addon_enabled on a missing id reports false
409        assert!(!p.set_addon_enabled("nope", true));
410
411        // kill-switch: only flips group flags — no denylist push
412        p.disable_all_addons();
413        assert!(p.addons.iter().all(|g| !g.enabled));
414        assert!(!p.skill_enabled("g_skill"));
415        assert!(!p.skill_enabled("g_cmd"));
416        assert!(!p.mcp_enabled("g_mcp")); // mcp kill-switch asserted
417
418        // re-enable restores members — kill-switch is NOT sticky
419        // (g_skill was individually denied in step 4 above and stays off;
420        //  g_cmd and g_mcp were never individually denied so they come back on)
421        assert!(p.set_addon_enabled("grp", true));
422        assert!(!p.skill_enabled("g_skill")); // still individually denied from step 4
423        assert!(p.skill_enabled("g_cmd")); // restored: never individually denied
424        assert!(p.mcp_enabled("g_mcp")); // restored: never individually denied
425
426        // clearing the individual deny fully restores g_skill too
427        p.set_skill_enabled("g_skill", true);
428        assert!(p.skill_enabled("g_skill"));
429    }
430
431    #[test]
432    fn addon_ref_content_hash_and_fetch_ref_default_none_and_round_trip() {
433        // legacy AddonRef (no new fields) → None
434        let legacy = "id: a\nsource: claude-local:a@1\nenabled: false\n";
435        let r: AddonRef = serde_yaml_ng::from_str(legacy).unwrap();
436        assert_eq!(r.content_hash, None);
437        assert_eq!(r.fetch_ref, None);
438
439        // with the new fields → round-trips
440        let full = "id: a\nsource: claude-local:a@1\nenabled: true\ncontent_hash: abc123\nfetch_ref: owner/repo\n";
441        let r2: AddonRef = serde_yaml_ng::from_str(full).unwrap();
442        assert_eq!(r2.content_hash.as_deref(), Some("abc123"));
443        assert_eq!(r2.fetch_ref.as_deref(), Some("owner/repo"));
444        let back = serde_yaml_ng::to_string(&r2).unwrap();
445        let r3: AddonRef = serde_yaml_ng::from_str(&back).unwrap();
446        assert_eq!(r2, r3);
447    }
448}