#![allow(dead_code)]
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;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
Allow,
Deny,
Prompt,
}
impl Decision {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Allow => "Allow",
Self::Deny => "Deny",
Self::Prompt => "Prompt",
}
}
#[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,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkPolicy {
#[serde(default = "default_decision")]
pub default: DecisionToml,
#[serde(default)]
pub allow: Vec<String>,
#[serde(default)]
pub deny: Vec<String>,
#[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,
}
}
}
#[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 {
#[must_use]
pub fn decide(&self, host: &str) -> Decision {
let normalized = normalize_host(host);
if normalized.is_empty() {
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()
}
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);
}
}
#[must_use]
pub fn audit_enabled(&self) -> bool {
self.audit
}
}
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
}
}
fn host_matches(entry: &str, normalized_host: &str) -> bool {
let entry_norm = normalize_host(entry);
if let Some(suffix) = entry_norm.strip_prefix('.') {
if suffix.is_empty() {
return false;
}
normalized_host.ends_with(&format!(".{suffix}"))
} else {
entry_norm == normalized_host
}
}
#[derive(Debug, Clone)]
pub struct NetworkAuditor {
path: PathBuf,
enabled: bool,
}
impl NetworkAuditor {
#[must_use]
pub fn new(path: PathBuf, enabled: bool) -> Self {
Self { path, enabled }
}
#[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))
}
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,
)
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
}
fn sanitize_field(s: &str) -> String {
s.chars()
.map(|c| if c.is_whitespace() { '_' } else { c })
.collect()
}
#[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 {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[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)
}
#[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)
}
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);
}
}
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);
}
}
}
#[derive(Debug, Clone, Error)]
#[error("network call to '{0}' blocked by network policy")]
pub struct NetworkDenied(pub String);
impl NetworkDenied {
#[must_use]
pub fn host(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct NetworkPolicyDecider {
policy: NetworkPolicy,
cache: NetworkSessionCache,
auditor: Option<NetworkAuditor>,
}
impl NetworkPolicyDecider {
#[must_use]
pub fn new(policy: NetworkPolicy, auditor: Option<NetworkAuditor>) -> Self {
Self {
policy,
cache: NetworkSessionCache::new(),
auditor,
}
}
#[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)
}
#[must_use]
pub fn policy(&self) -> &NetworkPolicy {
&self.policy
}
#[must_use]
pub fn cache(&self) -> &NetworkSessionCache {
&self.cache
}
#[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
}
pub fn approve_session(&self, host: &str, tool: &str) {
self.cache.approve(host);
self.audit_record(host, tool, "Prompt-Approved");
}
pub fn deny_session(&self, host: &str, tool: &str) {
self.cache.deny(host);
self.audit_record(host, tool, "Prompt-Denied");
}
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);
}
}
}
#[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() {
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() {
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);
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 {
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);
assert_eq!(
decider.evaluate("api.example.com", "fetch_url"),
Decision::Prompt
);
decider.approve_session("api.example.com", "fetch_url");
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")
);
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"));
}
}