1use super::normalize_host_pattern;
8use crate::agent::{AgentProfile, McpNetMode};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum HostList {
13 AgentAllow,
15 McpAllow(String),
17 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#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct InvalidHost {
34 pub list: HostList,
35 pub value: String,
36 pub reason: String,
37}
38
39pub 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#[derive(Debug, Default, Clone, PartialEq, Eq)]
68pub struct HostRepair {
69 pub removed: Vec<InvalidHost>,
70 pub emptied_restricted: Vec<String>,
73}
74
75pub 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 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}