Skip to main content

mur_common/host_pattern/
audit.rs

1//! Find and remove stored host entries the write-time validator would refuse
2//! (#1906). Entries written before #1905 can hold anything — CJK sentences,
3//! dot-less words — and they sit in the profile matching nothing. One pass
4//! over every place a host list lives, so `mur doctor`, `--fix` and the
5//! profile-load warning cannot disagree about what is invalid.
6
7use super::normalize_host_pattern;
8use crate::agent::{AgentProfile, McpNetMode};
9
10/// Which host list an entry lives in.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum HostList {
13    /// `entitlements.network.outbound.allow_hosts`.
14    AgentAllow,
15    /// `mcp_servers[server].network.allow_hosts`.
16    McpAllow(String),
17    /// `mcp_servers[server].network.deny_hosts`.
18    McpDeny(String),
19}
20
21impl std::fmt::Display for HostList {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        match self {
24            Self::AgentAllow => write!(f, "agent outbound allow_hosts"),
25            Self::McpAllow(s) => write!(f, "MCP server `{s}` allow_hosts"),
26            Self::McpDeny(s) => write!(f, "MCP server `{s}` deny_hosts"),
27        }
28    }
29}
30
31/// One stored value the validator refuses.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct InvalidHost {
34    pub list: HostList,
35    pub value: String,
36    pub reason: String,
37}
38
39/// Every invalid entry on `profile`, in profile order.
40pub fn invalid_hosts(profile: &AgentProfile) -> Vec<InvalidHost> {
41    let mut out = Vec::new();
42    let mut scan = |list: HostList, hosts: &[String]| {
43        for h in hosts {
44            if let Err(e) = normalize_host_pattern(h) {
45                out.push(InvalidHost {
46                    list: list.clone(),
47                    value: h.clone(),
48                    reason: e.to_string(),
49                });
50            }
51        }
52    };
53    scan(
54        HostList::AgentAllow,
55        &profile.entitlements.network.outbound.allow_hosts,
56    );
57    for e in &profile.mcp_servers {
58        if let Some(n) = &e.network {
59            scan(HostList::McpAllow(e.name.clone()), &n.allow_hosts);
60            scan(HostList::McpDeny(e.name.clone()), &n.deny_hosts);
61        }
62    }
63    out
64}
65
66/// What [`remove_invalid_hosts`] did.
67#[derive(Debug, Default, Clone, PartialEq, Eq)]
68pub struct HostRepair {
69    pub removed: Vec<InvalidHost>,
70    /// `Restricted` servers left with no hosts: they now reach nothing. Flagged
71    /// for the user; the mode is never switched on their behalf.
72    pub emptied_restricted: Vec<String>,
73}
74
75/// Drop every invalid entry from `profile` in memory. Valid entries, their
76/// order, and every mode are left exactly as they were.
77pub fn remove_invalid_hosts(profile: &mut AgentProfile) -> HostRepair {
78    let removed = invalid_hosts(profile);
79    if removed.is_empty() {
80        return HostRepair::default();
81    }
82    let keep = |v: &mut Vec<String>| v.retain(|h| normalize_host_pattern(h).is_ok());
83    keep(&mut profile.entitlements.network.outbound.allow_hosts);
84    let mut emptied_restricted = Vec::new();
85    for e in &mut profile.mcp_servers {
86        let Some(n) = e.network.as_mut() else {
87            continue;
88        };
89        let had = !n.allow_hosts.is_empty();
90        keep(&mut n.allow_hosts);
91        keep(&mut n.deny_hosts);
92        if had && n.allow_hosts.is_empty() && n.mode == McpNetMode::Restricted {
93            emptied_restricted.push(e.name.clone());
94        }
95    }
96    HostRepair {
97        removed,
98        emptied_restricted,
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::agent::{McpServerEntry, McpServerNetwork};
106
107    fn profile() -> AgentProfile {
108        let yaml = r#"schema: 1
109id: 01900000-0000-7000-8000-000000000000
110name: mur
111display_name: "MUR"
112version: "0.1.0"
113persona:
114  category: custom
115  description: "test"
116  traits: { tone: neutral, risk: cautious, verbosity: low }
117sys_prompt_file: "sys_prompt.md"
118model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
119mcp_servers: []
120skills: []
121transport:
122  stdio: true
123  socket: { enabled: false, bind: "" }
124communication: { accepts_from: ["*"], sends_to: [] }
125capabilities: []
126entitlements:
127  network:
128    inbound: { ports: [] }
129    outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
130  filesystem: { read: [], write: [], deny: [] }
131  processes: { spawn: { mode: allowlist, allowed: [] } }
132  syscalls: { mode: default }
133  limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
134notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
135retry:
136  llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit] }
137  tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
138lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: false }
139created_at: "2026-01-01T00:00:00Z"
140updated_at: "2026-01-01T00:00:00Z"
141"#;
142        let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).expect("minimal profile");
143        p.entitlements.network.outbound.allow_hosts =
144            vec!["api.example.com".into(), "評分,".into()];
145        let entry = |name: &str, mode, allow: &[&str], deny: &[&str]| McpServerEntry {
146            name: name.into(),
147            network: Some(McpServerNetwork {
148                mode,
149                allow_hosts: allow.iter().map(|s| (*s).into()).collect(),
150                deny_hosts: deny.iter().map(|s| (*s).into()).collect(),
151                ..Default::default()
152            }),
153            ..serde_yaml_ng::from_str("name: x\ncommand: x").unwrap()
154        };
155        p.mcp_servers = vec![
156            entry(
157                "browser",
158                McpNetMode::Restricted,
159                &["查台灣居家修繕市場規模", "/", "競品"],
160                &[],
161            ),
162            entry(
163                "search",
164                McpNetMode::BroadAudited,
165                &[],
166                &["evil.example", "a"],
167            ),
168            entry(
169                "shop",
170                McpNetMode::Restricted,
171                &["shop.example.com", "x y"],
172                &[],
173            ),
174        ];
175        p
176    }
177
178    #[test]
179    fn reports_every_invalid_entry_with_its_list() {
180        let bad = invalid_hosts(&profile());
181        let got: Vec<(String, &str)> = bad
182            .iter()
183            .map(|b| (b.list.to_string(), b.value.as_str()))
184            .collect();
185        assert_eq!(
186            got,
187            vec![
188                ("agent outbound allow_hosts".into(), "評分,"),
189                (
190                    "MCP server `browser` allow_hosts".into(),
191                    "查台灣居家修繕市場規模"
192                ),
193                ("MCP server `browser` allow_hosts".into(), "/"),
194                ("MCP server `browser` allow_hosts".into(), "競品"),
195                ("MCP server `search` deny_hosts".into(), "a"),
196                ("MCP server `shop` allow_hosts".into(), "x y"),
197            ]
198        );
199        assert!(bad.iter().all(|b| !b.reason.is_empty()));
200    }
201
202    #[test]
203    fn fix_keeps_valid_hosts_and_flags_emptied_restricted() {
204        let mut p = profile();
205        let r = remove_invalid_hosts(&mut p);
206        assert_eq!(r.removed.len(), 6);
207        assert_eq!(r.emptied_restricted, vec!["browser".to_string()]);
208        assert_eq!(
209            p.entitlements.network.outbound.allow_hosts,
210            vec!["api.example.com"]
211        );
212        let net = |n: &str| {
213            p.mcp_servers
214                .iter()
215                .find(|e| e.name == n)
216                .unwrap()
217                .network
218                .clone()
219                .unwrap()
220        };
221        assert!(net("browser").allow_hosts.is_empty());
222        // Never re-moded.
223        assert_eq!(net("browser").mode, McpNetMode::Restricted);
224        assert_eq!(net("search").deny_hosts, vec!["evil.example"]);
225        assert_eq!(net("shop").allow_hosts, vec!["shop.example.com"]);
226        assert!(invalid_hosts(&p).is_empty());
227    }
228
229    #[test]
230    fn clean_profile_is_untouched() {
231        let mut p = profile();
232        remove_invalid_hosts(&mut p);
233        let before = p.clone();
234        assert_eq!(remove_invalid_hosts(&mut p), HostRepair::default());
235        assert_eq!(p, before);
236    }
237}