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:
@@ -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
@@ -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?;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user