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 std::collections::BTreeMap;
20use std::net::IpAddr;
21use std::rc::Rc;
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(Rc<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.insert(
394        "type".to_string(),
395        VmValue::String(Rc::from("NetPolicyViolation")),
396    );
397    dict.insert(
398        "category".to_string(),
399        VmValue::String(Rc::from("net_policy_violation")),
400    );
401    dict.insert(
402        "message".to_string(),
403        VmValue::String(Rc::from(format!(
404            "harness.net.{} blocked {}: {}",
405            audit.method, audit.url, audit.reason
406        ))),
407    );
408    dict.insert(
409        "method".to_string(),
410        VmValue::String(Rc::from(audit.method.as_str())),
411    );
412    dict.insert(
413        "url".to_string(),
414        VmValue::String(Rc::from(audit.url.as_str())),
415    );
416    dict.insert(
417        "host".to_string(),
418        VmValue::String(Rc::from(audit.host.as_str())),
419    );
420    dict.insert(
421        "port".to_string(),
422        audit
423            .port
424            .map(|port| VmValue::Int(port as i64))
425            .unwrap_or(VmValue::Nil),
426    );
427    dict.insert(
428        "reason".to_string(),
429        VmValue::String(Rc::from(audit.reason.as_str())),
430    );
431    dict.insert(
432        "outcome".to_string(),
433        VmValue::String(Rc::from(audit.outcome)),
434    );
435    dict.insert(
436        "matched_rule".to_string(),
437        audit
438            .matched_rule
439            .as_deref()
440            .map(|raw| VmValue::String(Rc::from(raw)))
441            .unwrap_or(VmValue::Nil),
442    );
443    if audit.bypass {
444        dict.insert("bypass".to_string(), VmValue::Bool(true));
445    }
446    VmError::Thrown(VmValue::Dict(Rc::new(dict)))
447}
448
449/// Build the request envelope handed to the user `on_violation`
450/// callback. Plain dict so the script can index with the usual
451/// optional-chaining and `?.` syntax.
452pub fn violation_request_value(audit: &NetPolicyAudit) -> VmValue {
453    let mut dict = BTreeMap::new();
454    dict.insert(
455        "method".to_string(),
456        VmValue::String(Rc::from(audit.method.as_str())),
457    );
458    dict.insert(
459        "url".to_string(),
460        VmValue::String(Rc::from(audit.url.as_str())),
461    );
462    dict.insert(
463        "host".to_string(),
464        VmValue::String(Rc::from(audit.host.as_str())),
465    );
466    dict.insert(
467        "port".to_string(),
468        audit
469            .port
470            .map(|port| VmValue::Int(port as i64))
471            .unwrap_or(VmValue::Nil),
472    );
473    dict.insert(
474        "reason".to_string(),
475        VmValue::String(Rc::from(audit.reason.as_str())),
476    );
477    dict.insert(
478        "matched_rule".to_string(),
479        audit
480            .matched_rule
481            .as_deref()
482            .map(|raw| VmValue::String(Rc::from(raw)))
483            .unwrap_or(VmValue::Nil),
484    );
485    VmValue::Dict(Rc::new(dict))
486}
487
488/// Emit a `harness.net.policy.audit` event to the active event log,
489/// if any. Returns silently when no event log is bound so unit tests
490/// that bypass the full runtime still exercise the matcher.
491pub async fn record_audit(audit: &NetPolicyAudit) {
492    let Some(log) = active_event_log() else {
493        return;
494    };
495    let Ok(topic) = Topic::new(NET_POLICY_AUDIT_TOPIC) else {
496        return;
497    };
498    let _ = log
499        .append(
500            &topic,
501            LogEvent::new("net.policy.evaluated", audit.to_json()),
502        )
503        .await;
504}
505
506/// Returns true when the bypass env var is set to a truthy value. The
507/// dispatcher still records the bypass with `bypass: true` so the
508/// audit trail keeps a record of the leak.
509pub fn bypass_enabled() -> bool {
510    match std::env::var(HARN_NET_POLICY_BYPASS_ENV) {
511        Ok(value) => matches!(
512            value.trim().to_ascii_lowercase().as_str(),
513            "1" | "true" | "yes" | "on"
514        ),
515        Err(_) => false,
516    }
517}
518
519fn normalize_host(host: &str) -> String {
520    host.trim()
521        .trim_end_matches('.')
522        .trim_matches('[')
523        .trim_matches(']')
524        .to_ascii_lowercase()
525}
526
527fn vm_error(message: impl Into<String>) -> VmError {
528    VmError::Thrown(VmValue::String(Rc::from(message.into())))
529}
530
531/// VM-side helpers used by both the constructor builtins
532/// (`crate::stdlib::net_policy`) and the dispatcher when it accepts
533/// a `with_net_policy({...})` shorthand dict.
534pub mod parse {
535    use super::*;
536
537    /// Sentinel key used to recognise a tagged-dict policy rule.
538    pub const RULE_TAG_KEY: &str = "__net_policy_rule";
539    /// Sentinel key used to recognise a tagged-dict policy value.
540    pub const POLICY_TAG_KEY: &str = "__net_policy";
541
542    /// Inspect a `VmValue` and lift it into a `NetPolicyRule`. Accepts
543    /// the tagged-dict shape produced by the constructor builtins as
544    /// well as bare strings interpreted as `domain` (or
545    /// `domain_wildcard` when they start with `*.`).
546    pub fn rule_from_vm(value: &VmValue) -> Result<NetPolicyRule, VmError> {
547        match value {
548            VmValue::Dict(dict) => rule_from_dict(dict),
549            VmValue::String(raw) => {
550                let raw = raw.as_ref();
551                if raw.starts_with("*.") {
552                    NetPolicyRule::parse_domain_wildcard(raw)
553                } else if raw.contains('/') {
554                    NetPolicyRule::parse_cidr(raw)
555                } else {
556                    NetPolicyRule::parse_domain(raw)
557                }
558            }
559            other => Err(vm_error(format!(
560                "NetPolicy: rule must be a tagged dict or string, got {}",
561                other.type_name()
562            ))),
563        }
564    }
565
566    fn rule_from_dict(dict: &BTreeMap<String, VmValue>) -> Result<NetPolicyRule, VmError> {
567        let tag = dict
568            .get(RULE_TAG_KEY)
569            .and_then(|v| match v {
570                VmValue::String(s) => Some(s.to_string()),
571                _ => None,
572            })
573            .ok_or_else(|| {
574                vm_error(
575                    "NetPolicy: rule dict is missing the `__net_policy_rule` tag; build rules via NetPolicy.domain/.domain_wildcard/.cidr/.host",
576                )
577            })?;
578        match tag.as_str() {
579            "domain" => {
580                let host = require_string(dict, "host", "NetPolicy.domain")?;
581                NetPolicyRule::parse_domain(&host)
582            }
583            "domain_wildcard" => {
584                let pattern = require_string(dict, "pattern", "NetPolicy.domain_wildcard")?;
585                NetPolicyRule::parse_domain_wildcard(&pattern)
586            }
587            "cidr" => {
588                let range = require_string(dict, "range", "NetPolicy.cidr")?;
589                NetPolicyRule::parse_cidr(&range)
590            }
591            "host" => {
592                let host = require_string(dict, "host", "NetPolicy.host")?;
593                let ports = match dict.get("ports") {
594                    Some(VmValue::List(list)) => {
595                        let mut parsed = Vec::with_capacity(list.len());
596                        for value in list.iter() {
597                            let port = value
598                                .as_int()
599                                .and_then(|n| u16::try_from(n).ok())
600                                .ok_or_else(|| {
601                                    vm_error("NetPolicy.host: ports must be a list of u16 integers")
602                                })?;
603                            parsed.push(port);
604                        }
605                        Some(parsed)
606                    }
607                    Some(VmValue::Nil) | None => None,
608                    Some(_) => {
609                        return Err(vm_error(
610                            "NetPolicy.host: ports must be a list of u16 integers",
611                        ))
612                    }
613                };
614                NetPolicyRule::parse_host(&host, ports)
615            }
616            other => Err(vm_error(format!("NetPolicy: unknown rule kind `{other}`"))),
617        }
618    }
619
620    fn require_string(
621        dict: &BTreeMap<String, VmValue>,
622        key: &str,
623        callee: &str,
624    ) -> Result<String, VmError> {
625        match dict.get(key) {
626            Some(VmValue::String(s)) => Ok(s.as_ref().to_string()),
627            Some(other) => Err(vm_error(format!(
628                "{callee}: `{key}` must be a string, got {}",
629                other.type_name()
630            ))),
631            None => Err(vm_error(format!("{callee}: missing `{key}` field"))),
632        }
633    }
634
635    /// Build a `NetPolicy` value from the `{allow, deny, default,
636    /// on_violation}` dict produced by `NetPolicy.create(...)`.
637    pub fn policy_from_dict(dict: &BTreeMap<String, VmValue>) -> Result<NetPolicy, VmError> {
638        let allow = parse_rule_list(dict.get("allow"), "allow")?;
639        let deny = parse_rule_list(dict.get("deny"), "deny")?;
640        let default = match dict.get("default") {
641            Some(VmValue::String(s)) => NetPolicyDefault::parse(s.as_ref())?,
642            Some(VmValue::Nil) | None => NetPolicyDefault::Deny,
643            Some(other) => {
644                return Err(vm_error(format!(
645                    "NetPolicy.create: default must be a string, got {}",
646                    other.type_name()
647                )))
648            }
649        };
650        let on_violation = match dict.get("on_violation") {
651            Some(VmValue::String(s)) => OnViolation::parse_str(s.as_ref())?,
652            Some(VmValue::Closure(closure)) => OnViolation::Callback(Rc::clone(closure)),
653            Some(VmValue::Nil) | None => OnViolation::Error,
654            Some(other) => {
655                return Err(vm_error(format!(
656                    "NetPolicy.create: on_violation must be a string or callback, got {}",
657                    other.type_name()
658                )))
659            }
660        };
661        Ok(NetPolicy {
662            allow: Arc::new(allow),
663            deny: Arc::new(deny),
664            default,
665            on_violation,
666        })
667    }
668
669    fn parse_rule_list(value: Option<&VmValue>, side: &str) -> Result<Vec<NetPolicyRule>, VmError> {
670        match value {
671            None | Some(VmValue::Nil) => Ok(Vec::new()),
672            Some(VmValue::List(items)) => items.iter().map(rule_from_vm).collect(),
673            Some(other) => Err(vm_error(format!(
674                "NetPolicy.create: `{side}` must be a list, got {}",
675                other.type_name()
676            ))),
677        }
678    }
679}
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684
685    fn rule(raw: &str, ports: Option<Vec<u16>>) -> NetPolicyRule {
686        NetPolicyRule::parse_host(raw, ports).expect("rule parses")
687    }
688
689    fn cidr(raw: &str) -> NetPolicyRule {
690        NetPolicyRule::parse_cidr(raw).expect("cidr parses")
691    }
692
693    fn build(
694        allow: Vec<NetPolicyRule>,
695        deny: Vec<NetPolicyRule>,
696        default: NetPolicyDefault,
697    ) -> NetPolicy {
698        NetPolicy {
699            allow: Arc::new(allow),
700            deny: Arc::new(deny),
701            default,
702            on_violation: OnViolation::Error,
703        }
704    }
705
706    #[test]
707    fn exact_host_match_allows() {
708        let policy = build(
709            vec![rule("github.com", None)],
710            Vec::new(),
711            NetPolicyDefault::Deny,
712        );
713        let decision = policy
714            .evaluate("get", "https://github.com/foo")
715            .expect("evaluates");
716        assert!(matches!(decision, NetPolicyDecision::Allow { .. }));
717    }
718
719    #[test]
720    fn wildcard_does_not_match_bare_apex() {
721        let policy = build(
722            vec![NetPolicyRule::parse_domain_wildcard("*.github.com").unwrap()],
723            Vec::new(),
724            NetPolicyDefault::Deny,
725        );
726        let allow = policy.evaluate("get", "https://api.github.com/x").unwrap();
727        assert!(matches!(allow, NetPolicyDecision::Allow { .. }));
728        let deny = policy.evaluate("get", "https://github.com/x").unwrap();
729        assert!(matches!(deny, NetPolicyDecision::Deny { .. }));
730    }
731
732    #[test]
733    fn cidr_matches_ip_literal() {
734        let policy = build(vec![cidr("10.0.0.0/8")], Vec::new(), NetPolicyDefault::Deny);
735        let allowed = policy.evaluate("get", "http://10.5.5.5/x").unwrap();
736        assert!(matches!(allowed, NetPolicyDecision::Allow { .. }));
737        let denied = policy.evaluate("get", "http://192.168.1.1/x").unwrap();
738        assert!(matches!(denied, NetPolicyDecision::Deny { .. }));
739    }
740
741    #[test]
742    fn host_port_rule_requires_matching_port() {
743        let policy = build(
744            vec![rule("api.anthropic.com", Some(vec![443]))],
745            Vec::new(),
746            NetPolicyDefault::Deny,
747        );
748        let allow = policy
749            .evaluate("get", "https://api.anthropic.com/v1/messages")
750            .unwrap();
751        assert!(matches!(allow, NetPolicyDecision::Allow { .. }));
752        let deny = policy
753            .evaluate("get", "http://api.anthropic.com/v1/messages")
754            .unwrap();
755        assert!(matches!(deny, NetPolicyDecision::Deny { .. }));
756    }
757
758    #[test]
759    fn deny_overrides_allow() {
760        let policy = build(
761            vec![NetPolicyRule::parse_domain_wildcard("*.github.com").unwrap()],
762            vec![rule("evil.github.com", None)],
763            NetPolicyDefault::Deny,
764        );
765        let decision = policy.evaluate("get", "https://evil.github.com/x").unwrap();
766        match decision {
767            NetPolicyDecision::Deny { audit, .. } => {
768                assert!(audit.reason.contains("deny rule"));
769            }
770            other => panic!("expected deny, got {other:?}"),
771        }
772    }
773
774    #[test]
775    fn default_allow_lets_unmatched_through() {
776        let policy = build(Vec::new(), Vec::new(), NetPolicyDefault::Allow);
777        let allow = policy.evaluate("get", "https://example.test/x").unwrap();
778        assert!(matches!(allow, NetPolicyDecision::Allow { .. }));
779    }
780
781    #[test]
782    fn audit_only_allows_but_carries_audit() {
783        let mut policy = build(Vec::new(), Vec::new(), NetPolicyDefault::Deny);
784        policy.on_violation = OnViolation::AuditOnly;
785        let decision = policy
786            .evaluate("get", "https://blocked.test/x")
787            .expect("evaluates");
788        match decision {
789            NetPolicyDecision::Allow { audited, audit } => {
790                assert!(audited);
791                let audit = audit.expect("audit attached");
792                assert_eq!(audit.outcome, "audit_only");
793                assert_eq!(audit.host, "blocked.test");
794            }
795            other => panic!("expected audit_only allow, got {other:?}"),
796        }
797    }
798
799    #[test]
800    fn quarantine_denies_with_signal() {
801        let mut policy = build(Vec::new(), Vec::new(), NetPolicyDefault::Deny);
802        policy.on_violation = OnViolation::Quarantine;
803        match policy
804            .evaluate("get", "https://blocked.test/x")
805            .expect("evaluates")
806        {
807            NetPolicyDecision::Deny { audit, quarantine } => {
808                assert!(quarantine);
809                assert_eq!(audit.outcome, "quarantine");
810            }
811            other => panic!("expected quarantine deny, got {other:?}"),
812        }
813    }
814
815    #[test]
816    fn invalid_url_surfaces_typed_error() {
817        let policy = build(Vec::new(), Vec::new(), NetPolicyDefault::Deny);
818        let err = policy.evaluate("get", "not a url").unwrap_err();
819        match err {
820            VmError::Thrown(VmValue::String(s)) => {
821                assert!(s.contains("invalid URL"), "unexpected error: {s}");
822            }
823            other => panic!("expected Thrown, got {other:?}"),
824        }
825    }
826
827    #[test]
828    fn parse_string_rule_branches_on_shape() {
829        let domain = parse::rule_from_vm(&VmValue::String(Rc::from("github.com"))).unwrap();
830        assert!(matches!(domain.matcher, NetMatcher::Host(_)));
831        let wildcard = parse::rule_from_vm(&VmValue::String(Rc::from("*.github.com"))).unwrap();
832        assert!(matches!(wildcard.matcher, NetMatcher::Suffix(_)));
833        let cidr_rule = parse::rule_from_vm(&VmValue::String(Rc::from("10.0.0.0/8"))).unwrap();
834        assert!(matches!(cidr_rule.matcher, NetMatcher::Cidr(_)));
835    }
836
837    #[test]
838    fn bypass_env_recognised() {
839        let original = std::env::var(HARN_NET_POLICY_BYPASS_ENV).ok();
840        std::env::set_var(HARN_NET_POLICY_BYPASS_ENV, "1");
841        assert!(bypass_enabled());
842        std::env::set_var(HARN_NET_POLICY_BYPASS_ENV, "0");
843        assert!(!bypass_enabled());
844        match original {
845            Some(value) => std::env::set_var(HARN_NET_POLICY_BYPASS_ENV, value),
846            None => std::env::remove_var(HARN_NET_POLICY_BYPASS_ENV),
847        }
848    }
849}