From 45727a09f7ff15d85eff09d9efdf11599c939205 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 28 Apr 2026 00:02:27 -0500 Subject: [PATCH] feat(network): #135 add per-domain network policy module Introduces `network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider, NetworkAuditor, NetworkSessionCache, NetworkDenied}` for gating outbound network calls. Deny-wins precedence: a host listed in both `allow` and `deny` is denied. Subdomain wildcard via leading-dot entries (`.example.com` matches `api.example.com` but not the apex). Audit log writes one plaintext line per terminal decision to `~/.deepseek/audit.log` in the format ` network `. Approve-once-for-session caching is implemented in `NetworkSessionCache`; `approve_persistent` mutates the policy's allow list so callers can write back to config later. 19 unit tests cover deny-wins precedence, subdomain matching, audit logging, session-cache short-circuit, and `NetworkDenied` shape. --- crates/tui/src/main.rs | 6 + crates/tui/src/network_policy.rs | 701 +++++++++++++++++++++++++++++++ 2 files changed, 707 insertions(+) create mode 100644 crates/tui/src/network_policy.rs diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 08b8278f..04701ed0 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; @@ -2890,6 +2891,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(), @@ -2906,6 +2911,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/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")); + } +}