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