feat(network): #135 gate fetch_url, web_search, MCP HTTP via policy

Threads the optional `NetworkPolicyDecider` from `EngineConfig` through to
`ToolContext.network_policy` and `McpPool::with_network_policy`. Each gate
point follows the same pattern: extract the host, call `decider.evaluate`,
then `Allow` proceeds, `Deny` returns a structured permission-denied error,
and `Prompt` falls through to the same denial with a hint pointing to
`/network allow <host>` (full modal flow lands in a follow-up).

* `fetch_url` — gates on the parsed URL host.
* `web_search` — gates DuckDuckGo (`html.duckduckgo.com`) and the Bing
  fallback (`www.bing.com`) independently so a deny on one engine doesn't
  silently let the other through.
* MCP — only the HTTP/SSE transport is gated; STDIO MCP servers are
  unaffected. `McpConnection::connect_with_policy` replaces the old
  `connect` (no external callers existed).

The session cache short-circuits `evaluate` once a host is approved, so
the existing `approve_session` hook is enough to wire the prompt-once
flow when the approval modal lands.

`NetworkPolicyDecider::with_default_audit` materializes the auditor at
`~/.deepseek/audit.log` when the config has `audit = true`.

Includes one tool-level test asserting `fetch_url` denies a blocked host
through the policy gate.
This commit is contained in:
Hunter Bown
2026-04-28 00:02:56 -05:00
parent abbb86cdd2
commit f82f162e7f
7 changed files with 152 additions and 7 deletions
+14 -2
View File
@@ -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 <host>`) persist for the
/// remainder of the run.
pub network_policy: Option<crate::network_policy::NetworkPolicyDecider>,
}
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)
+41 -3
View File
@@ -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<Self> {
let connect_timeout_secs = config.effective_connect_timeout(global_timeouts);
let cancel_token = tokio_util::sync::CancellationToken::new();
let transport: Box<dyn McpTransport> = 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<String, McpConnection>,
config: McpConfig,
network_policy: Option<NetworkPolicyDecider>,
}
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?;
+4
View File
@@ -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);
+42 -1
View File
@@ -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<ToolResult, ToolError> {
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
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"));
}
}
+15
View File
@@ -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 <path>`. Distinct from
/// `trust_mode`, which is the all-or-nothing legacy switch (#29).
pub trusted_external_paths: Vec<PathBuf>,
/// 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<NetworkPolicyDecider>,
}
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.
+33 -1
View File
@@ -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<Regex> = OnceLock::new();
static SNIPPET_RE: OnceLock<Regex> = OnceLock::new();
@@ -140,7 +162,7 @@ impl ToolSpec for WebSearchTool {
ApprovalRequirement::Auto
}
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
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;
+3
View File
@@ -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())
}),
}
}