Skip to main content

harn_vm/
harness_net.rs

1//! Per-harness `NetPolicy` rules and enforcement for `harness.net.*`.
2//!
3//! Issue: harn#1913 / epic #1765 — Harness explicit-capability.
4//!
5//! This module supplies the data model and matchers; the wiring into
6//! the request path lives in `crate::vm::methods::harness`. The
7//! constructor builtins (`__net_policy_create`,
8//! `__net_policy_domain`, …) live in `crate::stdlib::net_policy` so
9//! the Harn stdlib facade in `stdlib_net_policy.harn` can expose them
10//! as a single `NetPolicy()` namespace dict, mirroring the
11//! `OnBudget()` pattern.
12//!
13//! The earlier `crate::egress` module remains the process-global
14//! egress allowlist used by the connector/HTTP builtin paths. This
15//! module is intentionally narrower — it only governs the
16//! `harness.net.*` surface and is bound per-harness so different
17//! agents in the same process can carry different policies.
18
19use crate::value::VmDictExt;
20use std::collections::BTreeMap;
21use std::net::IpAddr;
22use std::str::FromStr;
23use std::sync::Arc;
24
25use ipnet::IpNet;
26use serde_json::json;
27use url::Url;
28
29use crate::event_log::{active_event_log, EventLog, LogEvent, Topic};
30use crate::value::{VmClosure, VmError, VmValue};
31
32/// Audit topic used for both deny and audit-only allow events.
33pub const NET_POLICY_AUDIT_TOPIC: &str = "harness.net.policy.audit";
34
35/// Env var that bypasses every policy on every harness. The bypass is
36/// itself audited so the trust graph still records the leak.
37pub const HARN_NET_POLICY_BYPASS_ENV: &str = "HARN_NET_POLICY_BYPASS";
38
39/// One allow / deny rule.
40#[derive(Clone, Debug)]
41pub struct NetPolicyRule {
42    pub raw: String,
43    pub matcher: NetMatcher,
44    pub ports: Option<Vec<u16>>,
45}
46
47#[derive(Clone, Debug)]
48pub enum NetMatcher {
49    /// Exact host match (case-insensitive). IDNA-normalised via `url`'s
50    /// host parser.
51    Host(String),
52    /// `*.suffix` — subdomain wildcard. Does NOT match the bare suffix.
53    Suffix(String),
54    /// Literal IP address.
55    Ip(IpAddr),
56    /// CIDR range; matches the resolved IP of the URL host.
57    Cidr(IpNet),
58}
59
60/// Default action when no allow rule matches and no deny rule fires.
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub enum NetPolicyDefault {
63    Allow,
64    Deny,
65}
66
67impl NetPolicyDefault {
68    pub fn as_str(self) -> &'static str {
69        match self {
70            NetPolicyDefault::Allow => "allow",
71            NetPolicyDefault::Deny => "deny",
72        }
73    }
74
75    pub fn parse(raw: &str) -> Result<Self, VmError> {
76        match raw.trim().to_ascii_lowercase().as_str() {
77            "allow" => Ok(NetPolicyDefault::Allow),
78            "" | "deny" => Ok(NetPolicyDefault::Deny),
79            other => Err(vm_error(format!(
80                "NetPolicy.create: default must be `allow` or `deny`, got `{other}`"
81            ))),
82        }
83    }
84}
85
86/// Action taken when the request is denied (either matched a deny
87/// rule or fell through to a `default: deny`).
88#[derive(Clone, Debug)]
89pub enum OnViolation {
90    /// Throw a typed `NetPolicyViolation` error.
91    Error,
92    /// Allow the request but record an audit entry tagged
93    /// `outcome: "audit_only"`.
94    AuditOnly,
95    /// Deny the request, record an audit entry tagged
96    /// `outcome: "quarantine"`, and mark the agent as quarantined via
97    /// an audit signal downstream consumers can pin on.
98    Quarantine,
99    /// Custom callback: `fn(req) -> "error" | "audit_only" |
100    /// "quarantine"`. The callback closure is invoked with a request
101    /// dict and the string outcome decides the next step.
102    Callback(Arc<VmClosure>),
103}
104
105impl OnViolation {
106    pub fn parse_str(raw: &str) -> Result<Self, VmError> {
107        match raw.trim() {
108            "error" => Ok(OnViolation::Error),
109            "audit_only" => Ok(OnViolation::AuditOnly),
110            "quarantine" => Ok(OnViolation::Quarantine),
111            other => Err(vm_error(format!(
112                "NetPolicy.create: on_violation must be one of `error`, `audit_only`, `quarantine`, or a callback, got `{other}`"
113            ))),
114        }
115    }
116}
117
118/// Compiled policy attached to a `Harness`.
119#[derive(Clone, Debug)]
120pub struct NetPolicy {
121    pub allow: Arc<Vec<NetPolicyRule>>,
122    pub deny: Arc<Vec<NetPolicyRule>>,
123    pub default: NetPolicyDefault,
124    pub on_violation: OnViolation,
125}
126
127/// Outcome of `NetPolicy::evaluate` plus any host bookkeeping the
128/// dispatcher should apply.
129#[derive(Clone, Debug)]
130pub enum NetPolicyDecision {
131    /// Allow the request. If `audited` is true, record an
132    /// `outcome: "audit_only"` entry for the trust graph.
133    Allow {
134        audited: bool,
135        audit: Option<NetPolicyAudit>,
136    },
137    /// Deny the request. The caller raises the typed error and the
138    /// dispatcher emits the audit.
139    Deny {
140        audit: NetPolicyAudit,
141        quarantine: bool,
142    },
143}
144
145/// Audit payload shared by both deny events and audit-only allows.
146#[derive(Clone, Debug)]
147pub struct NetPolicyAudit {
148    pub method: String,
149    pub url: String,
150    pub host: String,
151    pub port: Option<u16>,
152    pub reason: String,
153    pub outcome: &'static str,
154    pub bypass: bool,
155    pub matched_rule: Option<String>,
156}
157
158impl NetPolicyAudit {
159    fn to_json(&self) -> serde_json::Value {
160        json!({
161            "method": self.method,
162            "url": self.url,
163            "host": self.host,
164            "port": self.port,
165            "reason": self.reason,
166            "outcome": self.outcome,
167            "bypass": self.bypass,
168            "matched_rule": self.matched_rule,
169        })
170    }
171}
172
173#[derive(Clone, Debug)]
174pub struct NetTarget {
175    pub host: String,
176    pub ip: Option<IpAddr>,
177    pub port: Option<u16>,
178}
179
180impl NetTarget {
181    pub fn parse(raw_url: &str) -> Result<Self, VmError> {
182        let parsed = Url::parse(raw_url)
183            .map_err(|error| vm_error(format!("harness.net: invalid URL `{raw_url}`: {error}")))?;
184        let host = parsed.host_str().ok_or_else(|| {
185            vm_error(format!(
186                "harness.net: URL `{raw_url}` does not include a host"
187            ))
188        })?;
189        let host = normalize_host(host);
190        let ip = IpAddr::from_str(&host).ok();
191        Ok(Self {
192            host,
193            ip,
194            port: parsed.port_or_known_default(),
195        })
196    }
197}
198
199impl NetPolicyRule {
200    pub fn parse_host(raw: &str, ports: Option<Vec<u16>>) -> Result<Self, VmError> {
201        let raw = raw.trim();
202        if raw.is_empty() {
203            return Err(vm_error("NetPolicy.host: empty host"));
204        }
205        let host = normalize_host(raw);
206        let matcher = if let Some(suffix) = host.strip_prefix("*.") {
207            if suffix.is_empty() {
208                return Err(vm_error(format!(
209                    "NetPolicy.domain_wildcard: invalid wildcard `{raw}`"
210                )));
211            }
212            NetMatcher::Suffix(suffix.to_string())
213        } else if let Ok(ip) = IpAddr::from_str(&host) {
214            NetMatcher::Ip(ip)
215        } else {
216            NetMatcher::Host(host)
217        };
218        Ok(Self {
219            raw: raw.to_string(),
220            matcher,
221            ports,
222        })
223    }
224
225    pub fn parse_domain(raw: &str) -> Result<Self, VmError> {
226        Self::parse_host(raw, None)
227    }
228
229    pub fn parse_domain_wildcard(raw: &str) -> Result<Self, VmError> {
230        let trimmed = raw.trim();
231        if !trimmed.starts_with("*.") {
232            return Err(vm_error(format!(
233                "NetPolicy.domain_wildcard: pattern must start with `*.`, got `{raw}`"
234            )));
235        }
236        Self::parse_host(trimmed, None)
237    }
238
239    pub fn parse_cidr(raw: &str) -> Result<Self, VmError> {
240        let trimmed = raw.trim();
241        let net = IpNet::from_str(trimmed)
242            .map_err(|error| vm_error(format!("NetPolicy.cidr: invalid CIDR `{raw}`: {error}")))?;
243        Ok(Self {
244            raw: trimmed.to_string(),
245            matcher: NetMatcher::Cidr(net),
246            ports: None,
247        })
248    }
249
250    pub fn matches(&self, target: &NetTarget) -> bool {
251        if let Some(ports) = &self.ports {
252            match target.port {
253                Some(port) if ports.contains(&port) => {}
254                _ => return false,
255            }
256        }
257        match &self.matcher {
258            NetMatcher::Host(host) => target.host == *host,
259            NetMatcher::Suffix(suffix) => {
260                target.host.len() > suffix.len()
261                    && target.host.ends_with(suffix)
262                    && target
263                        .host
264                        .as_bytes()
265                        .get(target.host.len() - suffix.len() - 1)
266                        == Some(&b'.')
267            }
268            NetMatcher::Ip(ip) => target.ip == Some(*ip),
269            NetMatcher::Cidr(net) => target.ip.is_some_and(|ip| net.contains(&ip)),
270        }
271    }
272}
273
274impl NetPolicy {
275    /// Resolve the decision for a single request. The caller decides
276    /// what to do with the audit + quarantine signal — see the
277    /// dispatcher in `vm::methods::harness`.
278    pub fn evaluate(&self, method: &str, raw_url: &str) -> Result<NetPolicyDecision, VmError> {
279        let target = NetTarget::parse(raw_url)?;
280        if let Some(rule) = self.deny.iter().find(|rule| rule.matches(&target)) {
281            return Ok(self.deny_decision(
282                method,
283                raw_url,
284                &target,
285                format!("matched deny rule `{}`", rule.raw),
286                Some(rule.raw.clone()),
287            ));
288        }
289        if let Some(rule) = self.allow.iter().find(|rule| rule.matches(&target)) {
290            return Ok(NetPolicyDecision::Allow {
291                audited: false,
292                audit: Some(NetPolicyAudit {
293                    method: method.to_string(),
294                    url: raw_url.to_string(),
295                    host: target.host,
296                    port: target.port,
297                    reason: format!("matched allow rule `{}`", rule.raw),
298                    outcome: "allow",
299                    bypass: false,
300                    matched_rule: Some(rule.raw.clone()),
301                }),
302            });
303        }
304        if self.default == NetPolicyDefault::Allow {
305            return Ok(NetPolicyDecision::Allow {
306                audited: false,
307                audit: None,
308            });
309        }
310        Ok(self.deny_decision(
311            method,
312            raw_url,
313            &target,
314            "no allow rule matched (default deny)".to_string(),
315            None,
316        ))
317    }
318
319    fn deny_decision(
320        &self,
321        method: &str,
322        raw_url: &str,
323        target: &NetTarget,
324        reason: String,
325        matched_rule: Option<String>,
326    ) -> NetPolicyDecision {
327        match &self.on_violation {
328            OnViolation::Error => NetPolicyDecision::Deny {
329                audit: NetPolicyAudit {
330                    method: method.to_string(),
331                    url: raw_url.to_string(),
332                    host: target.host.clone(),
333                    port: target.port,
334                    reason,
335                    outcome: "error",
336                    bypass: false,
337                    matched_rule,
338                },
339                quarantine: false,
340            },
341            OnViolation::AuditOnly => NetPolicyDecision::Allow {
342                audited: true,
343                audit: Some(NetPolicyAudit {
344                    method: method.to_string(),
345                    url: raw_url.to_string(),
346                    host: target.host.clone(),
347                    port: target.port,
348                    reason,
349                    outcome: "audit_only",
350                    bypass: false,
351                    matched_rule,
352                }),
353            },
354            OnViolation::Quarantine => NetPolicyDecision::Deny {
355                audit: NetPolicyAudit {
356                    method: method.to_string(),
357                    url: raw_url.to_string(),
358                    host: target.host.clone(),
359                    port: target.port,
360                    reason,
361                    outcome: "quarantine",
362                    bypass: false,
363                    matched_rule,
364                },
365                quarantine: true,
366            },
367            // Callback resolution happens in the dispatcher because it
368            // needs the VM to invoke the closure. The default deny
369            // shape carries the audit; the dispatcher overrides
370            // `outcome` after the callback returns.
371            OnViolation::Callback(_) => NetPolicyDecision::Deny {
372                audit: NetPolicyAudit {
373                    method: method.to_string(),
374                    url: raw_url.to_string(),
375                    host: target.host.clone(),
376                    port: target.port,
377                    reason,
378                    outcome: "callback",
379                    bypass: false,
380                    matched_rule,
381                },
382                quarantine: false,
383            },
384        }
385    }
386}
387
388/// Construct the typed VM error returned to callers when a request is
389/// denied. Mirrors the shape of `crate::egress::EgressBlocked` so
390/// hosts can route on either consistently.
391pub fn violation_vm_error(audit: &NetPolicyAudit) -> VmError {
392    let mut dict = BTreeMap::new();
393    dict.put_str("type", "NetPolicyViolation");
394    dict.put_str("category", "net_policy_violation");
395    dict.put_str(
396        "message",
397        format!(
398            "harness.net.{} blocked {}: {}",
399            audit.method, audit.url, audit.reason
400        ),
401    );
402    dict.put_str("method", audit.method.as_str());
403    dict.put_str("url", audit.url.as_str());
404    dict.put_str("host", audit.host.as_str());
405    dict.insert(
406        "port".to_string(),
407        audit
408            .port
409            .map(|port| VmValue::Int(port as i64))
410            .unwrap_or(VmValue::Nil),
411    );
412    dict.put_str("reason", audit.reason.as_str());
413    dict.put_str("outcome", audit.outcome);
414    dict.insert(
415        "matched_rule".to_string(),
416        audit
417            .matched_rule
418            .as_deref()
419            .map(|raw| VmValue::String(std::sync::Arc::from(raw)))
420            .unwrap_or(VmValue::Nil),
421    );
422    if audit.bypass {
423        dict.insert("bypass".to_string(), VmValue::Bool(true));
424    }
425    VmError::Thrown(VmValue::dict(dict))
426}
427
428/// Build the request envelope handed to the user `on_violation`
429/// callback. Plain dict so the script can index with the usual
430/// optional-chaining and `?.` syntax.
431pub fn violation_request_value(audit: &NetPolicyAudit) -> VmValue {
432    let mut dict = BTreeMap::new();
433    dict.put_str("method", audit.method.as_str());
434    dict.put_str("url", audit.url.as_str());
435    dict.put_str("host", audit.host.as_str());
436    dict.insert(
437        "port".to_string(),
438        audit
439            .port
440            .map(|port| VmValue::Int(port as i64))
441            .unwrap_or(VmValue::Nil),
442    );
443    dict.put_str("reason", audit.reason.as_str());
444    dict.insert(
445        "matched_rule".to_string(),
446        audit
447            .matched_rule
448            .as_deref()
449            .map(|raw| VmValue::String(std::sync::Arc::from(raw)))
450            .unwrap_or(VmValue::Nil),
451    );
452    VmValue::dict(dict)
453}
454
455/// Emit a `harness.net.policy.audit` event to the active event log,
456/// if any. Returns silently when no event log is bound so unit tests
457/// that bypass the full runtime still exercise the matcher.
458pub async fn record_audit(audit: &NetPolicyAudit) {
459    let Some(log) = active_event_log() else {
460        return;
461    };
462    let Ok(topic) = Topic::new(NET_POLICY_AUDIT_TOPIC) else {
463        return;
464    };
465    let _ = log
466        .append(
467            &topic,
468            LogEvent::new("net.policy.evaluated", audit.to_json()),
469        )
470        .await;
471}
472
473/// Returns true when the bypass env var is set to a truthy value. The
474/// dispatcher still records the bypass with `bypass: true` so the
475/// audit trail keeps a record of the leak.
476pub fn bypass_enabled() -> bool {
477    match std::env::var(HARN_NET_POLICY_BYPASS_ENV) {
478        Ok(value) => matches!(
479            value.trim().to_ascii_lowercase().as_str(),
480            "1" | "true" | "yes" | "on"
481        ),
482        Err(_) => false,
483    }
484}
485
486fn normalize_host(host: &str) -> String {
487    host.trim()
488        .trim_end_matches('.')
489        .trim_matches('[')
490        .trim_matches(']')
491        .to_ascii_lowercase()
492}
493
494fn vm_error(message: impl Into<String>) -> VmError {
495    VmError::Thrown(VmValue::String(std::sync::Arc::from(message.into())))
496}
497
498/// VM-side helpers used by both the constructor builtins
499/// (`crate::stdlib::net_policy`) and the dispatcher when it accepts
500/// a `with_net_policy({...})` shorthand dict.
501pub mod parse {
502    use super::*;
503
504    /// Sentinel key used to recognise a tagged-dict policy rule.
505    pub const RULE_TAG_KEY: &str = "__net_policy_rule";
506    /// Sentinel key used to recognise a tagged-dict policy value.
507    pub const POLICY_TAG_KEY: &str = "__net_policy";
508
509    /// Inspect a `VmValue` and lift it into a `NetPolicyRule`. Accepts
510    /// the tagged-dict shape produced by the constructor builtins as
511    /// well as bare strings interpreted as `domain` (or
512    /// `domain_wildcard` when they start with `*.`).
513    pub fn rule_from_vm(value: &VmValue) -> Result<NetPolicyRule, VmError> {
514        match value {
515            VmValue::Dict(dict) => rule_from_dict(dict),
516            VmValue::String(raw) => {
517                let raw = raw.as_ref();
518                if raw.starts_with("*.") {
519                    NetPolicyRule::parse_domain_wildcard(raw)
520                } else if raw.contains('/') {
521                    NetPolicyRule::parse_cidr(raw)
522                } else {
523                    NetPolicyRule::parse_domain(raw)
524                }
525            }
526            other => Err(vm_error(format!(
527                "NetPolicy: rule must be a tagged dict or string, got {}",
528                other.type_name()
529            ))),
530        }
531    }
532
533    fn rule_from_dict(dict: &crate::value::DictMap) -> Result<NetPolicyRule, VmError> {
534        let tag = dict
535            .get(RULE_TAG_KEY)
536            .and_then(|v| match v {
537                VmValue::String(s) => Some(s.to_string()),
538                _ => None,
539            })
540            .ok_or_else(|| {
541                vm_error(
542                    "NetPolicy: rule dict is missing the `__net_policy_rule` tag; build rules via NetPolicy.domain/.domain_wildcard/.cidr/.host",
543                )
544            })?;
545        match tag.as_str() {
546            "domain" => {
547                let host = require_string(dict, "host", "NetPolicy.domain")?;
548                NetPolicyRule::parse_domain(&host)
549            }
550            "domain_wildcard" => {
551                let pattern = require_string(dict, "pattern", "NetPolicy.domain_wildcard")?;
552                NetPolicyRule::parse_domain_wildcard(&pattern)
553            }
554            "cidr" => {
555                let range = require_string(dict, "range", "NetPolicy.cidr")?;
556                NetPolicyRule::parse_cidr(&range)
557            }
558            "host" => {
559                let host = require_string(dict, "host", "NetPolicy.host")?;
560                let ports = match dict.get("ports") {
561                    Some(VmValue::List(list)) => {
562                        let mut parsed = Vec::with_capacity(list.len());
563                        for value in list.iter() {
564                            let port = value
565                                .as_int()
566                                .and_then(|n| u16::try_from(n).ok())
567                                .ok_or_else(|| {
568                                    vm_error("NetPolicy.host: ports must be a list of u16 integers")
569                                })?;
570                            parsed.push(port);
571                        }
572                        Some(parsed)
573                    }
574                    Some(VmValue::Nil) | None => None,
575                    Some(_) => {
576                        return Err(vm_error(
577                            "NetPolicy.host: ports must be a list of u16 integers",
578                        ))
579                    }
580                };
581                NetPolicyRule::parse_host(&host, ports)
582            }
583            other => Err(vm_error(format!("NetPolicy: unknown rule kind `{other}`"))),
584        }
585    }
586
587    fn require_string(
588        dict: &crate::value::DictMap,
589        key: &str,
590        callee: &str,
591    ) -> Result<String, VmError> {
592        match dict.get(key) {
593            Some(VmValue::String(s)) => Ok(s.as_ref().to_string()),
594            Some(other) => Err(vm_error(format!(
595                "{callee}: `{key}` must be a string, got {}",
596                other.type_name()
597            ))),
598            None => Err(vm_error(format!("{callee}: missing `{key}` field"))),
599        }
600    }
601
602    /// Build a `NetPolicy` value from the `{allow, deny, default,
603    /// on_violation}` dict produced by `NetPolicy.create(...)`.
604    pub fn policy_from_dict(dict: &crate::value::DictMap) -> Result<NetPolicy, VmError> {
605        let allow = parse_rule_list(dict.get("allow"), "allow")?;
606        let deny = parse_rule_list(dict.get("deny"), "deny")?;
607        let default = match dict.get("default") {
608            Some(VmValue::String(s)) => NetPolicyDefault::parse(s.as_ref())?,
609            Some(VmValue::Nil) | None => NetPolicyDefault::Deny,
610            Some(other) => {
611                return Err(vm_error(format!(
612                    "NetPolicy.create: default must be a string, got {}",
613                    other.type_name()
614                )))
615            }
616        };
617        let on_violation = match dict.get("on_violation") {
618            Some(VmValue::String(s)) => OnViolation::parse_str(s.as_ref())?,
619            Some(VmValue::Closure(closure)) => OnViolation::Callback(Arc::clone(closure)),
620            Some(VmValue::Nil) | None => OnViolation::Error,
621            Some(other) => {
622                return Err(vm_error(format!(
623                    "NetPolicy.create: on_violation must be a string or callback, got {}",
624                    other.type_name()
625                )))
626            }
627        };
628        Ok(NetPolicy {
629            allow: Arc::new(allow),
630            deny: Arc::new(deny),
631            default,
632            on_violation,
633        })
634    }
635
636    fn parse_rule_list(value: Option<&VmValue>, side: &str) -> Result<Vec<NetPolicyRule>, VmError> {
637        match value {
638            None | Some(VmValue::Nil) => Ok(Vec::new()),
639            Some(VmValue::List(items)) => items.iter().map(rule_from_vm).collect(),
640            Some(other) => Err(vm_error(format!(
641                "NetPolicy.create: `{side}` must be a list, got {}",
642                other.type_name()
643            ))),
644        }
645    }
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651
652    fn rule(raw: &str, ports: Option<Vec<u16>>) -> NetPolicyRule {
653        NetPolicyRule::parse_host(raw, ports).expect("rule parses")
654    }
655
656    fn cidr(raw: &str) -> NetPolicyRule {
657        NetPolicyRule::parse_cidr(raw).expect("cidr parses")
658    }
659
660    fn build(
661        allow: Vec<NetPolicyRule>,
662        deny: Vec<NetPolicyRule>,
663        default: NetPolicyDefault,
664    ) -> NetPolicy {
665        NetPolicy {
666            allow: Arc::new(allow),
667            deny: Arc::new(deny),
668            default,
669            on_violation: OnViolation::Error,
670        }
671    }
672
673    #[test]
674    fn exact_host_match_allows() {
675        let policy = build(
676            vec![rule("github.com", None)],
677            Vec::new(),
678            NetPolicyDefault::Deny,
679        );
680        let decision = policy
681            .evaluate("get", "https://github.com/foo")
682            .expect("evaluates");
683        assert!(matches!(decision, NetPolicyDecision::Allow { .. }));
684    }
685
686    #[test]
687    fn wildcard_does_not_match_bare_apex() {
688        let policy = build(
689            vec![NetPolicyRule::parse_domain_wildcard("*.github.com").unwrap()],
690            Vec::new(),
691            NetPolicyDefault::Deny,
692        );
693        let allow = policy.evaluate("get", "https://api.github.com/x").unwrap();
694        assert!(matches!(allow, NetPolicyDecision::Allow { .. }));
695        let deny = policy.evaluate("get", "https://github.com/x").unwrap();
696        assert!(matches!(deny, NetPolicyDecision::Deny { .. }));
697    }
698
699    #[test]
700    fn cidr_matches_ip_literal() {
701        let policy = build(vec![cidr("10.0.0.0/8")], Vec::new(), NetPolicyDefault::Deny);
702        let allowed = policy.evaluate("get", "http://10.5.5.5/x").unwrap();
703        assert!(matches!(allowed, NetPolicyDecision::Allow { .. }));
704        let denied = policy.evaluate("get", "http://192.168.1.1/x").unwrap();
705        assert!(matches!(denied, NetPolicyDecision::Deny { .. }));
706    }
707
708    #[test]
709    fn host_port_rule_requires_matching_port() {
710        let policy = build(
711            vec![rule("api.anthropic.com", Some(vec![443]))],
712            Vec::new(),
713            NetPolicyDefault::Deny,
714        );
715        let allow = policy
716            .evaluate("get", "https://api.anthropic.com/v1/messages")
717            .unwrap();
718        assert!(matches!(allow, NetPolicyDecision::Allow { .. }));
719        let deny = policy
720            .evaluate("get", "http://api.anthropic.com/v1/messages")
721            .unwrap();
722        assert!(matches!(deny, NetPolicyDecision::Deny { .. }));
723    }
724
725    #[test]
726    fn deny_overrides_allow() {
727        let policy = build(
728            vec![NetPolicyRule::parse_domain_wildcard("*.github.com").unwrap()],
729            vec![rule("evil.github.com", None)],
730            NetPolicyDefault::Deny,
731        );
732        let decision = policy.evaluate("get", "https://evil.github.com/x").unwrap();
733        match decision {
734            NetPolicyDecision::Deny { audit, .. } => {
735                assert!(audit.reason.contains("deny rule"));
736            }
737            other => panic!("expected deny, got {other:?}"),
738        }
739    }
740
741    #[test]
742    fn default_allow_lets_unmatched_through() {
743        let policy = build(Vec::new(), Vec::new(), NetPolicyDefault::Allow);
744        let allow = policy.evaluate("get", "https://example.test/x").unwrap();
745        assert!(matches!(allow, NetPolicyDecision::Allow { .. }));
746    }
747
748    #[test]
749    fn audit_only_allows_but_carries_audit() {
750        let mut policy = build(Vec::new(), Vec::new(), NetPolicyDefault::Deny);
751        policy.on_violation = OnViolation::AuditOnly;
752        let decision = policy
753            .evaluate("get", "https://blocked.test/x")
754            .expect("evaluates");
755        match decision {
756            NetPolicyDecision::Allow { audited, audit } => {
757                assert!(audited);
758                let audit = audit.expect("audit attached");
759                assert_eq!(audit.outcome, "audit_only");
760                assert_eq!(audit.host, "blocked.test");
761            }
762            other => panic!("expected audit_only allow, got {other:?}"),
763        }
764    }
765
766    #[test]
767    fn quarantine_denies_with_signal() {
768        let mut policy = build(Vec::new(), Vec::new(), NetPolicyDefault::Deny);
769        policy.on_violation = OnViolation::Quarantine;
770        match policy
771            .evaluate("get", "https://blocked.test/x")
772            .expect("evaluates")
773        {
774            NetPolicyDecision::Deny { audit, quarantine } => {
775                assert!(quarantine);
776                assert_eq!(audit.outcome, "quarantine");
777            }
778            other => panic!("expected quarantine deny, got {other:?}"),
779        }
780    }
781
782    #[test]
783    fn invalid_url_surfaces_typed_error() {
784        let policy = build(Vec::new(), Vec::new(), NetPolicyDefault::Deny);
785        let err = policy.evaluate("get", "not a url").unwrap_err();
786        match err {
787            VmError::Thrown(VmValue::String(s)) => {
788                assert!(s.contains("invalid URL"), "unexpected error: {s}");
789            }
790            other => panic!("expected Thrown, got {other:?}"),
791        }
792    }
793
794    #[test]
795    fn parse_string_rule_branches_on_shape() {
796        let domain =
797            parse::rule_from_vm(&VmValue::String(std::sync::Arc::from("github.com"))).unwrap();
798        assert!(matches!(domain.matcher, NetMatcher::Host(_)));
799        let wildcard =
800            parse::rule_from_vm(&VmValue::String(std::sync::Arc::from("*.github.com"))).unwrap();
801        assert!(matches!(wildcard.matcher, NetMatcher::Suffix(_)));
802        let cidr_rule =
803            parse::rule_from_vm(&VmValue::String(std::sync::Arc::from("10.0.0.0/8"))).unwrap();
804        assert!(matches!(cidr_rule.matcher, NetMatcher::Cidr(_)));
805    }
806
807    #[test]
808    fn bypass_env_recognised() {
809        let original = std::env::var(HARN_NET_POLICY_BYPASS_ENV).ok();
810        std::env::set_var(HARN_NET_POLICY_BYPASS_ENV, "1");
811        assert!(bypass_enabled());
812        std::env::set_var(HARN_NET_POLICY_BYPASS_ENV, "0");
813        assert!(!bypass_enabled());
814        match original {
815            Some(value) => std::env::set_var(HARN_NET_POLICY_BYPASS_ENV, value),
816            None => std::env::remove_var(HARN_NET_POLICY_BYPASS_ENV),
817        }
818    }
819}