返回 CodeWhale
network.rs
根目录 / crates / tui / src / commands / groups / utility / network.rs
1 //! Slash commands for the persistent network allow/deny list.
2
3 use std::fs;
4 use std::path::{Path, PathBuf};
5
6 use anyhow::{Context, bail};
7 use toml::Value;
8
9 use codewhale_command_contract::handler::CommandHandler;
10 use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand};
11
12 use crate::commands::CommandResult;
13
14 pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
15 name: "network",
16 aliases: &[],
17 usage: "/network [list|allow <host>|deny <host>|remove <host>|default <allow|deny|prompt>]",
18 description_key: "cmd_network_description",
19 };
20
21 pub(in crate::commands) struct NetworkCmd;
22
23 impl RegisterCommand<CommandResult> for NetworkCmd {
24 fn info() -> &'static CommandInfo {
25 &COMMAND_INFO
26 }
27
28 fn handler() -> CommandHandler<CommandResult> {
29 CommandHandler::Pure(network)
30 }
31 }
32
33 fn network(arg: Option<&str>) -> CommandResult {
34 match network_inner(arg) {
35 Ok(message) => CommandResult::message(message),
36 Err(err) => CommandResult::error(err.to_string()),
37 }
38 }
39
40 fn network_inner(arg: Option<&str>) -> anyhow::Result<String> {
41 let raw = arg.map(str::trim).unwrap_or("");
42 if raw.is_empty() || raw.eq_ignore_ascii_case("list") {
43 return list_policy();
44 }
45
46 let mut parts = raw.split_whitespace();
47 let Some(command) = parts.next() else {
48 return list_policy();
49 };
50 let command = command.to_ascii_lowercase();
51
52 match command.as_str() {
53 "allow" | "deny" | "remove" | "forget" => {
54 let Some(host_arg) = parts.next() else {
55 bail!("Usage: /network {command} <host>");
56 };
57 if parts.next().is_some() {
58 bail!("Usage: /network {command} <host>");
59 }
60 let host = normalize_host_arg(host_arg)?;
61 let edit = match command.as_str() {
62 "allow" => NetworkEdit::Allow,
63 "deny" => NetworkEdit::Deny,
64 _ => NetworkEdit::Remove,
65 };
66 update_host(edit, &host)
67 }
68 "default" => {
69 let Some(value) = parts.next() else {
70 bail!("Usage: /network default <allow|deny|prompt>");
71 };
72 if parts.next().is_some() {
73 bail!("Usage: /network default <allow|deny|prompt>");
74 }
75 update_default(value)
76 }
77 _ => bail!(usage()),
78 }
79 }
80
81 fn usage() -> &'static str {
82 "Usage: /network [list|allow <host>|deny <host>|remove <host>|default <allow|deny|prompt>]"
83 }
84
85 #[derive(Clone, Copy)]
86 enum NetworkEdit {
87 Allow,
88 Deny,
89 Remove,
90 }
91
92 /// Resolve the active config document path through the leaf configuration
93 /// crate (acyclic; no TUI persistence helper).
94 fn config_toml_path() -> anyhow::Result<PathBuf> {
95 codewhale_config::resolve_config_path(None)
96 }
97
98 fn list_policy() -> anyhow::Result<String> {
99 let path = config_toml_path()?;
100 let doc = load_config_doc(&path)?;
101 let network = doc.get("network").and_then(Value::as_table);
102 let default = network
103 .and_then(|table| table.get("default"))
104 .and_then(Value::as_str)
105 .unwrap_or("prompt");
106 let allow = network
107 .map(|table| string_array(table, "allow"))
108 .unwrap_or_default();
109 let deny = network
110 .map(|table| string_array(table, "deny"))
111 .unwrap_or_default();
112
113 Ok(format!(
114 "Network policy ({})\n\
115 default = {default}\n\
116 allow = {}\n\
117 deny = {}\n\n\
118 Use `/network allow <host>` to allow a host, `/network deny <host>` to block it, or `/network remove <host>` to clear an entry.",
119 path.display(),
120 display_list(&allow),
121 display_list(&deny)
122 ))
123 }
124
125 fn update_host(edit: NetworkEdit, host: &str) -> anyhow::Result<String> {
126 let path = config_toml_path()?;
127 codewhale_config::mutate_config_document(&path, |doc| {
128 ensure_network_defaults(doc)?;
129 let mut allow = document_string_array(doc, "allow")?;
130 let mut deny = document_string_array(doc, "deny")?;
131 match edit {
132 NetworkEdit::Allow => {
133 remove_host(&mut deny, host);
134 add_host(&mut allow, host);
135 }
136 NetworkEdit::Deny => {
137 remove_host(&mut allow, host);
138 add_host(&mut deny, host);
139 }
140 NetworkEdit::Remove => {
141 remove_host(&mut allow, host);
142 remove_host(&mut deny, host);
143 }
144 }
145 codewhale_config::set_config_document_value(
146 doc,
147 &["network", "allow"],
148 string_array_value(&allow),
149 )?;
150 codewhale_config::set_config_document_value(
151 doc,
152 &["network", "deny"],
153 string_array_value(&deny),
154 )
155 })?;
156 let action = match edit {
157 NetworkEdit::Allow => "allowed",
158 NetworkEdit::Deny => "denied",
159 NetworkEdit::Remove => "removed",
160 };
161 Ok(format!(
162 "Network host {action}: {host}\nSaved to {}. Retry the command now.",
163 path.display()
164 ))
165 }
166
167 fn update_default(value: &str) -> anyhow::Result<String> {
168 let normalized = match value.trim().to_ascii_lowercase().as_str() {
169 "allow" => "allow",
170 "deny" | "block" => "deny",
171 "prompt" | "ask" => "prompt",
172 _ => bail!("Usage: /network default <allow|deny|prompt>"),
173 };
174
175 let path = config_toml_path()?;
176 codewhale_config::mutate_config_document(&path, |doc| {
177 ensure_network_defaults(doc)?;
178 codewhale_config::set_config_document_value(doc, &["network", "default"], normalized)
179 })?;
180
181 Ok(format!(
182 "Network default set to {normalized}\nSaved to {}.",
183 path.display()
184 ))
185 }
186
187 fn load_config_doc(path: &Path) -> anyhow::Result<Value> {
188 if !path.exists() {
189 return Ok(Value::Table(toml::value::Table::new()));
190 }
191 let raw = fs::read_to_string(path)
192 .with_context(|| format!("failed to read config at {}", path.display()))?;
193 toml::from_str(&raw).map_err(|_| {
194 anyhow::anyhow!(
195 "failed to parse config at {}; file contents were omitted",
196 codewhale_config::quote_os_path(path)
197 )
198 })
199 }
200
201 fn ensure_network_defaults(doc: &mut toml_edit::DocumentMut) -> anyhow::Result<()> {
202 if doc
203 .get("network")
204 .and_then(toml_edit::Item::as_table_like)
205 .and_then(|table| table.get("default"))
206 .is_none()
207 {
208 codewhale_config::set_config_document_value(doc, &["network", "default"], "prompt")?;
209 }
210 if doc
211 .get("network")
212 .and_then(toml_edit::Item::as_table_like)
213 .and_then(|table| table.get("audit"))
214 .is_none()
215 {
216 codewhale_config::set_config_document_value(doc, &["network", "audit"], true)?;
217 }
218 Ok(())
219 }
220
221 fn document_string_array(doc: &toml_edit::DocumentMut, key: &str) -> anyhow::Result<Vec<String>> {
222 let Some(item) = doc
223 .get("network")
224 .and_then(toml_edit::Item::as_table_like)
225 .and_then(|table| table.get(key))
226 else {
227 return Ok(Vec::new());
228 };
229 let array = item
230 .as_array()
231 .with_context(|| format!("`network.{key}` must be an array of strings"))?;
232 array
233 .iter()
234 .map(|value| {
235 value
236 .as_str()
237 .map(ToString::to_string)
238 .with_context(|| format!("`network.{key}` must be an array of strings"))
239 })
240 .collect()
241 }
242
243 fn string_array_value(values: &[String]) -> toml_edit::Array {
244 values.iter().map(String::as_str).collect()
245 }
246
247 fn string_array(table: &toml::value::Table, key: &str) -> Vec<String> {
248 table
249 .get(key)
250 .and_then(Value::as_array)
251 .into_iter()
252 .flatten()
253 .filter_map(Value::as_str)
254 .map(ToString::to_string)
255 .collect()
256 }
257
258 fn add_host(list: &mut Vec<String>, host: &str) {
259 if !list
260 .iter()
261 .any(|existing| normalize_host_for_compare(existing) == host)
262 {
263 list.push(host.to_string());
264 }
265 }
266
267 fn remove_host(list: &mut Vec<String>, host: &str) {
268 list.retain(|existing| normalize_host_for_compare(existing) != host);
269 }
270
271 fn normalize_host_arg(input: &str) -> anyhow::Result<String> {
272 let trimmed = input.trim();
273 let host = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
274 host_from_url(trimmed).context("URL must include a host")?
275 } else {
276 if trimmed.contains("://") || trimmed.contains('/') {
277 bail!("Pass a host like `github.com`, not a URL path");
278 }
279 trimmed.to_string()
280 };
281
282 let normalized = normalize_host_for_compare(&host);
283 if normalized.is_empty() {
284 bail!("host cannot be empty");
285 }
286 Ok(normalized)
287 }
288
289 /// Extract the host portion of a URL, lowercased (leaf `reqwest::Url` parse;
290 /// no TUI helper dependency).
291 fn host_from_url(url: &str) -> Option<String> {
292 let parsed = reqwest::Url::parse(url.trim()).ok()?;
293 parsed.host_str().map(str::to_ascii_lowercase)
294 }
295
296 fn normalize_host_for_compare(host: &str) -> String {
297 let trimmed = host.trim().trim_end_matches('.').to_ascii_lowercase();
298 if let Some(rest) = trimmed.strip_prefix("*.") {
299 format!(".{rest}")
300 } else {
301 trimmed
302 }
303 }
304
305 fn display_list(values: &[String]) -> String {
306 if values.is_empty() {
307 "[]".to_string()
308 } else {
309 format!("[{}]", values.join(", "))
310 }
311 }
312
313 #[cfg(test)]
314 mod tests {
315 use super::*;
316 use std::env;
317 use std::time::{SystemTime, UNIX_EPOCH};
318
319 struct EnvGuard {
320 _home: crate::test_support::EnvVarGuard,
321 _userprofile: crate::test_support::EnvVarGuard,
322 _codewhale_config_path: crate::test_support::EnvVarGuard,
323 _deepseek_config_path: crate::test_support::EnvVarGuard,
324 _lock: crate::test_support::TestEnvLock,
325 }
326
327 impl EnvGuard {
328 fn new(home: &Path) -> Self {
329 let lock = crate::test_support::lock_test_env();
330 let config_path = home.join(".deepseek").join("config.toml");
331 Self {
332 _home: crate::test_support::EnvVarGuard::set("HOME", home),
333 _userprofile: crate::test_support::EnvVarGuard::set("USERPROFILE", home),
334 _codewhale_config_path: crate::test_support::EnvVarGuard::set(
335 "CODEWHALE_CONFIG_PATH",
336 &config_path,
337 ),
338 _deepseek_config_path: crate::test_support::EnvVarGuard::set(
339 "DEEPSEEK_CONFIG_PATH",
340 &config_path,
341 ),
342 _lock: lock,
343 }
344 }
345 }
346
347 fn temp_home(label: &str) -> PathBuf {
348 let nanos = SystemTime::now()
349 .duration_since(UNIX_EPOCH)
350 .unwrap()
351 .as_nanos();
352 let path = env::temp_dir().join(format!(
353 "deepseek-network-{label}-{}-{nanos}",
354 std::process::id()
355 ));
356 fs::create_dir_all(&path).unwrap();
357 path
358 }
359
360 #[test]
361 fn network_allow_persists_host_and_removes_exact_deny() {
362 let home = temp_home("allow");
363 let _guard = EnvGuard::new(&home);
364 let config_path = home.join(".deepseek").join("config.toml");
365 fs::create_dir_all(config_path.parent().unwrap()).unwrap();
366 fs::write(
367 &config_path,
368 "[network]\ndefault = \"prompt\"\ndeny = [\"github.com\"]\n",
369 )
370 .unwrap();
371
372 let result = network(Some("allow GitHub.COM"));
373
374 assert!(!result.is_error, "{:?}", result.message);
375 let body = fs::read_to_string(config_path).unwrap();
376 assert!(body.contains("allow = [\"github.com\"]"), "{body}");
377 assert!(body.contains("deny = []"), "{body}");
378 }
379
380 #[test]
381 fn network_allow_extracts_host_from_url() {
382 let home = temp_home("url");
383 let _guard = EnvGuard::new(&home);
384
385 let result = network(Some("allow https://github.com/obra/superpowers"));
386
387 assert!(!result.is_error, "{:?}", result.message);
388 let body = fs::read_to_string(home.join(".deepseek").join("config.toml")).unwrap();
389 assert!(body.contains("allow = [\"github.com\"]"), "{body}");
390 }
391
392 #[test]
393 fn network_default_rejects_unknown_value() {
394 let home = temp_home("default");
395 let _guard = EnvGuard::new(&home);
396
397 let result = network(Some("default maybe"));
398
399 assert!(result.is_error);
400 assert!(
401 result
402 .message
403 .as_deref()
404 .unwrap_or_default()
405 .contains("/network default <allow|deny|prompt>")
406 );
407 }
408
409 #[test]
410 fn network_config_parse_error_omits_secret_contents_and_keys() {
411 let home = temp_home("parse-redaction");
412 let path = home.join("config.toml");
413 let secret = "cw-secret-network-config-4507";
414 fs::write(
415 &path,
416 format!("[providers.xai]\napi_key = \"{secret}\" trailing-junk\n"),
417 )
418 .unwrap();
419
420 let error = load_config_doc(&path).expect_err("malformed config must fail");
421 let diagnostic = format!("{error:#}");
422 assert!(!diagnostic.contains(secret), "{diagnostic}");
423 assert!(!diagnostic.contains("api_key"), "{diagnostic}");
424 assert!(
425 diagnostic.contains("file contents were omitted"),
426 "{diagnostic}"
427 );
428 }
429
430 #[test]
431 fn handler_is_pure_and_argument_only() {
432 assert!(matches!(NetworkCmd::handler(), CommandHandler::Pure(_)));
433 assert_eq!(
434 NetworkCmd::info().description_key,
435 "cmd_network_description"
436 );
437 assert_eq!(NetworkCmd::info().aliases, &[] as &[&str]);
438 }
439
440 #[test]
441 fn host_normalization_handles_wildcard_trailing_dot_and_url_paths() {
442 // Wildcard prefix normalizes to a leading-dot suffix form.
443 assert_eq!(normalize_host_for_compare("*.example.com"), ".example.com");
444 // Trailing dot and case are normalized away.
445 assert_eq!(normalize_host_for_compare("Example.COM."), "example.com");
446 // A bare wildcard suffix compares equal to its explicit form.
447 assert_eq!(normalize_host_for_compare("*.github.com"), ".github.com");
448
449 // URL input extracts the host; a URL path is rejected.
450 assert_eq!(
451 host_from_url("https://API.Github.com/path").as_deref(),
452 Some("api.github.com")
453 );
454 assert_eq!(host_from_url("not a url"), None);
455 assert!(normalize_host_arg("https://github.com/path").is_ok());
456 assert!(normalize_host_arg("a/b").is_err(), "URL path rejected");
457 assert!(
458 normalize_host_arg("https://").is_err(),
459 "hostless URL rejected"
460 );
461 }
462
463 #[test]
464 fn exact_conflict_removal_is_case_and_wildcard_aware() {
465 let mut allow = vec!["github.com".to_string()];
466 let mut deny = vec!["GitHub.COM".to_string(), "*.example.com".to_string()];
467 // Production normalizes the host argument before update_host; the
468 // normalized form then removes the exact deny entry (case-insensitive).
469 let host = normalize_host_arg("GitHub.COM").expect("normalize");
470 assert_eq!(host, "github.com");
471 remove_host(&mut deny, &host);
472 assert_eq!(deny, vec!["*.example.com".to_string()]);
473 // Denying removes the exact allow entry.
474 remove_host(&mut allow, &host);
475 assert!(allow.is_empty());
476 // Adding an existing normalized host is a no-op (production passes
477 // the already-normalized host into add_host).
478 add_host(&mut allow, "github.com");
479 add_host(&mut allow, "github.com");
480 assert_eq!(allow, vec!["github.com".to_string()]);
481 }
482 }
483
483 lines RUST