From a09a72572f713d5f8426a9a8a4ecb7ebc5413908 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Wed, 6 May 2026 02:46:02 -0500 Subject: [PATCH] fix(network): add allowlist slash command (#819) --- crates/tui/src/commands/config.rs | 2 +- crates/tui/src/commands/mod.rs | 8 + crates/tui/src/commands/network.rs | 418 +++++++++++++++++++++++++++++ crates/tui/src/localization.rs | 6 + 4 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 crates/tui/src/commands/network.rs diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 5ad252d4..4ce80cf1 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -236,7 +236,7 @@ pub fn persist_root_string_key(key: &str, value: &str) -> anyhow::Result anyhow::Result { +pub(super) fn config_toml_path() -> anyhow::Result { use anyhow::Context; if let Ok(env) = std::env::var("DEEPSEEK_CONFIG_PATH") { let trimmed = env.trim(); diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index b28be125..69224052 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -14,6 +14,7 @@ mod init; mod jobs; mod mcp; mod memory; +mod network; mod note; mod provider; mod queue; @@ -240,6 +241,12 @@ pub const COMMANDS: &[CommandInfo] = &[ usage: "/mcp [init|add stdio [args...]|add http |enable |disable |remove |validate|reload]", description_id: MessageId::CmdMcpDescription, }, + CommandInfo { + name: "network", + aliases: &[], + usage: "/network [list|allow |deny |remove |default ]", + description_id: MessageId::CmdNetworkDescription, + }, // Session commands CommandInfo { name: "save", @@ -493,6 +500,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { "task" | "tasks" => task::task(app, arg), "jobs" | "job" => jobs::jobs(app, arg), "mcp" => mcp::mcp(app, arg), + "network" => network::network(app, arg), // Session commands "save" => session::save(app, arg), diff --git a/crates/tui/src/commands/network.rs b/crates/tui/src/commands/network.rs new file mode 100644 index 00000000..1fef2581 --- /dev/null +++ b/crates/tui/src/commands/network.rs @@ -0,0 +1,418 @@ +//! Slash commands for the persistent network allow/deny list. + +use std::fs; +use std::path::Path; + +use anyhow::{Context, bail}; +use toml::Value; + +use super::CommandResult; +use crate::network_policy::host_from_url; +use crate::tui::app::App; + +pub fn network(_app: &mut App, arg: Option<&str>) -> CommandResult { + match network_inner(arg) { + Ok(message) => CommandResult::message(message), + Err(err) => CommandResult::error(err.to_string()), + } +} + +fn network_inner(arg: Option<&str>) -> anyhow::Result { + let raw = arg.map(str::trim).unwrap_or(""); + if raw.is_empty() || raw.eq_ignore_ascii_case("list") { + return list_policy(); + } + + let mut parts = raw.split_whitespace(); + let Some(command) = parts.next() else { + return list_policy(); + }; + let command = command.to_ascii_lowercase(); + + match command.as_str() { + "allow" | "deny" | "remove" | "forget" => { + let Some(host_arg) = parts.next() else { + bail!("Usage: /network {command} "); + }; + if parts.next().is_some() { + bail!("Usage: /network {command} "); + } + let host = normalize_host_arg(host_arg)?; + let edit = match command.as_str() { + "allow" => NetworkEdit::Allow, + "deny" => NetworkEdit::Deny, + _ => NetworkEdit::Remove, + }; + update_host(edit, &host) + } + "default" => { + let Some(value) = parts.next() else { + bail!("Usage: /network default "); + }; + if parts.next().is_some() { + bail!("Usage: /network default "); + } + update_default(value) + } + _ => bail!(usage()), + } +} + +fn usage() -> &'static str { + "Usage: /network [list|allow |deny |remove |default ]" +} + +#[derive(Clone, Copy)] +enum NetworkEdit { + Allow, + Deny, + Remove, +} + +fn list_policy() -> anyhow::Result { + let path = super::config::config_toml_path()?; + let doc = load_config_doc(&path)?; + let network = doc.get("network").and_then(Value::as_table); + let default = network + .and_then(|table| table.get("default")) + .and_then(Value::as_str) + .unwrap_or("prompt"); + let allow = network + .map(|table| string_array(table, "allow")) + .unwrap_or_default(); + let deny = network + .map(|table| string_array(table, "deny")) + .unwrap_or_default(); + + Ok(format!( + "Network policy ({})\n\ + default = {default}\n\ + allow = {}\n\ + deny = {}\n\n\ + Use `/network allow ` to allow a host, `/network deny ` to block it, or `/network remove ` to clear an entry.", + path.display(), + display_list(&allow), + display_list(&deny) + )) +} + +fn update_host(edit: NetworkEdit, host: &str) -> anyhow::Result { + let path = super::config::config_toml_path()?; + let mut doc = load_config_doc(&path)?; + let network = network_table_mut(&mut doc)?; + + match edit { + NetworkEdit::Allow => { + remove_host(network, "deny", host)?; + add_host(network, "allow", host)?; + } + NetworkEdit::Deny => { + remove_host(network, "allow", host)?; + add_host(network, "deny", host)?; + } + NetworkEdit::Remove => { + remove_host(network, "allow", host)?; + remove_host(network, "deny", host)?; + } + } + + save_config_doc(&path, &doc)?; + let action = match edit { + NetworkEdit::Allow => "allowed", + NetworkEdit::Deny => "denied", + NetworkEdit::Remove => "removed", + }; + Ok(format!( + "Network host {action}: {host}\nSaved to {}. Retry the command now.", + path.display() + )) +} + +fn update_default(value: &str) -> anyhow::Result { + let normalized = match value.trim().to_ascii_lowercase().as_str() { + "allow" => "allow", + "deny" | "block" => "deny", + "prompt" | "ask" => "prompt", + _ => bail!("Usage: /network default "), + }; + + let path = super::config::config_toml_path()?; + let mut doc = load_config_doc(&path)?; + let network = network_table_mut(&mut doc)?; + network.insert("default".to_string(), Value::String(normalized.to_string())); + save_config_doc(&path, &doc)?; + + Ok(format!( + "Network default set to {normalized}\nSaved to {}.", + path.display() + )) +} + +fn load_config_doc(path: &Path) -> anyhow::Result { + if !path.exists() { + return Ok(Value::Table(toml::value::Table::new())); + } + let raw = fs::read_to_string(path) + .with_context(|| format!("failed to read config at {}", path.display()))?; + toml::from_str(&raw).with_context(|| format!("failed to parse config at {}", path.display())) +} + +fn save_config_doc(path: &Path, doc: &Value) -> anyhow::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create config directory {}", parent.display()))?; + } + let body = toml::to_string_pretty(doc).context("failed to serialize config.toml")?; + fs::write(path, body).with_context(|| format!("failed to write config at {}", path.display())) +} + +fn network_table_mut(doc: &mut Value) -> anyhow::Result<&mut toml::value::Table> { + let root = doc + .as_table_mut() + .context("config.toml root must be a table")?; + let entry = root + .entry("network".to_string()) + .or_insert_with(|| Value::Table(toml::value::Table::new())); + let table = entry + .as_table_mut() + .context("`network` section in config.toml must be a table")?; + table + .entry("default".to_string()) + .or_insert_with(|| Value::String("prompt".to_string())); + table + .entry("audit".to_string()) + .or_insert_with(|| Value::Boolean(true)); + Ok(table) +} + +fn string_array(table: &toml::value::Table, key: &str) -> Vec { + table + .get(key) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(ToString::to_string) + .collect() +} + +fn string_array_mut<'a>( + table: &'a mut toml::value::Table, + key: &str, +) -> anyhow::Result<&'a mut Vec> { + let value = table + .entry(key.to_string()) + .or_insert_with(|| Value::Array(Vec::new())); + value + .as_array_mut() + .with_context(|| format!("`network.{key}` must be an array of strings")) +} + +fn add_host(table: &mut toml::value::Table, key: &str, host: &str) -> anyhow::Result<()> { + let list = string_array_mut(table, key)?; + if !list + .iter() + .filter_map(Value::as_str) + .any(|existing| normalize_host_for_compare(existing) == host) + { + list.push(Value::String(host.to_string())); + } + Ok(()) +} + +fn remove_host(table: &mut toml::value::Table, key: &str, host: &str) -> anyhow::Result<()> { + let list = string_array_mut(table, key)?; + list.retain(|value| { + value + .as_str() + .is_none_or(|existing| normalize_host_for_compare(existing) != host) + }); + Ok(()) +} + +fn normalize_host_arg(input: &str) -> anyhow::Result { + let trimmed = input.trim(); + let host = if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + host_from_url(trimmed).context("URL must include a host")? + } else { + if trimmed.contains("://") || trimmed.contains('/') { + bail!("Pass a host like `github.com`, not a URL path"); + } + trimmed.to_string() + }; + + let normalized = normalize_host_for_compare(&host); + if normalized.is_empty() { + bail!("host cannot be empty"); + } + Ok(normalized) +} + +fn normalize_host_for_compare(host: &str) -> String { + let trimmed = host.trim().trim_end_matches('.').to_ascii_lowercase(); + if let Some(rest) = trimmed.strip_prefix("*.") { + format!(".{rest}") + } else { + trimmed + } +} + +fn display_list(values: &[String]) -> String { + if values.is_empty() { + "[]".to_string() + } else { + format!("[{}]", values.join(", ")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::test_support::lock_test_env; + use crate::tui::app::{App, TuiOptions}; + use std::env; + use std::ffi::OsString; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + struct EnvGuard { + home: Option, + userprofile: Option, + deepseek_config_path: Option, + } + + impl EnvGuard { + fn new(home: &Path) -> Self { + let config_path = home.join(".deepseek").join("config.toml"); + let home_prev = env::var_os("HOME"); + let userprofile_prev = env::var_os("USERPROFILE"); + let deepseek_config_prev = env::var_os("DEEPSEEK_CONFIG_PATH"); + + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("HOME", home.as_os_str()); + env::set_var("USERPROFILE", home.as_os_str()); + env::set_var("DEEPSEEK_CONFIG_PATH", config_path.as_os_str()); + } + + Self { + home: home_prev, + userprofile: userprofile_prev, + deepseek_config_path: deepseek_config_prev, + } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + restore_env("HOME", self.home.take()); + restore_env("USERPROFILE", self.userprofile.take()); + restore_env("DEEPSEEK_CONFIG_PATH", self.deepseek_config_path.take()); + } + } + + fn restore_env(key: &str, value: Option) { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + if let Some(value) = value { + env::set_var(key, value); + } else { + env::remove_var(key); + } + } + } + + fn temp_home(label: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = env::temp_dir().join(format!( + "deepseek-network-{label}-{}-{nanos}", + std::process::id() + )); + fs::create_dir_all(&path).unwrap(); + path + } + + fn create_test_app(home: &Path) -> App { + let options = TuiOptions { + model: "test-model".to_string(), + workspace: home.to_path_buf(), + config_path: None, + config_profile: None, + allow_shell: false, + use_alt_screen: true, + use_mouse_capture: false, + use_bracketed_paste: true, + max_subagents: 1, + skills_dir: home.join("skills"), + memory_path: home.join("memory.md"), + notes_path: home.join("notes.txt"), + mcp_config_path: home.join("mcp.json"), + use_memory: false, + start_in_agent_mode: false, + skip_onboarding: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }; + App::new(options, &Config::default()) + } + + #[test] + fn network_allow_persists_host_and_removes_exact_deny() { + let _lock = lock_test_env(); + let home = temp_home("allow"); + let _guard = EnvGuard::new(&home); + let config_path = home.join(".deepseek").join("config.toml"); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write( + &config_path, + "[network]\ndefault = \"prompt\"\ndeny = [\"github.com\"]\n", + ) + .unwrap(); + + let mut app = create_test_app(&home); + let result = network(&mut app, Some("allow GitHub.COM")); + + assert!(!result.is_error, "{:?}", result.message); + let body = fs::read_to_string(config_path).unwrap(); + assert!(body.contains("allow = [\"github.com\"]"), "{body}"); + assert!(body.contains("deny = []"), "{body}"); + } + + #[test] + fn network_allow_extracts_host_from_url() { + let _lock = lock_test_env(); + let home = temp_home("url"); + let _guard = EnvGuard::new(&home); + + let mut app = create_test_app(&home); + let result = network(&mut app, Some("allow https://github.com/obra/superpowers")); + + assert!(!result.is_error, "{:?}", result.message); + let body = fs::read_to_string(home.join(".deepseek").join("config.toml")).unwrap(); + assert!(body.contains("allow = [\"github.com\"]"), "{body}"); + } + + #[test] + fn network_default_rejects_unknown_value() { + let _lock = lock_test_env(); + let home = temp_home("default"); + let _guard = EnvGuard::new(&home); + + let mut app = create_test_app(&home); + let result = network(&mut app, Some("default maybe")); + + assert!(result.is_error); + assert!( + result + .message + .as_deref() + .unwrap_or_default() + .contains("/network default ") + ); + } +} diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 588a53a5..f4a448c0 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -241,6 +241,7 @@ pub enum MessageId { CmdMemoryDescription, CmdModelDescription, CmdModelsDescription, + CmdNetworkDescription, CmdNoteDescription, CmdPlanDescription, CmdProviderDescription, @@ -428,6 +429,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::CmdMemoryDescription, MessageId::CmdModelDescription, MessageId::CmdModelsDescription, + MessageId::CmdNetworkDescription, MessageId::CmdNoteDescription, MessageId::CmdPlanDescription, MessageId::CmdProviderDescription, @@ -744,6 +746,7 @@ fn english(id: MessageId) -> &'static str { MessageId::CmdMemoryDescription => "Inspect or manage the persistent user-memory file", MessageId::CmdModelDescription => "Switch or view current model", MessageId::CmdModelsDescription => "List available models from API", + MessageId::CmdNetworkDescription => "Manage network allow and deny rules", MessageId::CmdNoteDescription => { "Append note to persistent notes file (.deepseek/notes.md)" } @@ -1026,6 +1029,7 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::CmdMemoryDescription => "永続ユーザーメモリファイルを確認・管理", MessageId::CmdModelDescription => "現在のモデルを切り替え・確認", MessageId::CmdModelsDescription => "API から利用可能なモデルを一覧表示", + MessageId::CmdNetworkDescription => "ネットワーク許可・拒否ルールを管理", MessageId::CmdNoteDescription => "永続ノートファイル(.deepseek/notes.md)に追記", MessageId::CmdPlanDescription => "Plan モードに切り替え、推奨される実装手順を確認", MessageId::CmdProviderDescription => { @@ -1288,6 +1292,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CmdMemoryDescription => "查看或管理持久用户记忆文件", MessageId::CmdModelDescription => "切换或查看当前模型", MessageId::CmdModelsDescription => "列出 API 中可用的模型", + MessageId::CmdNetworkDescription => "管理网络允许和拒绝规则", MessageId::CmdNoteDescription => "将笔记追加到持久笔记文件(.deepseek/notes.md)", MessageId::CmdPlanDescription => "切换到 Plan 模式并查看建议的实现步骤", MessageId::CmdProviderDescription => "切换或查看当前 LLM 后端(deepseek | nvidia-nim)", @@ -1540,6 +1545,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { } MessageId::CmdModelDescription => "Trocar ou exibir o modelo atual", MessageId::CmdModelsDescription => "Listar os modelos disponíveis pela API", + MessageId::CmdNetworkDescription => "Gerenciar regras de rede permitidas e bloqueadas", MessageId::CmdNoteDescription => { "Adicionar nota ao arquivo persistente (.deepseek/notes.md)" }