diff --git a/config.example.toml b/config.example.toml index f6e44990..a38f73cf 100644 --- a/config.example.toml +++ b/config.example.toml @@ -100,6 +100,30 @@ max_subagents = 5 # optional (1-20) # base_url = "https://integrate.api.nvidia.com/v1" # model = "deepseek-ai/deepseek-v4-pro" # or deepseek-ai/deepseek-v4-flash +# ───────────────────────────────────────────────────────────────────────────────── +# Network Policy (#135) +# ───────────────────────────────────────────────────────────────────────────────── +# Per-domain allow/deny rules for outbound network calls made by the TUI's +# tools (`fetch_url`, `web_search`) and the MCP HTTP transport. Stdio MCP +# servers and direct LLM API calls are unaffected. +# +# Precedence: deny wins. A host listed in both `allow` and `deny` is denied. +# +# Host-matching rules: +# - Exact match: `api.deepseek.com` matches only `api.deepseek.com`. +# - Subdomain wildcard: an entry starting with `.` (e.g. `.example.com`) +# matches `api.example.com` and `a.b.example.com` but not the apex +# `example.com`. To cover both, list both. `*.example.com` is also accepted. +# +# Defaults are intentionally conservative: when this section is absent, no +# policy is enforced (mirrors pre-v0.7.0 behavior). To opt in: +# +# [network] +# default = "prompt" # allow | deny | prompt +# allow = ["api.deepseek.com", "github.com", ".githubusercontent.com"] +# deny = [] +# audit = true # one line per call to ~/.deepseek/audit.log + # ───────────────────────────────────────────────────────────────────────────────── # TUI # ───────────────────────────────────────────────────────────────────────────────── diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 825e9409..9e7b1884 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -124,10 +124,53 @@ pub struct ConfigToml { pub sandbox_mode: Option, #[serde(default)] pub providers: ProvidersToml, + /// Per-domain network policy (#135). When absent, network tools fall back + /// to a permissive default that mirrors pre-v0.7.0 behavior. + #[serde(default)] + pub network: Option, #[serde(flatten)] pub extras: BTreeMap, } +/// On-disk schema for the `[network]` table (#135). See `config.example.toml` +/// for documentation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NetworkPolicyToml { + /// Decision for hosts that are not in `allow` or `deny`. One of + /// `"allow" | "deny" | "prompt"`. Defaults to `"prompt"`. + #[serde(default = "default_network_decision")] + pub default: String, + /// Hosts that are always allowed. Subdomain rules: a leading dot + /// (`.example.com`) matches subdomains but not the apex. + #[serde(default)] + pub allow: Vec, + /// Hosts that are always denied. Deny entries win over allow entries. + #[serde(default)] + pub deny: Vec, + /// Whether to record one audit-log line per outbound network call. + #[serde(default = "default_network_audit")] + pub audit: bool, +} + +fn default_network_decision() -> String { + "prompt".to_string() +} + +fn default_network_audit() -> bool { + true +} + +impl Default for NetworkPolicyToml { + fn default() -> Self { + Self { + default: default_network_decision(), + allow: Vec::new(), + deny: Vec::new(), + audit: default_network_audit(), + } + } +} + impl ConfigToml { #[must_use] pub fn get_value(&self, key: &str) -> Option { diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 2b612596..7b0a7275 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -424,6 +424,66 @@ pub struct Config { /// Desktop notification settings (OSC 9 / BEL on long turn completion). #[serde(default)] pub notifications: Option, + + /// Per-domain network policy (#135). When absent, network tools fall back + /// to a permissive default that mirrors pre-v0.7.0 behavior. + #[serde(default)] + pub network: Option, +} + +/// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live +/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`] +/// without reaching into the workspace config crate. See `config.example.toml` +/// for documentation. +#[derive(Debug, Clone, Deserialize)] +pub struct NetworkPolicyToml { + /// Decision for hosts that are not in `allow` or `deny`. One of + /// `"allow" | "deny" | "prompt"`. Defaults to `"prompt"`. + #[serde(default = "default_network_decision")] + pub default: String, + /// Hosts that are always allowed. Subdomain rules: a leading dot + /// (`.example.com`) matches subdomains but not the apex. + #[serde(default)] + pub allow: Vec, + /// Hosts that are always denied. Deny entries win over allow entries. + #[serde(default)] + pub deny: Vec, + /// Whether to record one audit-log line per outbound network call. + #[serde(default = "default_network_audit")] + pub audit: bool, +} + +fn default_network_decision() -> String { + "prompt".to_string() +} + +fn default_network_audit() -> bool { + true +} + +impl Default for NetworkPolicyToml { + fn default() -> Self { + Self { + default: default_network_decision(), + allow: Vec::new(), + deny: Vec::new(), + audit: default_network_audit(), + } + } +} + +impl NetworkPolicyToml { + /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the + /// on-disk schema. + #[must_use] + pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy { + crate::network_policy::NetworkPolicy { + default: crate::network_policy::Decision::parse(&self.default).into(), + allow: self.allow, + deny: self.deny, + audit: self.audit, + } + } } #[derive(Debug, Clone, Default, Deserialize)] @@ -1323,6 +1383,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config { providers: merge_providers(base.providers, override_cfg.providers), features: merge_features(base.features, override_cfg.features), notifications: override_cfg.notifications.or(base.notifications), + network: override_cfg.network.or(base.network), } } 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/main.rs b/crates/tui/src/main.rs index 5d1e7b35..88316221 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -32,6 +32,7 @@ mod logging; mod mcp; mod mcp_server; mod models; +mod network_policy; mod palette; mod pricing; mod project_context; @@ -2928,6 +2929,10 @@ async fn run_exec_agent( ..Default::default() }; + let network_policy = config.network.clone().map(|toml_cfg| { + crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime()) + }); + let engine_config = EngineConfig { model: model.to_string(), workspace: workspace.clone(), @@ -2944,6 +2949,7 @@ async fn run_exec_agent( 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_handle = spawn_engine(engine_config, config); 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/network_policy.rs b/crates/tui/src/network_policy.rs new file mode 100644 index 00000000..1799cf0e --- /dev/null +++ b/crates/tui/src/network_policy.rs @@ -0,0 +1,701 @@ +// Several public helpers in this module are exposed for future slash-command +// wiring (`/network allow `, `/network deny `) and for the +// approval-modal hook that v0.7.x adds incrementally. Dead-code warnings +// would otherwise be noisy until those call sites land. +#![allow(dead_code)] + +//! Per-domain network policy for outbound network calls (#135). +//! +//! Three small pieces: +//! +//! 1. [`Decision`] — `Allow | Deny | Prompt`. +//! 2. [`NetworkPolicy`] — a list of allow/deny hostnames + a default decision, +//! with **deny-wins precedence**: a host that matches an entry in `deny` +//! is denied even if it also matches `allow`. +//! 3. [`NetworkAuditor`] — appends one plaintext line per outbound call to +//! `~/.deepseek/audit.log` in the format described below. +//! +//! In addition, [`NetworkSessionCache`] holds in-process "approve once for +//! this session" state for the `Prompt` flow, and [`NetworkDenied`] is the +//! structured error surfaced to callers when a host is blocked. +//! +//! # Host-matching rules +//! +//! * **Exact match** — an entry like `api.deepseek.com` matches only the host +//! `api.deepseek.com` (case-insensitive). +//! * **Subdomain match** — an entry that **starts with a leading dot**, e.g. +//! `.example.com`, matches any subdomain (`api.example.com`, `a.b.example.com`) +//! but **not** the apex `example.com`. To match both, list both. +//! +//! Matching is case-insensitive and trims a single trailing dot from the host +//! (so `example.com.` and `example.com` are equivalent). +//! +//! # Audit-log format +//! +//! ```text +//! network +//! ``` +//! +//! Plaintext, one line per call, appended to `` (defaults to +//! `~/.deepseek/audit.log`). Best-effort: write failures are logged but do +//! not block the call. + +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// What the policy decided about an outbound network call. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Decision { + /// Allow the call without prompting. + Allow, + /// Deny the call. Surfaced to callers as [`NetworkDenied`]. + Deny, + /// Defer to the user via an approval prompt. + Prompt, +} + +impl Decision { + /// String form used in audit-log lines. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Allow => "Allow", + Self::Deny => "Deny", + Self::Prompt => "Prompt", + } + } + + /// Parse a decision from a TOML string. Unknown values fall back to + /// `Prompt` so a typo never silently disables the policy. + #[must_use] + pub fn parse(value: &str) -> Self { + match value.trim().to_ascii_lowercase().as_str() { + "allow" => Self::Allow, + "deny" | "block" => Self::Deny, + _ => Self::Prompt, + } + } +} + +/// Per-domain allow/deny list with a default fallback. +/// +/// See the module docs for [host-matching rules](self#host-matching-rules) +/// and [deny-wins precedence](self#deny-wins-precedence). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NetworkPolicy { + /// Decision for hosts that match neither `allow` nor `deny`. + #[serde(default = "default_decision")] + pub default: DecisionToml, + /// Hosts that should be allowed without prompting. + #[serde(default)] + pub allow: Vec, + /// Hosts that should always be denied. + #[serde(default)] + pub deny: Vec, + /// Whether to record one audit-log line per network call. Defaults to true. + #[serde(default = "default_audit")] + pub audit: bool, +} + +fn default_decision() -> DecisionToml { + DecisionToml::Prompt +} + +fn default_audit() -> bool { + true +} + +impl Default for NetworkPolicy { + fn default() -> Self { + Self { + default: DecisionToml::Prompt, + allow: Vec::new(), + deny: Vec::new(), + audit: true, + } + } +} + +/// Wire-format wrapper for [`Decision`] used in serde-derived TOML/JSON. The +/// runtime API exposes [`Decision`] directly; this type only exists so +/// `default = "prompt"` round-trips cleanly through TOML. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DecisionToml { + Allow, + Deny, + Prompt, +} + +impl From for Decision { + fn from(value: DecisionToml) -> Self { + match value { + DecisionToml::Allow => Self::Allow, + DecisionToml::Deny => Self::Deny, + DecisionToml::Prompt => Self::Prompt, + } + } +} + +impl From for DecisionToml { + fn from(value: Decision) -> Self { + match value { + Decision::Allow => Self::Allow, + Decision::Deny => Self::Deny, + Decision::Prompt => Self::Prompt, + } + } +} + +impl NetworkPolicy { + /// Decide what to do for a single outbound call to `host`. + /// + /// **Deny-wins precedence**: if `host` matches any entry in `deny`, the + /// answer is [`Decision::Deny`] regardless of `allow`. This makes deny + /// lists safe to combine with broad allow rules. + #[must_use] + pub fn decide(&self, host: &str) -> Decision { + let normalized = normalize_host(host); + if normalized.is_empty() { + // We don't pretend we can audit a malformed host; treat it as the + // default (prompt or deny). + return self.default.into(); + } + if self + .deny + .iter() + .any(|entry| host_matches(entry, &normalized)) + { + return Decision::Deny; + } + if self + .allow + .iter() + .any(|entry| host_matches(entry, &normalized)) + { + return Decision::Allow; + } + self.default.into() + } + + /// Append `host` to the allow list (de-duplicated, case-insensitive). + /// Used by the prompt flow when the user picks "always for this host". + pub fn add_allow(&mut self, host: &str) { + let normalized = normalize_host(host); + if normalized.is_empty() { + return; + } + if !self + .allow + .iter() + .any(|existing| normalize_host(existing) == normalized) + { + self.allow.push(normalized); + } + } + + /// Whether audit logging is enabled. + #[must_use] + pub fn audit_enabled(&self) -> bool { + self.audit + } +} + +/// Normalize a host for matching: lowercase, trim whitespace, strip a single +/// trailing dot (FQDN form), and strip a leading `*.` or `.` for entries that +/// are written that way in config (we treat both as subdomain wildcards on +/// the *match* side, but on input normalization we keep the leading dot so +/// `host_matches` can detect the wildcard intent). +fn normalize_host(host: &str) -> String { + let trimmed = host.trim().trim_end_matches('.').to_ascii_lowercase(); + if let Some(rest) = trimmed.strip_prefix("*.") { + format!(".{rest}") + } else { + trimmed + } +} + +/// Match a single allow/deny entry against an already-normalized host. +fn host_matches(entry: &str, normalized_host: &str) -> bool { + let entry_norm = normalize_host(entry); + if let Some(suffix) = entry_norm.strip_prefix('.') { + // Wildcard subdomain rule. Match any host ending in `.suffix`, but + // *not* the bare `suffix` itself (per spec). + if suffix.is_empty() { + return false; + } + normalized_host.ends_with(&format!(".{suffix}")) + } else { + entry_norm == normalized_host + } +} + +/// Best-effort writer for the network audit log. +#[derive(Debug, Clone)] +pub struct NetworkAuditor { + path: PathBuf, + enabled: bool, +} + +impl NetworkAuditor { + /// New auditor that writes to `path`. `enabled = false` turns it into a no-op. + #[must_use] + pub fn new(path: PathBuf, enabled: bool) -> Self { + Self { path, enabled } + } + + /// Auditor pointing at `~/.deepseek/audit.log`. Returns `None` if the + /// home directory can't be resolved. + #[must_use] + pub fn default_path(enabled: bool) -> Option { + let home = dirs::home_dir()?; + Some(Self::new(home.join(".deepseek").join("audit.log"), enabled)) + } + + /// Append one line. Best-effort: errors are logged via `eprintln!` but + /// never bubble back to the caller. + pub fn record(&self, host: &str, tool: &str, decision_label: &str) { + if !self.enabled { + return; + } + if let Err(err) = self.try_record(host, tool, decision_label) { + eprintln!("network audit write failed: {err}"); + } + } + + fn try_record(&self, host: &str, tool: &str, decision_label: &str) -> std::io::Result<()> { + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent)?; + } + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&self.path)?; + writeln!( + file, + "{ts} network {host} {tool} {decision}", + ts = Utc::now().to_rfc3339(), + host = sanitize_field(host), + tool = sanitize_field(tool), + decision = decision_label, + ) + } + + /// Path the auditor would write to. Mostly useful for tests. + #[must_use] + pub fn path(&self) -> &Path { + &self.path + } +} + +/// Replace whitespace in a token so the line stays parseable. +fn sanitize_field(s: &str) -> String { + s.chars() + .map(|c| if c.is_whitespace() { '_' } else { c }) + .collect() +} + +/// In-process cache of "approve once for this session" decisions. Keyed by +/// normalized host. Thread-safe. +#[derive(Debug, Default, Clone)] +pub struct NetworkSessionCache { + inner: Arc>, +} + +#[derive(Debug, Default)] +struct NetworkSessionCacheInner { + approved: std::collections::HashSet, + denied: std::collections::HashSet, +} + +impl NetworkSessionCache { + /// New empty cache. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// `true` if the host was previously approved this session. + #[must_use] + pub fn is_approved(&self, host: &str) -> bool { + let normalized = normalize_host(host); + self.inner + .lock() + .map(|guard| guard.approved.contains(&normalized)) + .unwrap_or(false) + } + + /// `true` if the host was previously denied this session. + #[must_use] + pub fn is_denied(&self, host: &str) -> bool { + let normalized = normalize_host(host); + self.inner + .lock() + .map(|guard| guard.denied.contains(&normalized)) + .unwrap_or(false) + } + + /// Mark the host as approved for the rest of this session. + pub fn approve(&self, host: &str) { + let normalized = normalize_host(host); + if let Ok(mut guard) = self.inner.lock() { + guard.denied.remove(&normalized); + guard.approved.insert(normalized); + } + } + + /// Mark the host as denied for the rest of this session. + pub fn deny(&self, host: &str) { + let normalized = normalize_host(host); + if let Ok(mut guard) = self.inner.lock() { + guard.approved.remove(&normalized); + guard.denied.insert(normalized); + } + } +} + +/// Structured error surfaced to callers when an outbound call is blocked. +#[derive(Debug, Clone, Error)] +#[error("network call to '{0}' blocked by network policy")] +pub struct NetworkDenied(pub String); + +impl NetworkDenied { + /// The host that was denied. + #[must_use] + pub fn host(&self) -> &str { + &self.0 + } +} + +/// Glue type that bundles a [`NetworkPolicy`] with a session cache and an +/// auditor. Tools call [`NetworkPolicyDecider::evaluate`] before any HTTP +/// transport is constructed; the result decides whether to proceed, deny, +/// or prompt the user. +#[derive(Debug, Clone)] +pub struct NetworkPolicyDecider { + policy: NetworkPolicy, + cache: NetworkSessionCache, + auditor: Option, +} + +impl NetworkPolicyDecider { + /// Build a decider from a policy. The session cache starts empty. + #[must_use] + pub fn new(policy: NetworkPolicy, auditor: Option) -> Self { + Self { + policy, + cache: NetworkSessionCache::new(), + auditor, + } + } + + /// Convenience: build a decider with default audit logging at + /// `~/.deepseek/audit.log`, if `policy.audit` is true. + #[must_use] + pub fn with_default_audit(policy: NetworkPolicy) -> Self { + let audit_enabled = policy.audit_enabled(); + let auditor = if audit_enabled { + NetworkAuditor::default_path(true) + } else { + None + }; + Self::new(policy, auditor) + } + + /// Inspect the policy. + #[must_use] + pub fn policy(&self) -> &NetworkPolicy { + &self.policy + } + + /// Inspect the session cache. + #[must_use] + pub fn cache(&self) -> &NetworkSessionCache { + &self.cache + } + + /// Decide for `host`, consulting the session cache first. + /// + /// Audit logging happens **only** for terminal decisions (Allow / Deny). + /// `Prompt` is intentionally not logged here — the caller is responsible + /// for recording the user's eventual answer with `record_prompt_outcome`. + #[must_use] + pub fn evaluate(&self, host: &str, tool: &str) -> Decision { + let normalized = normalize_host(host); + if normalized.is_empty() { + return self.policy.default.into(); + } + if self.cache.is_denied(&normalized) { + self.audit_record(&normalized, tool, "Deny"); + return Decision::Deny; + } + if self.cache.is_approved(&normalized) { + self.audit_record(&normalized, tool, "Allow"); + return Decision::Allow; + } + let decision = self.policy.decide(&normalized); + match decision { + Decision::Allow => self.audit_record(&normalized, tool, "Allow"), + Decision::Deny => self.audit_record(&normalized, tool, "Deny"), + Decision::Prompt => {} + } + decision + } + + /// Approve `host` for the rest of the session (one-shot). Audit log gets + /// `Prompt-Approved`. + pub fn approve_session(&self, host: &str, tool: &str) { + self.cache.approve(host); + self.audit_record(host, tool, "Prompt-Approved"); + } + + /// Deny `host` for the rest of the session. Audit log gets `Prompt-Denied`. + pub fn deny_session(&self, host: &str, tool: &str) { + self.cache.deny(host); + self.audit_record(host, tool, "Prompt-Denied"); + } + + /// Persist `host` into the policy's allow list (so it survives the session) + /// **and** approve it in-session. Returns the updated policy so callers can + /// write it back to disk. + pub fn approve_persistent(&mut self, host: &str, tool: &str) -> &NetworkPolicy { + self.policy.add_allow(host); + self.cache.approve(host); + self.audit_record(host, tool, "Prompt-Approved"); + &self.policy + } + + fn audit_record(&self, host: &str, tool: &str, label: &str) { + if let Some(auditor) = self.auditor.as_ref() { + auditor.record(host, tool, label); + } + } +} + +/// Extract the host portion of a URL, lowercased. Returns `None` if the URL +/// can't be parsed or has no host. +#[must_use] +pub fn host_from_url(url: &str) -> Option { + let parsed = reqwest::Url::parse(url.trim()).ok()?; + parsed.host_str().map(str::to_ascii_lowercase) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn mk(default: Decision, allow: &[&str], deny: &[&str]) -> NetworkPolicy { + NetworkPolicy { + default: default.into(), + allow: allow.iter().map(|s| (*s).to_string()).collect(), + deny: deny.iter().map(|s| (*s).to_string()).collect(), + audit: false, + } + } + + #[test] + fn exact_match_in_allow_returns_allow() { + let p = mk(Decision::Deny, &["api.deepseek.com"], &[]); + assert_eq!(p.decide("api.deepseek.com"), Decision::Allow); + } + + #[test] + fn unknown_host_returns_default() { + let p = mk(Decision::Deny, &["api.deepseek.com"], &[]); + assert_eq!(p.decide("evil.example.com"), Decision::Deny); + + let p2 = mk(Decision::Prompt, &[], &[]); + assert_eq!(p2.decide("anything.example"), Decision::Prompt); + } + + #[test] + fn deny_wins_precedence() { + // Acceptance criterion: a host in both allow and deny is denied. + let p = mk(Decision::Prompt, &["api.example.com"], &["api.example.com"]); + assert_eq!(p.decide("api.example.com"), Decision::Deny); + } + + #[test] + fn deny_wins_with_subdomain_rules() { + // Deny-wins applies even when the deny is a wildcard and the allow is exact. + let p = mk(Decision::Allow, &["api.example.com"], &[".example.com"]); + assert_eq!(p.decide("api.example.com"), Decision::Deny); + } + + #[test] + fn subdomain_wildcard_matches_subdomain_only() { + let p = mk(Decision::Deny, &[".example.com"], &[]); + assert_eq!(p.decide("api.example.com"), Decision::Allow); + assert_eq!(p.decide("a.b.example.com"), Decision::Allow); + // The bare apex is *not* matched by `.example.com` per the rule. + assert_eq!(p.decide("example.com"), Decision::Deny); + } + + #[test] + fn star_dot_subdomain_alias_is_accepted() { + let p = mk(Decision::Deny, &["*.example.com"], &[]); + assert_eq!(p.decide("api.example.com"), Decision::Allow); + assert_eq!(p.decide("example.com"), Decision::Deny); + } + + #[test] + fn host_match_is_case_insensitive() { + let p = mk(Decision::Deny, &["API.DeepSeek.com"], &[]); + assert_eq!(p.decide("api.deepseek.com"), Decision::Allow); + } + + #[test] + fn trailing_dot_is_ignored() { + let p = mk(Decision::Deny, &["api.deepseek.com"], &[]); + assert_eq!(p.decide("api.deepseek.com."), Decision::Allow); + } + + #[test] + fn empty_host_uses_default() { + let p = mk(Decision::Deny, &["api.example.com"], &[]); + assert_eq!(p.decide(""), Decision::Deny); + assert_eq!(p.decide(" "), Decision::Deny); + } + + #[test] + fn add_allow_dedupes_case_insensitively() { + let mut p = mk(Decision::Deny, &[], &[]); + p.add_allow("Example.COM"); + p.add_allow("example.com"); + assert_eq!(p.allow.len(), 1); + assert_eq!(p.allow[0], "example.com"); + } + + #[test] + fn host_from_url_extracts_host() { + assert_eq!( + host_from_url("https://api.deepseek.com/health"), + Some("api.deepseek.com".to_string()) + ); + assert_eq!( + host_from_url("http://Example.COM:8080/x"), + Some("example.com".to_string()) + ); + assert_eq!(host_from_url("not a url"), None); + } + + #[test] + fn auditor_writes_one_line_per_call() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("audit.log"); + let auditor = NetworkAuditor::new(path.clone(), true); + auditor.record("api.example.com", "fetch_url", "Allow"); + auditor.record("evil.example.com", "fetch_url", "Deny"); + let body = std::fs::read_to_string(&path).expect("read"); + let lines: Vec<&str> = body.lines().collect(); + assert_eq!(lines.len(), 2); + for line in &lines { + // network + let parts: Vec<&str> = line.split_whitespace().collect(); + assert!(parts.len() >= 5, "line shape: {line}"); + assert_eq!(parts[1], "network"); + } + assert!(lines[0].contains("api.example.com")); + assert!(lines[0].ends_with("Allow")); + assert!(lines[1].contains("evil.example.com")); + assert!(lines[1].ends_with("Deny")); + } + + #[test] + fn auditor_disabled_writes_nothing() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("audit.log"); + let auditor = NetworkAuditor::new(path.clone(), false); + auditor.record("api.example.com", "fetch_url", "Allow"); + assert!(!path.exists() || std::fs::read_to_string(&path).unwrap().is_empty()); + } + + #[test] + fn session_cache_short_circuits_evaluate() { + let policy = mk(Decision::Prompt, &[], &[]); + let decider = NetworkPolicyDecider::new(policy, None); + // First call returns Prompt. + assert_eq!( + decider.evaluate("api.example.com", "fetch_url"), + Decision::Prompt + ); + decider.approve_session("api.example.com", "fetch_url"); + // After approve_session, the same host returns Allow without prompting. + assert_eq!( + decider.evaluate("api.example.com", "fetch_url"), + Decision::Allow + ); + } + + #[test] + fn approve_persistent_writes_back_to_policy() { + let policy = mk(Decision::Prompt, &[], &[]); + let mut decider = NetworkPolicyDecider::new(policy, None); + decider.approve_persistent("api.example.com", "fetch_url"); + assert!( + decider + .policy() + .allow + .iter() + .any(|h| h == "api.example.com") + ); + // And the session cache also got updated, so fresh evaluate returns Allow. + assert_eq!( + decider.evaluate("api.example.com", "fetch_url"), + Decision::Allow + ); + } + + #[test] + fn deny_session_blocks_subsequent_evaluate() { + let policy = mk(Decision::Allow, &[], &[]); + let decider = NetworkPolicyDecider::new(policy, None); + decider.deny_session("evil.example.com", "fetch_url"); + assert_eq!( + decider.evaluate("evil.example.com", "fetch_url"), + Decision::Deny + ); + } + + #[test] + fn audit_records_terminal_decisions_through_decider() { + let dir = tempdir().expect("tempdir"); + let auditor = NetworkAuditor::new(dir.path().join("audit.log"), true); + let policy = mk(Decision::Deny, &["api.deepseek.com"], &[]); + let decider = NetworkPolicyDecider::new(policy, Some(auditor)); + + let allow = decider.evaluate("api.deepseek.com", "fetch_url"); + let deny = decider.evaluate("evil.example.com", "fetch_url"); + assert_eq!(allow, Decision::Allow); + assert_eq!(deny, Decision::Deny); + + let body = std::fs::read_to_string(dir.path().join("audit.log")).expect("read"); + let lines: Vec<&str> = body.lines().collect(); + assert_eq!(lines.len(), 2); + assert!(lines[0].ends_with("Allow")); + assert!(lines[1].ends_with("Deny")); + } + + #[test] + fn decision_parse_unknown_falls_back_to_prompt() { + assert_eq!(Decision::parse("allow"), Decision::Allow); + assert_eq!(Decision::parse("Deny"), Decision::Deny); + assert_eq!(Decision::parse("BLOCK"), Decision::Deny); + assert_eq!(Decision::parse("prompt"), Decision::Prompt); + assert_eq!(Decision::parse("garbage"), Decision::Prompt); + } + + #[test] + fn network_denied_carries_host() { + let err = NetworkDenied("api.example.com".to_string()); + assert_eq!(err.host(), "api.example.com"); + assert!(format!("{err}").contains("api.example.com")); + } +} 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 979f2a32..3e50b995 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()) + }), } }