diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 208f5a9d..6175a467 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -106,6 +106,10 @@ pub struct EngineConfig { /// `SubAgentRuntime::max_spawn_depth`. Override via /// `[runtime] max_spawn_depth = N` in `~/.deepseek/config.toml`. pub max_spawn_depth: u32, + /// Per-domain network policy decider (#135). Shared across the session so + /// session-scoped approvals (`/network allow `) persist for the + /// remainder of the run. + pub network_policy: Option, } impl Default for EngineConfig { @@ -126,6 +130,7 @@ impl Default for EngineConfig { todos: new_shared_todo_list(), plan_state: new_shared_plan_state(), max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH, + network_policy: None, } } } @@ -1984,7 +1989,7 @@ impl Engine { // `/trust add` / `/trust remove` mutations without an explicit cache // refresh hook. let trusted = crate::workspace_trust::WorkspaceTrust::load_for(&self.session.workspace); - let ctx = ToolContext::with_auto_approve( + let mut ctx = ToolContext::with_auto_approve( self.session.workspace.clone(), self.session.trust_mode, self.session.notes_path.clone(), @@ -1996,6 +2001,10 @@ impl Engine { .with_shell_manager(self.shell_manager.clone()) .with_trusted_external_paths(trusted.paths().to_vec()); + if let Some(decider) = self.config.network_policy.as_ref() { + ctx = ctx.with_network_policy(decider.clone()); + } + if mode == AppMode::Yolo { ctx.with_elevated_sandbox_policy(crate::sandbox::SandboxPolicy::WorkspaceWrite { writable_roots: vec![self.session.workspace.clone()], @@ -2012,8 +2021,11 @@ impl Engine { if let Some(pool) = self.mcp_pool.as_ref() { return Ok(Arc::clone(pool)); } - let pool = McpPool::from_config_path(&self.session.mcp_config_path) + let mut pool = McpPool::from_config_path(&self.session.mcp_config_path) .map_err(|e| ToolError::execution_failed(format!("Failed to load MCP config: {e}")))?; + if let Some(decider) = self.config.network_policy.as_ref() { + pool = pool.with_network_policy(decider.clone()); + } let pool = Arc::new(AsyncMutex::new(pool)); self.mcp_pool = Some(Arc::clone(&pool)); Ok(pool) diff --git a/crates/tui/src/mcp.rs b/crates/tui/src/mcp.rs index 32c507c2..23b61d3f 100644 --- a/crates/tui/src/mcp.rs +++ b/crates/tui/src/mcp.rs @@ -20,6 +20,8 @@ use serde::{Deserialize, Serialize}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; use tokio::process::{Child, ChildStdin, ChildStdout}; +use crate::network_policy::{Decision, NetworkPolicyDecider, host_from_url}; + // === Error diagnostics helpers (#71) === /// Bytes of a non-2xx response body to surface in connection errors. @@ -489,16 +491,42 @@ pub struct McpConnection { } impl McpConnection { - /// Connect to an MCP server and initialize it - pub async fn connect( + /// Connect to an MCP server and initialize it. + /// + /// `network_policy` (added in v0.7.0 for #135) is consulted for HTTP/SSE + /// transports only — STDIO transports are unaffected. Pass `None` to + /// match pre-v0.7.0 permissive behavior. + pub async fn connect_with_policy( name: String, config: McpServerConfig, global_timeouts: &McpTimeouts, + network_policy: Option<&NetworkPolicyDecider>, ) -> Result { let connect_timeout_secs = config.effective_connect_timeout(global_timeouts); let cancel_token = tokio_util::sync::CancellationToken::new(); let transport: Box = if let Some(url) = &config.url { + // Per-domain network policy gate (#135). Only the HTTP/SSE transport + // is gated; STDIO MCP servers run as local subprocesses and never + // touch the network from this code path. + if let Some(decider) = network_policy + && let Some(host) = host_from_url(url) + { + match decider.evaluate(&host, "mcp") { + Decision::Allow => {} + Decision::Deny => { + anyhow::bail!( + "MCP server '{name}' connection to '{host}' blocked by network policy" + ); + } + Decision::Prompt => { + anyhow::bail!( + "MCP server '{name}' connection to '{host}' requires approval; \ + re-run after `/network allow {host}` or set network.default = \"allow\" in config" + ); + } + } + } let client = reqwest::Client::builder() .timeout(Duration::from_secs(connect_timeout_secs)) .build()?; @@ -889,6 +917,7 @@ impl Drop for McpConnection { pub struct McpPool { connections: HashMap, config: McpConfig, + network_policy: Option, } impl McpPool { @@ -897,6 +926,7 @@ impl McpPool { Self { connections: HashMap::new(), config, + network_policy: None, } } @@ -913,6 +943,13 @@ impl McpPool { Ok(Self::new(config)) } + /// Attach a per-domain network policy (#135). When set, HTTP/SSE + /// transports are gated through it; STDIO transports are unaffected. + pub fn with_network_policy(mut self, policy: NetworkPolicyDecider) -> Self { + self.network_policy = Some(policy); + self + } + /// Get or create a connection to a server pub async fn get_or_connect(&mut self, server_name: &str) -> Result<&mut McpConnection> { let is_ready = self @@ -940,10 +977,11 @@ impl McpPool { anyhow::bail!("Failed to connect MCP server '{server_name}': server is disabled"); } - let connection = McpConnection::connect( + let connection = McpConnection::connect_with_policy( server_name.to_string(), server_config, &self.config.timeouts, + self.network_policy.as_ref(), ) .await?; diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index a8d05660..ed942108 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -1368,6 +1368,9 @@ impl RuntimeThreadManager { message_threshold: compaction_message_threshold_for_model(&thread.model), ..Default::default() }; + let network_policy = self.config.network.clone().map(|toml_cfg| { + crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime()) + }); let engine_cfg = EngineConfig { model: thread.model.clone(), workspace: thread.workspace.clone(), @@ -1386,6 +1389,7 @@ impl RuntimeThreadManager { todos: new_shared_todo_list(), plan_state: new_shared_plan_state(), max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH, + network_policy, }; let engine = spawn_engine(engine_cfg, &self.config); diff --git a/crates/tui/src/tools/fetch_url.rs b/crates/tui/src/tools/fetch_url.rs index e017ea43..a0f12e84 100644 --- a/crates/tui/src/tools/fetch_url.rs +++ b/crates/tui/src/tools/fetch_url.rs @@ -10,6 +10,7 @@ use super::spec::{ ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64, }; +use crate::network_policy::{Decision, host_from_url}; use async_trait::async_trait; use regex::Regex; use serde::Serialize; @@ -123,7 +124,7 @@ impl ToolSpec for FetchUrlTool { ApprovalRequirement::Auto } - async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + async fn execute(&self, input: Value, context: &ToolContext) -> Result { let url = input .get("url") .and_then(Value::as_str) @@ -141,6 +142,27 @@ impl ToolSpec for FetchUrlTool { )); } + // Per-domain network policy gate (#135). If no policy is attached + // (e.g. ad-hoc tests), behavior is permissive — match pre-v0.7.0. + if let Some(decider) = context.network_policy.as_ref() + && let Some(host) = host_from_url(&url) + { + match decider.evaluate(&host, "fetch_url") { + Decision::Allow => {} + Decision::Deny => { + return Err(ToolError::permission_denied(format!( + "network call to '{host}' blocked by network policy" + ))); + } + Decision::Prompt => { + return Err(ToolError::permission_denied(format!( + "network call to '{host}' requires approval; \ + re-run after `/network allow {host}` or set network.default = \"allow\" in config" + ))); + } + } + } + let format = Format::parse(input.get("format").and_then(Value::as_str))?; let max_bytes = optional_u64(&input, "max_bytes", DEFAULT_MAX_BYTES).min(HARD_MAX_BYTES); let timeout_ms = @@ -312,4 +334,23 @@ mod tests { let res = tool.execute(json!({}), &ctx()).await; assert!(res.is_err()); } + + #[tokio::test] + async fn network_policy_denies_blocked_host() { + use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider}; + let policy = NetworkPolicy { + default: Decision::Deny.into(), + allow: vec!["api.deepseek.com".to_string()], + deny: vec![], + audit: false, + }; + let decider = NetworkPolicyDecider::new(policy, None); + let ctx = ToolContext::new(PathBuf::from(".")).with_network_policy(decider); + let tool = FetchUrlTool; + let res = tool + .execute(json!({"url": "https://example.com/foo"}), &ctx) + .await; + let err = res.expect_err("blocked host should fail"); + assert!(format!("{err}").contains("blocked")); + } } diff --git a/crates/tui/src/tools/spec.rs b/crates/tui/src/tools/spec.rs index b1e2db6e..cb39055b 100644 --- a/crates/tui/src/tools/spec.rs +++ b/crates/tui/src/tools/spec.rs @@ -14,6 +14,7 @@ use serde_json::Value; use thiserror::Error; use crate::features::Features; +use crate::network_policy::NetworkPolicyDecider; use crate::tools::shell::{SharedShellManager, new_shared_shell_manager}; /// Capabilities that a tool may have or require. @@ -203,6 +204,10 @@ pub struct ToolContext { /// and refreshed when the user runs `/trust add `. Distinct from /// `trust_mode`, which is the all-or-nothing legacy switch (#29). pub trusted_external_paths: Vec, + /// Per-domain network policy (#135). When `None`, network tools fall back + /// to a permissive default that mirrors pre-v0.7.0 behavior so tests and + /// other contexts that don't construct a real policy keep working. + pub network_policy: Option, } impl ToolContext { @@ -225,6 +230,7 @@ impl ToolContext { features: Features::with_defaults(), state_namespace: "workspace".to_string(), trusted_external_paths: Vec::new(), + network_policy: None, } } @@ -250,6 +256,7 @@ impl ToolContext { features: Features::with_defaults(), state_namespace: "workspace".to_string(), trusted_external_paths: Vec::new(), + network_policy: None, } } @@ -275,9 +282,17 @@ impl ToolContext { features: Features::with_defaults(), state_namespace: "workspace".to_string(), trusted_external_paths: Vec::new(), + network_policy: None, } } + /// Attach a per-domain network policy to this context (#135). + #[must_use] + pub fn with_network_policy(mut self, policy: NetworkPolicyDecider) -> Self { + self.network_policy = Some(policy); + self + } + /// Set the user's trusted external paths (loaded from /// `~/.deepseek/workspace-trust.json`). See [`Self::resolve_path`] for /// how the list is consulted. diff --git a/crates/tui/src/tools/web_search.rs b/crates/tui/src/tools/web_search.rs index fb3c0bf3..6a0eea68 100644 --- a/crates/tui/src/tools/web_search.rs +++ b/crates/tui/src/tools/web_search.rs @@ -6,6 +6,7 @@ use super::spec::{ ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64, }; +use crate::network_policy::{Decision, NetworkPolicyDecider}; use async_trait::async_trait; use base64::{Engine as _, engine::general_purpose}; use regex::Regex; @@ -14,6 +15,27 @@ use serde_json::{Value, json}; use std::sync::OnceLock; use std::time::Duration; +const DUCKDUCKGO_HOST: &str = "html.duckduckgo.com"; +const BING_HOST: &str = "www.bing.com"; + +/// Returns `Ok(())` if the policy allows the call, or a `ToolError` otherwise. +/// Falls through silently when no policy is attached (back-compat). +fn check_policy(decider: Option<&NetworkPolicyDecider>, host: &str) -> Result<(), ToolError> { + let Some(decider) = decider else { + return Ok(()); + }; + match decider.evaluate(host, "web_search") { + Decision::Allow => Ok(()), + Decision::Deny => Err(ToolError::permission_denied(format!( + "web search to '{host}' blocked by network policy" + ))), + Decision::Prompt => Err(ToolError::permission_denied(format!( + "web search to '{host}' requires approval; \ + re-run after `/network allow {host}` or set network.default = \"allow\" in config" + ))), + } +} + // Cached regex patterns for HTML parsing static TITLE_RE: OnceLock = OnceLock::new(); static SNIPPET_RE: OnceLock = OnceLock::new(); @@ -140,7 +162,7 @@ impl ToolSpec for WebSearchTool { ApprovalRequirement::Auto } - async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + async fn execute(&self, input: Value, context: &ToolContext) -> Result { let query = extract_search_query(&input)?; if query.is_empty() { return Err(ToolError::invalid_input("Query cannot be empty")); @@ -150,6 +172,13 @@ impl ToolSpec for WebSearchTool { let max_results = max_results.clamp(1, MAX_RESULTS); let timeout_ms = optional_u64(&input, "timeout_ms", DEFAULT_TIMEOUT_MS).min(60_000); + // Per-domain network policy gate (#135). The "host" for web search is + // the upstream search engine domain — DuckDuckGo first, Bing on + // fallback. We gate DuckDuckGo here; Bing is gated separately inside + // `run_bing_search` so a deny on one engine doesn't block the other. + let decider = context.network_policy.as_ref(); + check_policy(decider, DUCKDUCKGO_HOST)?; + let client = reqwest::Client::builder() .timeout(Duration::from_millis(timeout_ms)) .user_agent(USER_AGENT) @@ -189,6 +218,9 @@ impl ToolSpec for WebSearchTool { let mut message_suffix = None; if results.is_empty() { let duckduckgo_blocked = is_duckduckgo_challenge(&body); + // Bing is a separate host — gate it independently so a deny on + // DuckDuckGo doesn't silently let Bing through (and vice versa). + check_policy(decider, BING_HOST)?; match run_bing_search(&client, &query, max_results).await { Ok(fallback_results) if !fallback_results.is_empty() => { results = fallback_results; diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index b1fbeb05..a93655ac 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -333,6 +333,9 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { todos: app.todos.clone(), plan_state: app.plan_state.clone(), max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH, + network_policy: config.network.clone().map(|toml_cfg| { + crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime()) + }), } }