返回 CodeWhale
network_policy.rs
根目录 / crates / tui / src / network_policy.rs
1 // Several public helpers in this module are exposed for future slash-command
2 // wiring (`/network allow <host>`, `/network deny <host>`) and for the
3 // approval-modal hook that v0.7.x adds incrementally. Dead-code warnings
4 // would otherwise be noisy until those call sites land.
5 #![allow(dead_code)]
6 // Audit-write failure must route through `tracing::*`, not raw stderr —
7 // see `runtime_log` for the scroll-demon rationale.
8 #![deny(clippy::print_stdout)]
9 #![deny(clippy::print_stderr)]
10
11 //! Per-domain network policy for outbound network calls (#135).
12 //!
13 //! Three small pieces:
14 //!
15 //! 1. [`Decision`] — `Allow | Deny | Prompt`.
16 //! 2. [`NetworkPolicy`] — a list of allow/deny hostnames + a default decision,
17 //! with **deny-wins precedence**: a host that matches an entry in `deny`
18 //! is denied even if it also matches `allow`.
19 //! 3. [`NetworkAuditor`] — appends one plaintext line per outbound call to
20 //! `~/.codewhale/audit.log` in the format described below.
21 //!
22 //! In addition, [`NetworkSessionCache`] holds in-process "approve once for
23 //! this session" state for the `Prompt` flow, and [`NetworkDenied`] is the
24 //! structured error surfaced to callers when a host is blocked.
25 //!
26 //! # Host-matching rules
27 //!
28 //! * **Exact match** — an entry like `api.deepseek.com` matches only the host
29 //! `api.deepseek.com` (case-insensitive).
30 //! * **Subdomain match** — an entry that **starts with a leading dot**, e.g.
31 //! `.example.com`, matches any subdomain (`api.example.com`, `a.b.example.com`)
32 //! but **not** the apex `example.com`. To match both, list both.
33 //!
34 //! Matching is case-insensitive and trims a single trailing dot from the host
35 //! (so `example.com.` and `example.com` are equivalent).
36 //!
37 //! # Audit-log format
38 //!
39 //! ```text
40 //! <RFC3339-timestamp> network <host> <tool> <Allow|Deny|Prompt-Approved|Prompt-Denied|TrustedProxyFakeIp-Allow>
41 //! ```
42 //!
43 //! Plaintext, one line per call, appended to `<audit_path>` (defaults to
44 //! `~/.codewhale/audit.log`). Best-effort: write failures are logged but do
45 //! not block the call.
46
47 use std::fs::{self, OpenOptions};
48 use std::io::Write;
49 use std::net::{IpAddr, Ipv4Addr};
50 use std::path::{Path, PathBuf};
51 use std::sync::{Arc, Mutex};
52
53 use chrono::Utc;
54 use serde::{Deserialize, Serialize};
55 use thiserror::Error;
56
57 /// What the policy decided about an outbound network call.
58 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
59 pub enum Decision {
60 /// Allow the call without prompting.
61 Allow,
62 /// Deny the call. Surfaced to callers as [`NetworkDenied`].
63 Deny,
64 /// Defer to the user via an approval prompt.
65 Prompt,
66 }
67
68 impl Decision {
69 /// String form used in audit-log lines.
70 #[must_use]
71 pub fn as_str(self) -> &'static str {
72 match self {
73 Self::Allow => "Allow",
74 Self::Deny => "Deny",
75 Self::Prompt => "Prompt",
76 }
77 }
78
79 /// Parse a decision from a TOML string. Unknown values fall back to
80 /// `Prompt` so a typo never silently disables the policy.
81 #[must_use]
82 pub fn parse(value: &str) -> Self {
83 match value.trim().to_ascii_lowercase().as_str() {
84 "allow" => Self::Allow,
85 "deny" | "block" => Self::Deny,
86 _ => Self::Prompt,
87 }
88 }
89 }
90
91 /// Per-domain allow/deny list with a default fallback.
92 ///
93 /// See the module docs for [host-matching rules](self#host-matching-rules)
94 /// and [deny-wins precedence](self#deny-wins-precedence).
95 #[derive(Debug, Clone, Serialize, Deserialize)]
96 pub struct NetworkPolicy {
97 /// Decision for hosts that match neither `allow` nor `deny`.
98 #[serde(default = "default_decision")]
99 pub default: DecisionToml,
100 /// Hosts that should be allowed without prompting.
101 #[serde(default)]
102 pub allow: Vec<String>,
103 /// Hosts that should always be denied.
104 #[serde(default)]
105 pub deny: Vec<String>,
106 /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an
107 /// explicitly trusted proxy setup. This does not affect literal IP URLs.
108 #[serde(default)]
109 pub proxy: Vec<String>,
110 /// Explicit fake-IP placeholder CIDRs used by the trusted proxy setup.
111 /// Only subnets contained by the IETF benchmark range `198.18.0.0/15`
112 /// are eligible; loopback, RFC1918, link-local, metadata, and ULA ranges
113 /// can never be trusted through this setting.
114 #[serde(default)]
115 pub proxy_fake_ip_cidrs: Vec<String>,
116 /// Whether to record one audit-log line per network call. Defaults to true.
117 #[serde(default = "default_audit")]
118 pub audit: bool,
119 }
120
121 fn default_decision() -> DecisionToml {
122 DecisionToml::Prompt
123 }
124
125 fn default_audit() -> bool {
126 true
127 }
128
129 impl Default for NetworkPolicy {
130 fn default() -> Self {
131 Self {
132 default: DecisionToml::Prompt,
133 allow: Vec::new(),
134 deny: Vec::new(),
135 proxy: Vec::new(),
136 proxy_fake_ip_cidrs: Vec::new(),
137 audit: true,
138 }
139 }
140 }
141
142 /// Wire-format wrapper for [`Decision`] used in serde-derived TOML/JSON. The
143 /// runtime API exposes [`Decision`] directly; this type only exists so
144 /// `default = "prompt"` round-trips cleanly through TOML.
145 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
146 #[serde(rename_all = "lowercase")]
147 pub enum DecisionToml {
148 Allow,
149 Deny,
150 Prompt,
151 }
152
153 impl From<DecisionToml> for Decision {
154 fn from(value: DecisionToml) -> Self {
155 match value {
156 DecisionToml::Allow => Self::Allow,
157 DecisionToml::Deny => Self::Deny,
158 DecisionToml::Prompt => Self::Prompt,
159 }
160 }
161 }
162
163 impl From<Decision> for DecisionToml {
164 fn from(value: Decision) -> Self {
165 match value {
166 Decision::Allow => Self::Allow,
167 Decision::Deny => Self::Deny,
168 Decision::Prompt => Self::Prompt,
169 }
170 }
171 }
172
173 impl NetworkPolicy {
174 /// Decide what to do for a single outbound call to `host`.
175 ///
176 /// **Deny-wins precedence**: if `host` matches any entry in `deny`, the
177 /// answer is [`Decision::Deny`] regardless of `allow`. This makes deny
178 /// lists safe to combine with broad allow rules.
179 #[must_use]
180 pub fn decide(&self, host: &str) -> Decision {
181 let normalized = normalize_host(host);
182 if normalized.is_empty() {
183 // We don't pretend we can audit a malformed host; treat it as the
184 // default (prompt or deny).
185 return self.default.into();
186 }
187 if self
188 .deny
189 .iter()
190 .any(|entry| host_matches(entry, &normalized))
191 {
192 return Decision::Deny;
193 }
194 if self
195 .allow
196 .iter()
197 .any(|entry| host_matches(entry, &normalized))
198 {
199 return Decision::Allow;
200 }
201 self.default.into()
202 }
203
204 /// Append `host` to the allow list (de-duplicated, case-insensitive).
205 /// Used by the prompt flow when the user picks "always for this host".
206 pub fn add_allow(&mut self, host: &str) {
207 let normalized = normalize_host(host);
208 if normalized.is_empty() {
209 return;
210 }
211 if !self
212 .allow
213 .iter()
214 .any(|existing| normalize_host(existing) == normalized)
215 {
216 self.allow.push(normalized);
217 }
218 }
219
220 /// Whether audit logging is enabled.
221 #[must_use]
222 pub fn audit_enabled(&self) -> bool {
223 self.audit
224 }
225
226 /// Whether `host` is explicitly trusted to resolve through a local
227 /// fake-IP proxy. Deny entries still win over this list.
228 #[must_use]
229 pub fn trusts_proxy_fakeip_host(&self, host: &str) -> bool {
230 let normalized = normalize_host(host);
231 if normalized.is_empty() {
232 return false;
233 }
234 if self
235 .deny
236 .iter()
237 .any(|entry| host_matches(entry, &normalized))
238 {
239 return false;
240 }
241 self.proxy
242 .iter()
243 .any(|entry| host_matches(entry, &normalized))
244 }
245 }
246
247 /// Normalize a host for matching: lowercase, trim whitespace, strip a single
248 /// trailing dot (FQDN form), and strip a leading `*.` or `.` for entries that
249 /// are written that way in config (we treat both as subdomain wildcards on
250 /// the *match* side, but on input normalization we keep the leading dot so
251 /// `host_matches` can detect the wildcard intent).
252 fn normalize_host(host: &str) -> String {
253 let trimmed = host.trim().trim_end_matches('.').to_ascii_lowercase();
254 if let Some(rest) = trimmed.strip_prefix("*.") {
255 format!(".{rest}")
256 } else {
257 trimmed
258 }
259 }
260
261 /// Match a single allow/deny entry against an already-normalized host.
262 fn host_matches(entry: &str, normalized_host: &str) -> bool {
263 let entry_norm = normalize_host(entry);
264 if let Some(suffix) = entry_norm.strip_prefix('.') {
265 // Wildcard subdomain rule. Match any host ending in `.suffix`, but
266 // *not* the bare `suffix` itself (per spec).
267 if suffix.is_empty() {
268 return false;
269 }
270 normalized_host.ends_with(&format!(".{suffix}"))
271 } else {
272 entry_norm == normalized_host
273 }
274 }
275
276 /// Parse an IPv4 CIDR string such as `"198.18.0.0/15"` into `(base, prefix)`.
277 /// Returns `None` for malformed input or a prefix length above 32.
278 fn parse_ipv4_cidr(cidr: &str) -> Option<(Ipv4Addr, u8)> {
279 let (addr, prefix) = cidr.split_once('/')?;
280 let base: Ipv4Addr = addr.trim().parse().ok()?;
281 let prefix: u8 = prefix.trim().parse().ok()?;
282 if prefix > 32 {
283 return None;
284 }
285 Some((base, prefix))
286 }
287
288 /// Parse only fake-IP networks that are fully contained by the IETF benchmark
289 /// block. This keeps an overly broad or mistaken config entry from weakening
290 /// the unconditional loopback/private/link-local/metadata protections.
291 fn parse_trusted_fakeip_cidr(cidr: &str) -> Option<(Ipv4Addr, u8)> {
292 let (base, prefix) = parse_ipv4_cidr(cidr)?;
293 let octets = base.octets();
294 (prefix >= 15 && octets[0] == 198 && matches!(octets[1], 18..=19)).then_some((base, prefix))
295 }
296
297 /// Whether `ip` is contained in the `base/prefix` IPv4 CIDR block.
298 fn ipv4_in_cidr(ip: Ipv4Addr, base: Ipv4Addr, prefix: u8) -> bool {
299 if prefix == 0 {
300 return true;
301 }
302 let mask: u32 = u32::MAX << (32 - prefix);
303 (u32::from(ip) & mask) == (u32::from(base) & mask)
304 }
305
306 /// Best-effort writer for the network audit log.
307 #[derive(Debug, Clone)]
308 pub struct NetworkAuditor {
309 path: PathBuf,
310 enabled: bool,
311 }
312
313 impl NetworkAuditor {
314 /// New auditor that writes to `path`. `enabled = false` turns it into a no-op.
315 #[must_use]
316 pub fn new(path: PathBuf, enabled: bool) -> Self {
317 Self { path, enabled }
318 }
319
320 /// Auditor pointing at `~/.codewhale/audit.log`. Returns `None` if the
321 /// home directory can't be resolved.
322 #[must_use]
323 pub fn default_path(enabled: bool) -> Option<Self> {
324 let home = crate::config::effective_home_dir()?;
325 Some(Self::new(
326 home.join(".codewhale").join("audit.log"),
327 enabled,
328 ))
329 }
330
331 /// Append one line. Best-effort: errors are logged via `eprintln!` but
332 /// never bubble back to the caller.
333 pub fn record(&self, host: &str, tool: &str, decision_label: &str) {
334 if !self.enabled {
335 return;
336 }
337 if let Err(err) = self.try_record(host, tool, decision_label) {
338 // Routed through tracing so it lands in
339 // `~/.codewhale/logs/tui-YYYY-MM-DD.log` rather than the
340 // alt-screen — see `runtime_log` for the scroll-demon
341 // rationale.
342 tracing::warn!(target: "network_policy", ?err, host, tool, "network audit write failed");
343 }
344 }
345
346 fn try_record(&self, host: &str, tool: &str, decision_label: &str) -> std::io::Result<()> {
347 if let Some(parent) = self.path.parent() {
348 fs::create_dir_all(parent)?;
349 }
350 let mut file = OpenOptions::new()
351 .create(true)
352 .append(true)
353 .open(&self.path)?;
354 writeln!(
355 file,
356 "{ts} network {host} {tool} {decision}",
357 ts = Utc::now().to_rfc3339(),
358 host = sanitize_field(host),
359 tool = sanitize_field(tool),
360 decision = decision_label,
361 )
362 }
363
364 /// Path the auditor would write to. Mostly useful for tests.
365 #[must_use]
366 pub fn path(&self) -> &Path {
367 &self.path
368 }
369 }
370
371 /// Replace whitespace in a token so the line stays parseable.
372 fn sanitize_field(s: &str) -> String {
373 s.chars()
374 .map(|c| if c.is_whitespace() { '_' } else { c })
375 .collect()
376 }
377
378 /// In-process cache of "approve once for this session" decisions. Keyed by
379 /// normalized host. Thread-safe.
380 #[derive(Debug, Default, Clone)]
381 pub struct NetworkSessionCache {
382 inner: Arc<Mutex<NetworkSessionCacheInner>>,
383 }
384
385 #[derive(Debug, Default)]
386 struct NetworkSessionCacheInner {
387 approved: std::collections::HashSet<String>,
388 denied: std::collections::HashSet<String>,
389 }
390
391 impl NetworkSessionCache {
392 /// New empty cache.
393 #[must_use]
394 pub fn new() -> Self {
395 Self::default()
396 }
397
398 /// `true` if the host was previously approved this session.
399 #[must_use]
400 pub fn is_approved(&self, host: &str) -> bool {
401 let normalized = normalize_host(host);
402 self.inner
403 .lock()
404 .map(|guard| guard.approved.contains(&normalized))
405 .unwrap_or(false)
406 }
407
408 /// `true` if the host was previously denied this session.
409 #[must_use]
410 pub fn is_denied(&self, host: &str) -> bool {
411 let normalized = normalize_host(host);
412 self.inner
413 .lock()
414 .map(|guard| guard.denied.contains(&normalized))
415 .unwrap_or(false)
416 }
417
418 /// Mark the host as approved for the rest of this session.
419 pub fn approve(&self, host: &str) {
420 let normalized = normalize_host(host);
421 if let Ok(mut guard) = self.inner.lock() {
422 guard.denied.remove(&normalized);
423 guard.approved.insert(normalized);
424 }
425 }
426
427 /// Mark the host as denied for the rest of this session.
428 pub fn deny(&self, host: &str) {
429 let normalized = normalize_host(host);
430 if let Ok(mut guard) = self.inner.lock() {
431 guard.approved.remove(&normalized);
432 guard.denied.insert(normalized);
433 }
434 }
435 }
436
437 /// Structured error surfaced to callers when an outbound call is blocked.
438 #[derive(Debug, Clone, Error)]
439 #[error("network call to '{0}' blocked by network policy")]
440 pub struct NetworkDenied(pub String);
441
442 impl NetworkDenied {
443 /// The host that was denied.
444 #[must_use]
445 pub fn host(&self) -> &str {
446 &self.0
447 }
448 }
449
450 /// Glue type that bundles a [`NetworkPolicy`] with a session cache and an
451 /// auditor. Tools call [`NetworkPolicyDecider::evaluate`] before any HTTP
452 /// transport is constructed; the result decides whether to proceed, deny,
453 /// or prompt the user.
454 #[derive(Debug, Clone)]
455 pub struct NetworkPolicyDecider {
456 policy: NetworkPolicy,
457 cache: NetworkSessionCache,
458 auditor: Option<NetworkAuditor>,
459 /// IPv4 CIDR ranges that are treated as benign fake-IP placeholders (e.g.
460 /// a transparent-proxy / TUN setup running in `fake-ip` mode, where DNS
461 /// resolves every hostname into a reserved range like `198.18.0.0/15`).
462 /// A resolved IP inside one of these ranges bypasses the restricted-IP SSRF
463 /// block; real private/loopback/link-local/metadata IPs are unaffected.
464 trusted_fakeip_cidrs: Vec<(Ipv4Addr, u8)>,
465 }
466
467 impl NetworkPolicyDecider {
468 /// Build a decider from a policy. The session cache starts empty.
469 #[must_use]
470 pub fn new(policy: NetworkPolicy, auditor: Option<NetworkAuditor>) -> Self {
471 let trusted_fakeip_cidrs = policy
472 .proxy_fake_ip_cidrs
473 .iter()
474 .filter_map(|cidr| parse_trusted_fakeip_cidr(cidr))
475 .collect();
476 Self {
477 policy,
478 cache: NetworkSessionCache::new(),
479 auditor,
480 trusted_fakeip_cidrs,
481 }
482 }
483
484 /// Register IPv4 CIDR ranges to treat as benign fake-IP placeholders.
485 /// Invalid CIDR strings are skipped. See [`Self::is_trusted_fakeip_addr`].
486 #[must_use]
487 pub fn with_trusted_fakeip_cidrs(mut self, cidrs: &[&str]) -> Self {
488 for cidr in cidrs {
489 if let Some(parsed) = parse_trusted_fakeip_cidr(cidr) {
490 self.trusted_fakeip_cidrs.push(parsed);
491 }
492 }
493 self
494 }
495
496 /// Whether `ip` falls inside a configured fake-IP placeholder range.
497 ///
498 /// In `fake-ip` proxy/TUN setups the local resolver maps every hostname to
499 /// a reserved range (commonly `198.18.0.0/15`), so the DNS-resolution SSRF
500 /// check would otherwise reject every request. This narrowly trusts only
501 /// those placeholder addresses — real private/loopback/link-local/cloud-
502 /// metadata IPs are *not* matched and stay blocked.
503 #[must_use]
504 pub fn is_trusted_fakeip_addr(&self, ip: &IpAddr) -> bool {
505 match ip {
506 IpAddr::V4(v4) => self
507 .trusted_fakeip_cidrs
508 .iter()
509 .any(|(base, prefix)| ipv4_in_cidr(*v4, *base, *prefix)),
510 // fake-ip placeholders are IPv4-only in practice.
511 IpAddr::V6(_) => false,
512 }
513 }
514
515 /// Convenience: build a decider with default audit logging at
516 /// `~/.codewhale/audit.log`, if `policy.audit` is true.
517 #[must_use]
518 pub fn with_default_audit(policy: NetworkPolicy) -> Self {
519 let audit_enabled = policy.audit_enabled();
520 let auditor = if audit_enabled {
521 NetworkAuditor::default_path(true)
522 } else {
523 None
524 };
525 Self::new(policy, auditor)
526 }
527
528 /// Inspect the policy.
529 #[must_use]
530 pub fn policy(&self) -> &NetworkPolicy {
531 &self.policy
532 }
533
534 /// Inspect the session cache.
535 #[must_use]
536 pub fn cache(&self) -> &NetworkSessionCache {
537 &self.cache
538 }
539
540 /// Decide for `host`, consulting the session cache first.
541 ///
542 /// Audit logging happens **only** for terminal decisions (Allow / Deny).
543 /// `Prompt` is intentionally not logged here — the caller is responsible
544 /// for recording the user's eventual answer with `record_prompt_outcome`.
545 #[must_use]
546 pub fn evaluate(&self, host: &str, tool: &str) -> Decision {
547 let normalized = normalize_host(host);
548 if normalized.is_empty() {
549 return self.policy.default.into();
550 }
551 if self.cache.is_denied(&normalized) {
552 self.audit_record(&normalized, tool, "Deny");
553 return Decision::Deny;
554 }
555 if self.cache.is_approved(&normalized) {
556 self.audit_record(&normalized, tool, "Allow");
557 return Decision::Allow;
558 }
559 let decision = self.policy.decide(&normalized);
560 match decision {
561 Decision::Allow => self.audit_record(&normalized, tool, "Allow"),
562 Decision::Deny => self.audit_record(&normalized, tool, "Deny"),
563 Decision::Prompt => {}
564 }
565 decision
566 }
567
568 /// Approve `host` for the rest of the session (one-shot). Audit log gets
569 /// `Prompt-Approved`.
570 pub fn approve_session(&self, host: &str, tool: &str) {
571 self.cache.approve(host);
572 self.audit_record(host, tool, "Prompt-Approved");
573 }
574
575 /// Deny `host` for the rest of the session. Audit log gets `Prompt-Denied`.
576 pub fn deny_session(&self, host: &str, tool: &str) {
577 self.cache.deny(host);
578 self.audit_record(host, tool, "Prompt-Denied");
579 }
580
581 /// Persist `host` into the policy's allow list (so it survives the session)
582 /// **and** approve it in-session. Returns the updated policy so callers can
583 /// write it back to disk.
584 pub fn approve_persistent(&mut self, host: &str, tool: &str) -> &NetworkPolicy {
585 self.policy.add_allow(host);
586 self.cache.approve(host);
587 self.audit_record(host, tool, "Prompt-Approved");
588 &self.policy
589 }
590
591 /// Whether this host is explicitly configured for trusted proxy fake-IP
592 /// DNS handling.
593 #[must_use]
594 pub fn trusts_proxy_fakeip_host(&self, host: &str) -> bool {
595 self.policy.trusts_proxy_fakeip_host(host)
596 }
597
598 /// Record that a restricted DNS result was allowed because the host is in
599 /// the trusted proxy fake-IP list.
600 pub fn record_trusted_proxy_fakeip_allow(&self, host: &str, tool: &str) {
601 self.audit_record(host, tool, "TrustedProxyFakeIp-Allow");
602 }
603
604 fn audit_record(&self, host: &str, tool: &str, label: &str) {
605 if let Some(auditor) = self.auditor.as_ref() {
606 auditor.record(host, tool, label);
607 }
608 }
609 }
610
611 /// Extract the host portion of a URL, lowercased. Returns `None` if the URL
612 /// can't be parsed or has no host.
613 #[must_use]
614 pub fn host_from_url(url: &str) -> Option<String> {
615 let parsed = reqwest::Url::parse(url.trim()).ok()?;
616 parsed.host_str().map(str::to_ascii_lowercase)
617 }
618
619 #[cfg(test)]
620 mod tests {
621 use super::*;
622 use tempfile::tempdir;
623
624 fn mk(default: Decision, allow: &[&str], deny: &[&str]) -> NetworkPolicy {
625 NetworkPolicy {
626 default: default.into(),
627 allow: allow.iter().map(|s| (*s).to_string()).collect(),
628 deny: deny.iter().map(|s| (*s).to_string()).collect(),
629 proxy: Vec::new(),
630 proxy_fake_ip_cidrs: Vec::new(),
631 audit: false,
632 }
633 }
634
635 #[test]
636 fn exact_match_in_allow_returns_allow() {
637 let p = mk(Decision::Deny, &["api.deepseek.com"], &[]);
638 assert_eq!(p.decide("api.deepseek.com"), Decision::Allow);
639 }
640
641 #[test]
642 fn unknown_host_returns_default() {
643 let p = mk(Decision::Deny, &["api.deepseek.com"], &[]);
644 assert_eq!(p.decide("evil.example.com"), Decision::Deny);
645
646 let p2 = mk(Decision::Prompt, &[], &[]);
647 assert_eq!(p2.decide("anything.example"), Decision::Prompt);
648 }
649
650 #[test]
651 fn deny_wins_precedence() {
652 // Acceptance criterion: a host in both allow and deny is denied.
653 let p = mk(Decision::Prompt, &["api.example.com"], &["api.example.com"]);
654 assert_eq!(p.decide("api.example.com"), Decision::Deny);
655 }
656
657 #[test]
658 fn deny_wins_with_subdomain_rules() {
659 // Deny-wins applies even when the deny is a wildcard and the allow is exact.
660 let p = mk(Decision::Allow, &["api.example.com"], &[".example.com"]);
661 assert_eq!(p.decide("api.example.com"), Decision::Deny);
662 }
663
664 #[test]
665 fn subdomain_wildcard_matches_subdomain_only() {
666 let p = mk(Decision::Deny, &[".example.com"], &[]);
667 assert_eq!(p.decide("api.example.com"), Decision::Allow);
668 assert_eq!(p.decide("a.b.example.com"), Decision::Allow);
669 // The bare apex is *not* matched by `.example.com` per the rule.
670 assert_eq!(p.decide("example.com"), Decision::Deny);
671 }
672
673 #[test]
674 fn star_dot_subdomain_alias_is_accepted() {
675 let p = mk(Decision::Deny, &["*.example.com"], &[]);
676 assert_eq!(p.decide("api.example.com"), Decision::Allow);
677 assert_eq!(p.decide("example.com"), Decision::Deny);
678 }
679
680 #[test]
681 fn host_match_is_case_insensitive() {
682 let p = mk(Decision::Deny, &["API.DeepSeek.com"], &[]);
683 assert_eq!(p.decide("api.deepseek.com"), Decision::Allow);
684 }
685
686 #[test]
687 fn trailing_dot_is_ignored() {
688 let p = mk(Decision::Deny, &["api.deepseek.com"], &[]);
689 assert_eq!(p.decide("api.deepseek.com."), Decision::Allow);
690 }
691
692 #[test]
693 fn empty_host_uses_default() {
694 let p = mk(Decision::Deny, &["api.example.com"], &[]);
695 assert_eq!(p.decide(""), Decision::Deny);
696 assert_eq!(p.decide(" "), Decision::Deny);
697 }
698
699 #[test]
700 fn add_allow_dedupes_case_insensitively() {
701 let mut p = mk(Decision::Deny, &[], &[]);
702 p.add_allow("Example.COM");
703 p.add_allow("example.com");
704 assert_eq!(p.allow.len(), 1);
705 assert_eq!(p.allow[0], "example.com");
706 }
707
708 #[test]
709 fn trusted_proxy_fakeip_hosts_match_exact_and_subdomains() {
710 let mut p = mk(Decision::Deny, &[], &[]);
711 p.proxy = vec![
712 "github.com".to_string(),
713 ".githubusercontent.com".to_string(),
714 ];
715
716 assert!(p.trusts_proxy_fakeip_host("github.com"));
717 assert!(p.trusts_proxy_fakeip_host("raw.githubusercontent.com"));
718 assert!(!p.trusts_proxy_fakeip_host("githubusercontent.com"));
719 assert!(!p.trusts_proxy_fakeip_host("example.com"));
720 }
721
722 #[test]
723 fn trusted_proxy_fakeip_hosts_respect_deny_precedence() {
724 let mut p = mk(Decision::Allow, &[], &["raw.githubusercontent.com"]);
725 p.proxy = vec![".githubusercontent.com".to_string()];
726
727 assert!(!p.trusts_proxy_fakeip_host("raw.githubusercontent.com"));
728 assert!(p.trusts_proxy_fakeip_host("avatars.githubusercontent.com"));
729 }
730
731 #[test]
732 fn trusted_fakeip_cidr_allows_placeholder_but_not_real_private() {
733 let decider = NetworkPolicyDecider::new(NetworkPolicy::default(), None)
734 .with_trusted_fakeip_cidrs(&[
735 "198.18.0.0/15",
736 "127.0.0.0/8",
737 "10.0.0.0/8",
738 "169.254.0.0/16",
739 ]);
740
741 // fake-ip placeholder range (clash default / IETF benchmark) is trusted
742 assert!(decider.is_trusted_fakeip_addr(&"198.18.0.5".parse::<std::net::IpAddr>().unwrap()));
743 assert!(
744 decider.is_trusted_fakeip_addr(&"198.19.255.255".parse::<std::net::IpAddr>().unwrap())
745 );
746
747 // real private / loopback / link-local / cloud-metadata are NOT trusted
748 for ip in ["192.168.1.1", "10.0.0.1", "127.0.0.1", "169.254.169.254"] {
749 assert!(
750 !decider.is_trusted_fakeip_addr(&ip.parse::<std::net::IpAddr>().unwrap()),
751 "{ip} must not be treated as a fake-ip placeholder"
752 );
753 }
754
755 // no ranges configured → nothing trusted
756 let bare = NetworkPolicyDecider::new(NetworkPolicy::default(), None);
757 assert!(!bare.is_trusted_fakeip_addr(&"198.18.0.5".parse::<std::net::IpAddr>().unwrap()));
758 }
759
760 #[test]
761 fn configured_fakeip_cidrs_are_loaded_but_unsafe_ranges_are_ignored() {
762 let policy = NetworkPolicy {
763 proxy_fake_ip_cidrs: vec![
764 "198.18.0.0/15".to_string(),
765 "127.0.0.0/8".to_string(),
766 "10.0.0.0/8".to_string(),
767 ],
768 ..NetworkPolicy::default()
769 };
770 let decider = NetworkPolicyDecider::new(policy, None);
771
772 assert!(decider.is_trusted_fakeip_addr(&"198.19.0.5".parse().unwrap()));
773 assert!(!decider.is_trusted_fakeip_addr(&"127.0.0.1".parse().unwrap()));
774 assert!(!decider.is_trusted_fakeip_addr(&"10.0.0.1".parse().unwrap()));
775 }
776
777 #[test]
778 fn host_from_url_extracts_host() {
779 assert_eq!(
780 host_from_url("https://api.deepseek.com/health"),
781 Some("api.deepseek.com".to_string())
782 );
783 assert_eq!(
784 host_from_url("http://Example.COM:8080/x"),
785 Some("example.com".to_string())
786 );
787 assert_eq!(host_from_url("not a url"), None);
788 }
789
790 #[test]
791 fn auditor_writes_one_line_per_call() {
792 let dir = tempdir().expect("tempdir");
793 let path = dir.path().join("audit.log");
794 let auditor = NetworkAuditor::new(path.clone(), true);
795 auditor.record("api.example.com", "fetch_url", "Allow");
796 auditor.record("evil.example.com", "fetch_url", "Deny");
797 let body = std::fs::read_to_string(&path).expect("read");
798 let lines: Vec<&str> = body.lines().collect();
799 assert_eq!(lines.len(), 2);
800 for line in &lines {
801 // <ts> network <host> <tool> <decision>
802 let parts: Vec<&str> = line.split_whitespace().collect();
803 assert!(parts.len() >= 5, "line shape: {line}");
804 assert_eq!(parts[1], "network");
805 }
806 assert!(lines[0].contains("api.example.com"));
807 assert!(lines[0].ends_with("Allow"));
808 assert!(lines[1].contains("evil.example.com"));
809 assert!(lines[1].ends_with("Deny"));
810 }
811
812 #[test]
813 fn auditor_disabled_writes_nothing() {
814 let dir = tempdir().expect("tempdir");
815 let path = dir.path().join("audit.log");
816 let auditor = NetworkAuditor::new(path.clone(), false);
817 auditor.record("api.example.com", "fetch_url", "Allow");
818 assert!(!path.exists() || std::fs::read_to_string(&path).unwrap().is_empty());
819 }
820
821 #[test]
822 fn session_cache_short_circuits_evaluate() {
823 let policy = mk(Decision::Prompt, &[], &[]);
824 let decider = NetworkPolicyDecider::new(policy, None);
825 // First call returns Prompt.
826 assert_eq!(
827 decider.evaluate("api.example.com", "fetch_url"),
828 Decision::Prompt
829 );
830 decider.approve_session("api.example.com", "fetch_url");
831 // After approve_session, the same host returns Allow without prompting.
832 assert_eq!(
833 decider.evaluate("api.example.com", "fetch_url"),
834 Decision::Allow
835 );
836 }
837
838 #[test]
839 fn approve_persistent_writes_back_to_policy() {
840 let policy = mk(Decision::Prompt, &[], &[]);
841 let mut decider = NetworkPolicyDecider::new(policy, None);
842 decider.approve_persistent("api.example.com", "fetch_url");
843 assert!(
844 decider
845 .policy()
846 .allow
847 .iter()
848 .any(|h| h == "api.example.com")
849 );
850 // And the session cache also got updated, so fresh evaluate returns Allow.
851 assert_eq!(
852 decider.evaluate("api.example.com", "fetch_url"),
853 Decision::Allow
854 );
855 }
856
857 #[test]
858 fn deny_session_blocks_subsequent_evaluate() {
859 let policy = mk(Decision::Allow, &[], &[]);
860 let decider = NetworkPolicyDecider::new(policy, None);
861 decider.deny_session("evil.example.com", "fetch_url");
862 assert_eq!(
863 decider.evaluate("evil.example.com", "fetch_url"),
864 Decision::Deny
865 );
866 }
867
868 #[test]
869 fn audit_records_terminal_decisions_through_decider() {
870 let dir = tempdir().expect("tempdir");
871 let auditor = NetworkAuditor::new(dir.path().join("audit.log"), true);
872 let policy = mk(Decision::Deny, &["api.deepseek.com"], &[]);
873 let decider = NetworkPolicyDecider::new(policy, Some(auditor));
874
875 let allow = decider.evaluate("api.deepseek.com", "fetch_url");
876 let deny = decider.evaluate("evil.example.com", "fetch_url");
877 assert_eq!(allow, Decision::Allow);
878 assert_eq!(deny, Decision::Deny);
879
880 let body = std::fs::read_to_string(dir.path().join("audit.log")).expect("read");
881 let lines: Vec<&str> = body.lines().collect();
882 assert_eq!(lines.len(), 2);
883 assert!(lines[0].ends_with("Allow"));
884 assert!(lines[1].ends_with("Deny"));
885 }
886
887 #[test]
888 fn decision_parse_unknown_falls_back_to_prompt() {
889 assert_eq!(Decision::parse("allow"), Decision::Allow);
890 assert_eq!(Decision::parse("Deny"), Decision::Deny);
891 assert_eq!(Decision::parse("BLOCK"), Decision::Deny);
892 assert_eq!(Decision::parse("prompt"), Decision::Prompt);
893 assert_eq!(Decision::parse("garbage"), Decision::Prompt);
894 }
895
896 #[test]
897 fn network_denied_carries_host() {
898 let err = NetworkDenied("api.example.com".to_string());
899 assert_eq!(err.host(), "api.example.com");
900 assert!(format!("{err}").contains("api.example.com"));
901 }
902 }
903
903 lines RUST