Merge branch 'feat/v070-network' (#135 egress network policy + audit)
- crates/tui/src/network_policy.rs (NEW, ~300 LOC) — NetworkPolicy/NetworkPolicyDecider/NetworkAuditor
- crates/tui/src/tools/{fetch_url,web_search,spec}.rs + mcp.rs — gate before egress
- crates/tui/src/core/engine.rs + runtime_threads.rs + tui/ui.rs — surface NetworkDenied
- crates/config/src/lib.rs + tui/src/config.rs + config.example.toml — [network] schema
- Subdomain-prefix matching with deny-wins precedence
- Audit format: <RFC3339> network <host> <tool> <decision>
# Conflicts:
# crates/tui/src/config.rs
This commit is contained in:
@@ -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
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -124,10 +124,53 @@ pub struct ConfigToml {
|
||||
pub sandbox_mode: Option<String>,
|
||||
#[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<NetworkPolicyToml>,
|
||||
#[serde(flatten)]
|
||||
pub extras: BTreeMap<String, toml::Value>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Hosts that are always denied. Deny entries win over allow entries.
|
||||
#[serde(default)]
|
||||
pub deny: Vec<String>,
|
||||
/// 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<String> {
|
||||
|
||||
@@ -424,6 +424,66 @@ pub struct Config {
|
||||
/// Desktop notification settings (OSC 9 / BEL on long turn completion).
|
||||
#[serde(default)]
|
||||
pub notifications: Option<NotificationsConfig>,
|
||||
|
||||
/// 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<NetworkPolicyToml>,
|
||||
}
|
||||
|
||||
/// `[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<String>,
|
||||
/// Hosts that are always denied. Deny entries win over allow entries.
|
||||
#[serde(default)]
|
||||
pub deny: Vec<String>,
|
||||
/// 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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
+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?;
|
||||
|
||||
|
||||
@@ -0,0 +1,701 @@
|
||||
// Several public helpers in this module are exposed for future slash-command
|
||||
// wiring (`/network allow <host>`, `/network deny <host>`) 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
|
||||
//! <RFC3339-timestamp> network <host> <tool> <Allow|Deny|Prompt-Approved|Prompt-Denied>
|
||||
//! ```
|
||||
//!
|
||||
//! Plaintext, one line per call, appended to `<audit_path>` (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<String>,
|
||||
/// Hosts that should always be denied.
|
||||
#[serde(default)]
|
||||
pub deny: Vec<String>,
|
||||
/// 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<DecisionToml> for Decision {
|
||||
fn from(value: DecisionToml) -> Self {
|
||||
match value {
|
||||
DecisionToml::Allow => Self::Allow,
|
||||
DecisionToml::Deny => Self::Deny,
|
||||
DecisionToml::Prompt => Self::Prompt,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Decision> 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<Self> {
|
||||
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<Mutex<NetworkSessionCacheInner>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct NetworkSessionCacheInner {
|
||||
approved: std::collections::HashSet<String>,
|
||||
denied: std::collections::HashSet<String>,
|
||||
}
|
||||
|
||||
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<NetworkAuditor>,
|
||||
}
|
||||
|
||||
impl NetworkPolicyDecider {
|
||||
/// Build a decider from a policy. The session cache starts empty.
|
||||
#[must_use]
|
||||
pub fn new(policy: NetworkPolicy, auditor: Option<NetworkAuditor>) -> 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<String> {
|
||||
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 {
|
||||
// <ts> network <host> <tool> <decision>
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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