返回 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 the same `audit.log` every other audit event uses:
321 /// `$CODEWHALE_HOME/audit.log`, else `~/.codewhale/audit.log`. It used to
322 /// join `$HOME/.codewhale` itself, which ignored `CODEWHALE_HOME` and let
323 /// test processes append to the developer's real log. Returns `None` if
324 /// no Codewhale home resolves.
325 #[must_use]
326 pub fn default_path(enabled: bool) -> Option<Self> {
327 Some(Self::new(crate::audit::audit_log_path()?, enabled))
328 }
329
330 /// Append one line. Best-effort: errors are logged via `eprintln!` but
331 /// never bubble back to the caller.
332 pub fn record(&self, host: &str, tool: &str, decision_label: &str) {
333 if !self.enabled {
334 return;
335 }
336 if let Err(err) = self.try_record(host, tool, decision_label) {
337 // Routed through tracing so it lands in
338 // `~/.codewhale/logs/tui-YYYY-MM-DD.log` rather than the
339 // alt-screen — see `runtime_log` for the scroll-demon
340 // rationale.
341 tracing::warn!(target: "network_policy", ?err, host, tool, "network audit write failed");
342 }
343 }
344
345 fn try_record(&self, host: &str, tool: &str, decision_label: &str) -> std::io::Result<()> {
346 if let Some(parent) = self.path.parent() {
347 fs::create_dir_all(parent)?;
348 }
349 let mut file = OpenOptions::new()
350 .create(true)
351 .append(true)
352 .open(&self.path)?;
353 writeln!(
354 file,
355 "{ts} network {host} {tool} {decision}",
356 ts = Utc::now().to_rfc3339(),
357 host = sanitize_field(host),
358 tool = sanitize_field(tool),
359 decision = decision_label,
360 )
361 }
362
363 /// Path the auditor would write to. Mostly useful for tests.
364 #[must_use]
365 pub fn path(&self) -> &Path {
366 &self.path
367 }
368 }
369
370 /// Replace whitespace in a token so the line stays parseable.
371 fn sanitize_field(s: &str) -> String {
372 s.chars()
373 .map(|c| if c.is_whitespace() { '_' } else { c })
374 .collect()
375 }
376
377 /// In-process cache of "approve once for this session" decisions. Keyed by
378 /// normalized host. Thread-safe.
379 #[derive(Debug, Default, Clone)]
380 pub struct NetworkSessionCache {
381 inner: Arc<Mutex<NetworkSessionCacheInner>>,
382 }
383
384 #[derive(Debug, Default)]
385 struct NetworkSessionCacheInner {
386 approved: std::collections::HashSet<String>,
387 denied: std::collections::HashSet<String>,
388 }
389
390 impl NetworkSessionCache {
391 /// New empty cache.
392 #[must_use]
393 pub fn new() -> Self {
394 Self::default()
395 }
396
397 /// `true` if the host was previously approved this session.
398 #[must_use]
399 pub fn is_approved(&self, host: &str) -> bool {
400 let normalized = normalize_host(host);
401 self.inner
402 .lock()
403 .map(|guard| guard.approved.contains(&normalized))
404 .unwrap_or(false)
405 }
406
407 /// `true` if the host was previously denied this session.
408 #[must_use]
409 pub fn is_denied(&self, host: &str) -> bool {
410 let normalized = normalize_host(host);
411 self.inner
412 .lock()
413 .map(|guard| guard.denied.contains(&normalized))
414 .unwrap_or(false)
415 }
416
417 /// Mark the host as approved for the rest of this session.
418 pub fn approve(&self, host: &str) {
419 let normalized = normalize_host(host);
420 if let Ok(mut guard) = self.inner.lock() {
421 guard.denied.remove(&normalized);
422 guard.approved.insert(normalized);
423 }
424 }
425
426 /// Mark the host as denied for the rest of this session.
427 pub fn deny(&self, host: &str) {
428 let normalized = normalize_host(host);
429 if let Ok(mut guard) = self.inner.lock() {
430 guard.approved.remove(&normalized);
431 guard.denied.insert(normalized);
432 }
433 }
434 }
435
436 /// Structured error surfaced to callers when an outbound call is blocked.
437 #[derive(Debug, Clone, Error)]
438 #[error("network call to '{0}' blocked by network policy")]
439 pub struct NetworkDenied(pub String);
440
441 impl NetworkDenied {
442 /// The host that was denied.
443 #[must_use]
444 pub fn host(&self) -> &str {
445 &self.0
446 }
447 }
448
449 /// Glue type that bundles a [`NetworkPolicy`] with a session cache and an
450 /// auditor. Tools call [`NetworkPolicyDecider::evaluate`] before any HTTP
451 /// transport is constructed; the result decides whether to proceed, deny,
452 /// or prompt the user.
453 #[derive(Debug, Clone)]
454 pub struct NetworkPolicyDecider {
455 policy: NetworkPolicy,
456 cache: NetworkSessionCache,
457 auditor: Option<NetworkAuditor>,
458 /// IPv4 CIDR ranges that are treated as benign fake-IP placeholders (e.g.
459 /// a transparent-proxy / TUN setup running in `fake-ip` mode, where DNS
460 /// resolves every hostname into a reserved range like `198.18.0.0/15`).
461 /// A resolved IP inside one of these ranges bypasses the restricted-IP SSRF
462 /// block; real private/loopback/link-local/metadata IPs are unaffected.
463 trusted_fakeip_cidrs: Vec<(Ipv4Addr, u8)>,
464 }
465
466 impl NetworkPolicyDecider {
467 /// Build a decider from a policy. The session cache starts empty.
468 #[must_use]
469 pub fn new(policy: NetworkPolicy, auditor: Option<NetworkAuditor>) -> Self {
470 let trusted_fakeip_cidrs = policy
471 .proxy_fake_ip_cidrs
472 .iter()
473 .filter_map(|cidr| parse_trusted_fakeip_cidr(cidr))
474 .collect();
475 Self {
476 policy,
477 cache: NetworkSessionCache::new(),
478 auditor,
479 trusted_fakeip_cidrs,
480 }
481 }
482
483 /// Register IPv4 CIDR ranges to treat as benign fake-IP placeholders.
484 /// Invalid CIDR strings are skipped. See [`Self::is_trusted_fakeip_addr`].
485 #[must_use]
486 pub fn with_trusted_fakeip_cidrs(mut self, cidrs: &[&str]) -> Self {
487 for cidr in cidrs {
488 if let Some(parsed) = parse_trusted_fakeip_cidr(cidr) {
489 self.trusted_fakeip_cidrs.push(parsed);
490 }
491 }
492 self
493 }
494
495 /// Whether `ip` falls inside a configured fake-IP placeholder range.
496 ///
497 /// In `fake-ip` proxy/TUN setups the local resolver maps every hostname to
498 /// a reserved range (commonly `198.18.0.0/15`), so the DNS-resolution SSRF
499 /// check would otherwise reject every request. This narrowly trusts only
500 /// those placeholder addresses — real private/loopback/link-local/cloud-
501 /// metadata IPs are *not* matched and stay blocked.
502 #[must_use]
503 pub fn is_trusted_fakeip_addr(&self, ip: &IpAddr) -> bool {
504 match ip {
505 IpAddr::V4(v4) => self
506 .trusted_fakeip_cidrs
507 .iter()
508 .any(|(base, prefix)| ipv4_in_cidr(*v4, *base, *prefix)),
509 // fake-ip placeholders are IPv4-only in practice.
510 IpAddr::V6(_) => false,
511 }
512 }
513
514 /// Convenience: build a decider with default audit logging at
515 /// `~/.codewhale/audit.log`, if `policy.audit` is true.
516 #[must_use]
517 pub fn with_default_audit(policy: NetworkPolicy) -> Self {
518 let audit_enabled = policy.audit_enabled();
519 let auditor = if audit_enabled {
520 NetworkAuditor::default_path(true)
521 } else {
522 None
523 };
524 Self::new(policy, auditor)
525 }
526
527 /// Inspect the policy.
528 #[must_use]
529 pub fn policy(&self) -> &NetworkPolicy {
530 &self.policy
531 }
532
533 /// Inspect the session cache.
534 #[must_use]
535 pub fn cache(&self) -> &NetworkSessionCache {
536 &self.cache
537 }
538
539 /// Decide for `host`, consulting the session cache first.
540 ///
541 /// Audit logging happens **only** for terminal decisions (Allow / Deny).
542 /// `Prompt` is intentionally not logged here — the caller is responsible
543 /// for recording the user's eventual answer with `record_prompt_outcome`.
544 #[must_use]
545 pub fn evaluate(&self, host: &str, tool: &str) -> Decision {
546 let normalized = normalize_host(host);
547 if normalized.is_empty() {
548 return self.policy.default.into();
549 }
550 if self.cache.is_denied(&normalized) {
551 self.audit_record(&normalized, tool, "Deny");
552 return Decision::Deny;
553 }
554 if self.cache.is_approved(&normalized) {
555 self.audit_record(&normalized, tool, "Allow");
556 return Decision::Allow;
557 }
558 let decision = self.policy.decide(&normalized);
559 match decision {
560 Decision::Allow => self.audit_record(&normalized, tool, "Allow"),
561 Decision::Deny => self.audit_record(&normalized, tool, "Deny"),
562 Decision::Prompt => {}
563 }
564 decision
565 }
566
567 /// Approve `host` for the rest of the session (one-shot). Audit log gets
568 /// `Prompt-Approved`.
569 pub fn approve_session(&self, host: &str, tool: &str) {
570 self.cache.approve(host);
571 self.audit_record(host, tool, "Prompt-Approved");
572 }
573
574 /// Deny `host` for the rest of the session. Audit log gets `Prompt-Denied`.
575 pub fn deny_session(&self, host: &str, tool: &str) {
576 self.cache.deny(host);
577 self.audit_record(host, tool, "Prompt-Denied");
578 }
579
580 /// Persist `host` into the policy's allow list (so it survives the session)
581 /// **and** approve it in-session. Returns the updated policy so callers can
582 /// write it back to disk.
583 pub fn approve_persistent(&mut self, host: &str, tool: &str) -> &NetworkPolicy {
584 self.policy.add_allow(host);
585 self.cache.approve(host);
586 self.audit_record(host, tool, "Prompt-Approved");
587 &self.policy
588 }
589
590 /// Whether this host is explicitly configured for trusted proxy fake-IP
591 /// DNS handling.
592 #[must_use]
593 pub fn trusts_proxy_fakeip_host(&self, host: &str) -> bool {
594 self.policy.trusts_proxy_fakeip_host(host)
595 }
596
597 /// Record that a restricted DNS result was allowed because the host is in
598 /// the trusted proxy fake-IP list.
599 pub fn record_trusted_proxy_fakeip_allow(&self, host: &str, tool: &str) {
600 self.audit_record(host, tool, "TrustedProxyFakeIp-Allow");
601 }
602
603 fn audit_record(&self, host: &str, tool: &str, label: &str) {
604 if let Some(auditor) = self.auditor.as_ref() {
605 auditor.record(host, tool, label);
606 }
607 }
608 }
609
610 /// Extract the host portion of a URL, lowercased. Returns `None` if the URL
611 /// can't be parsed or has no host.
612 #[must_use]
613 pub fn host_from_url(url: &str) -> Option<String> {
614 let parsed = reqwest::Url::parse(url.trim()).ok()?;
615 parsed.host_str().map(str::to_ascii_lowercase)
616 }
617
618 #[cfg(test)]
619 mod tests {
620 use super::*;
621 use tempfile::tempdir;
622
623 fn mk(default: Decision, allow: &[&str], deny: &[&str]) -> NetworkPolicy {
624 NetworkPolicy {
625 default: default.into(),
626 allow: allow.iter().map(|s| (*s).to_string()).collect(),
627 deny: deny.iter().map(|s| (*s).to_string()).collect(),
628 proxy: Vec::new(),
629 proxy_fake_ip_cidrs: Vec::new(),
630 audit: false,
631 }
632 }
633
634 /// The network auditor writes to the one `audit.log` (which follows
635 /// `CODEWHALE_HOME`); it used to join `$HOME/.codewhale` itself.
636 #[test]
637 fn default_auditor_writes_to_the_shared_audit_log() {
638 assert_eq!(
639 NetworkAuditor::default_path(true).map(|auditor| auditor.path),
640 crate::audit::audit_log_path()
641 );
642 }
643
644 #[test]
645 fn exact_match_in_allow_returns_allow() {
646 let p = mk(Decision::Deny, &["api.deepseek.com"], &[]);
647 assert_eq!(p.decide("api.deepseek.com"), Decision::Allow);
648 }
649
650 #[test]
651 fn unknown_host_returns_default() {
652 let p = mk(Decision::Deny, &["api.deepseek.com"], &[]);
653 assert_eq!(p.decide("evil.example.com"), Decision::Deny);
654
655 let p2 = mk(Decision::Prompt, &[], &[]);
656 assert_eq!(p2.decide("anything.example"), Decision::Prompt);
657 }
658
659 #[test]
660 fn deny_wins_precedence() {
661 // Acceptance criterion: a host in both allow and deny is denied.
662 let p = mk(Decision::Prompt, &["api.example.com"], &["api.example.com"]);
663 assert_eq!(p.decide("api.example.com"), Decision::Deny);
664 }
665
666 #[test]
667 fn deny_wins_with_subdomain_rules() {
668 // Deny-wins applies even when the deny is a wildcard and the allow is exact.
669 let p = mk(Decision::Allow, &["api.example.com"], &[".example.com"]);
670 assert_eq!(p.decide("api.example.com"), Decision::Deny);
671 }
672
673 #[test]
674 fn subdomain_wildcard_matches_subdomain_only() {
675 let p = mk(Decision::Deny, &[".example.com"], &[]);
676 assert_eq!(p.decide("api.example.com"), Decision::Allow);
677 assert_eq!(p.decide("a.b.example.com"), Decision::Allow);
678 // The bare apex is *not* matched by `.example.com` per the rule.
679 assert_eq!(p.decide("example.com"), Decision::Deny);
680 }
681
682 #[test]
683 fn star_dot_subdomain_alias_is_accepted() {
684 let p = mk(Decision::Deny, &["*.example.com"], &[]);
685 assert_eq!(p.decide("api.example.com"), Decision::Allow);
686 assert_eq!(p.decide("example.com"), Decision::Deny);
687 }
688
689 #[test]
690 fn host_match_is_case_insensitive() {
691 let p = mk(Decision::Deny, &["API.DeepSeek.com"], &[]);
692 assert_eq!(p.decide("api.deepseek.com"), Decision::Allow);
693 }
694
695 #[test]
696 fn trailing_dot_is_ignored() {
697 let p = mk(Decision::Deny, &["api.deepseek.com"], &[]);
698 assert_eq!(p.decide("api.deepseek.com."), Decision::Allow);
699 }
700
701 #[test]
702 fn empty_host_uses_default() {
703 let p = mk(Decision::Deny, &["api.example.com"], &[]);
704 assert_eq!(p.decide(""), Decision::Deny);
705 assert_eq!(p.decide(" "), Decision::Deny);
706 }
707
708 #[test]
709 fn add_allow_dedupes_case_insensitively() {
710 let mut p = mk(Decision::Deny, &[], &[]);
711 p.add_allow("Example.COM");
712 p.add_allow("example.com");
713 assert_eq!(p.allow.len(), 1);
714 assert_eq!(p.allow[0], "example.com");
715 }
716
717 #[test]
718 fn trusted_proxy_fakeip_hosts_match_exact_and_subdomains() {
719 let mut p = mk(Decision::Deny, &[], &[]);
720 p.proxy = vec![
721 "github.com".to_string(),
722 ".githubusercontent.com".to_string(),
723 ];
724
725 assert!(p.trusts_proxy_fakeip_host("github.com"));
726 assert!(p.trusts_proxy_fakeip_host("raw.githubusercontent.com"));
727 assert!(!p.trusts_proxy_fakeip_host("githubusercontent.com"));
728 assert!(!p.trusts_proxy_fakeip_host("example.com"));
729 }
730
731 #[test]
732 fn trusted_proxy_fakeip_hosts_respect_deny_precedence() {
733 let mut p = mk(Decision::Allow, &[], &["raw.githubusercontent.com"]);
734 p.proxy = vec![".githubusercontent.com".to_string()];
735
736 assert!(!p.trusts_proxy_fakeip_host("raw.githubusercontent.com"));
737 assert!(p.trusts_proxy_fakeip_host("avatars.githubusercontent.com"));
738 }
739
740 #[test]
741 fn trusted_fakeip_cidr_allows_placeholder_but_not_real_private() {
742 let decider = NetworkPolicyDecider::new(NetworkPolicy::default(), None)
743 .with_trusted_fakeip_cidrs(&[
744 "198.18.0.0/15",
745 "127.0.0.0/8",
746 "10.0.0.0/8",
747 "169.254.0.0/16",
748 ]);
749
750 // fake-ip placeholder range (clash default / IETF benchmark) is trusted
751 assert!(decider.is_trusted_fakeip_addr(&"198.18.0.5".parse::<std::net::IpAddr>().unwrap()));
752 assert!(
753 decider.is_trusted_fakeip_addr(&"198.19.255.255".parse::<std::net::IpAddr>().unwrap())
754 );
755
756 // real private / loopback / link-local / cloud-metadata are NOT trusted
757 for ip in ["192.168.1.1", "10.0.0.1", "127.0.0.1", "169.254.169.254"] {
758 assert!(
759 !decider.is_trusted_fakeip_addr(&ip.parse::<std::net::IpAddr>().unwrap()),
760 "{ip} must not be treated as a fake-ip placeholder"
761 );
762 }
763
764 // no ranges configured → nothing trusted
765 let bare = NetworkPolicyDecider::new(NetworkPolicy::default(), None);
766 assert!(!bare.is_trusted_fakeip_addr(&"198.18.0.5".parse::<std::net::IpAddr>().unwrap()));
767 }
768
769 #[test]
770 fn configured_fakeip_cidrs_are_loaded_but_unsafe_ranges_are_ignored() {
771 let policy = NetworkPolicy {
772 proxy_fake_ip_cidrs: vec![
773 "198.18.0.0/15".to_string(),
774 "127.0.0.0/8".to_string(),
775 "10.0.0.0/8".to_string(),
776 ],
777 ..NetworkPolicy::default()
778 };
779 let decider = NetworkPolicyDecider::new(policy, None);
780
781 assert!(decider.is_trusted_fakeip_addr(&"198.19.0.5".parse().unwrap()));
782 assert!(!decider.is_trusted_fakeip_addr(&"127.0.0.1".parse().unwrap()));
783 assert!(!decider.is_trusted_fakeip_addr(&"10.0.0.1".parse().unwrap()));
784 }
785
786 #[test]
787 fn host_from_url_extracts_host() {
788 assert_eq!(
789 host_from_url("https://api.deepseek.com/health"),
790 Some("api.deepseek.com".to_string())
791 );
792 assert_eq!(
793 host_from_url("http://Example.COM:8080/x"),
794 Some("example.com".to_string())
795 );
796 assert_eq!(host_from_url("not a url"), None);
797 }
798
799 #[test]
800 fn auditor_writes_one_line_per_call() {
801 let dir = tempdir().expect("tempdir");
802 let path = dir.path().join("audit.log");
803 let auditor = NetworkAuditor::new(path.clone(), true);
804 auditor.record("api.example.com", "fetch_url", "Allow");
805 auditor.record("evil.example.com", "fetch_url", "Deny");
806 let body = std::fs::read_to_string(&path).expect("read");
807 let lines: Vec<&str> = body.lines().collect();
808 assert_eq!(lines.len(), 2);
809 for line in &lines {
810 // <ts> network <host> <tool> <decision>
811 let parts: Vec<&str> = line.split_whitespace().collect();
812 assert!(parts.len() >= 5, "line shape: {line}");
813 assert_eq!(parts[1], "network");
814 }
815 assert!(lines[0].contains("api.example.com"));
816 assert!(lines[0].ends_with("Allow"));
817 assert!(lines[1].contains("evil.example.com"));
818 assert!(lines[1].ends_with("Deny"));
819 }
820
821 #[test]
822 fn auditor_disabled_writes_nothing() {
823 let dir = tempdir().expect("tempdir");
824 let path = dir.path().join("audit.log");
825 let auditor = NetworkAuditor::new(path.clone(), false);
826 auditor.record("api.example.com", "fetch_url", "Allow");
827 assert!(!path.exists() || std::fs::read_to_string(&path).unwrap().is_empty());
828 }
829
830 #[test]
831 fn session_cache_short_circuits_evaluate() {
832 let policy = mk(Decision::Prompt, &[], &[]);
833 let decider = NetworkPolicyDecider::new(policy, None);
834 // First call returns Prompt.
835 assert_eq!(
836 decider.evaluate("api.example.com", "fetch_url"),
837 Decision::Prompt
838 );
839 decider.approve_session("api.example.com", "fetch_url");
840 // After approve_session, the same host returns Allow without prompting.
841 assert_eq!(
842 decider.evaluate("api.example.com", "fetch_url"),
843 Decision::Allow
844 );
845 }
846
847 #[test]
848 fn approve_persistent_writes_back_to_policy() {
849 let policy = mk(Decision::Prompt, &[], &[]);
850 let mut decider = NetworkPolicyDecider::new(policy, None);
851 decider.approve_persistent("api.example.com", "fetch_url");
852 assert!(
853 decider
854 .policy()
855 .allow
856 .iter()
857 .any(|h| h == "api.example.com")
858 );
859 // And the session cache also got updated, so fresh evaluate returns Allow.
860 assert_eq!(
861 decider.evaluate("api.example.com", "fetch_url"),
862 Decision::Allow
863 );
864 }
865
866 #[test]
867 fn deny_session_blocks_subsequent_evaluate() {
868 let policy = mk(Decision::Allow, &[], &[]);
869 let decider = NetworkPolicyDecider::new(policy, None);
870 decider.deny_session("evil.example.com", "fetch_url");
871 assert_eq!(
872 decider.evaluate("evil.example.com", "fetch_url"),
873 Decision::Deny
874 );
875 }
876
877 #[test]
878 fn audit_records_terminal_decisions_through_decider() {
879 let dir = tempdir().expect("tempdir");
880 let auditor = NetworkAuditor::new(dir.path().join("audit.log"), true);
881 let policy = mk(Decision::Deny, &["api.deepseek.com"], &[]);
882 let decider = NetworkPolicyDecider::new(policy, Some(auditor));
883
884 let allow = decider.evaluate("api.deepseek.com", "fetch_url");
885 let deny = decider.evaluate("evil.example.com", "fetch_url");
886 assert_eq!(allow, Decision::Allow);
887 assert_eq!(deny, Decision::Deny);
888
889 let body = std::fs::read_to_string(dir.path().join("audit.log")).expect("read");
890 let lines: Vec<&str> = body.lines().collect();
891 assert_eq!(lines.len(), 2);
892 assert!(lines[0].ends_with("Allow"));
893 assert!(lines[1].ends_with("Deny"));
894 }
895
896 #[test]
897 fn decision_parse_unknown_falls_back_to_prompt() {
898 assert_eq!(Decision::parse("allow"), Decision::Allow);
899 assert_eq!(Decision::parse("Deny"), Decision::Deny);
900 assert_eq!(Decision::parse("BLOCK"), Decision::Deny);
901 assert_eq!(Decision::parse("prompt"), Decision::Prompt);
902 assert_eq!(Decision::parse("garbage"), Decision::Prompt);
903 }
904
905 #[test]
906 fn network_denied_carries_host() {
907 let err = NetworkDenied("api.example.com".to_string());
908 assert_eq!(err.host(), "api.example.com");
909 assert!(format!("{err}").contains("api.example.com"));
910 }
911 }
912
912 lines RUST