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
`<RFC3339> network <host> <tool> <Allow|Deny|Prompt-Approved|Prompt-Denied>`.
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.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user