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) => host_has_dns_suffix(&target.host, suffix),
260            NetMatcher::Ip(ip) => target.ip == Some(*ip),
261            NetMatcher::Cidr(net) => target.ip.is_some_and(|ip| net.contains(&ip)),
262        }
263    }
264}
265
266/// True when `host` is a strict dot-delimited subdomain of `suffix`
267/// (e.g. `api.example.com` matches suffix `example.com`, but `notexample.com`
268/// and a bare `example.com` do not). Shared by the harness and egress
269/// suffix matchers so the boundary rule stays identical.
270pub(crate) fn host_has_dns_suffix(host: &str, suffix: &str) -> bool {
271    host.len() > suffix.len()
272        && host.ends_with(suffix)
273        && host.as_bytes().get(host.len() - suffix.len() - 1) == Some(&b'.')
274}
275
276impl NetPolicy {
277    /// Resolve the decision for a single request. The caller decides
278    /// what to do with the audit + quarantine signal — see the
279    /// dispatcher in `vm::methods::harness`.
280    pub fn evaluate(&self, method: &str, raw_url: &str) -> Result<NetPolicyDecision, VmError> {
281        let target = NetTarget::parse(raw_url)?;
282        if let Some(rule) = self.deny.iter().find(|rule| rule.matches(&target)) {
283            return Ok(self.deny_decision(
284                method,
285                raw_url,
286                &target,
287                format!("matched deny rule `{}`", rule.raw),
288                Some(rule.raw.clone()),
289            ));
290        }
291        if let Some(rule) = self.allow.iter().find(|rule| rule.matches(&target)) {
292            return Ok(NetPolicyDecision::Allow {
293                audited: false,
294                audit: Some(NetPolicyAudit {
295                    method: method.to_string(),
296                    url: raw_url.to_string(),
297                    host: target.host,
298                    port: target.port,
299                    reason: format!("matched allow rule `{}`", rule.raw),
300                    outcome: "allow",
301                    bypass: false,
302                    matched_rule: Some(rule.raw.clone()),
303                }),
304            });
305        }
306        if self.default == NetPolicyDefault::Allow {
307            return Ok(NetPolicyDecision::Allow {
308                audited: false,
309                audit: None,
310            });
311        }
312        Ok(self.deny_decision(
313            method,
314            raw_url,
315            &target,
316            "no allow rule matched (default deny)".to_string(),
317            None,
318        ))
319    }
320
321    fn deny_decision(
322        &self,
323        method: &str,
324        raw_url: &str,
325        target: &NetTarget,
326        reason: String,
327        matched_rule: Option<String>,
328    ) -> NetPolicyDecision {
329        match &self.on_violation {
330            OnViolation::Error => NetPolicyDecision::Deny {
331                audit: NetPolicyAudit {
332                    method: method.to_string(),
333                    url: raw_url.to_string(),
334                    host: target.host.clone(),
335                    port: target.port,
336                    reason,
337                    outcome: "error",
338                    bypass: false,
339                    matched_rule,
340                },
341                quarantine: false,
342            },
343            OnViolation::AuditOnly => NetPolicyDecision::Allow {
344                audited: true,
345                audit: Some(NetPolicyAudit {
346                    method: method.to_string(),
347                    url: raw_url.to_string(),
348                    host: target.host.clone(),
349                    port: target.port,
350                    reason,
351                    outcome: "audit_only",
352                    bypass: false,
353                    matched_rule,
354                }),
355            },
356            OnViolation::Quarantine => NetPolicyDecision::Deny {
357                audit: NetPolicyAudit {
358                    method: method.to_string(),
359                    url: raw_url.to_string(),
360                    host: target.host.clone(),
361                    port: target.port,
362                    reason,
363                    outcome: "quarantine",
364                    bypass: false,
365                    matched_rule,
366                },
367                quarantine: true,
368            },
369            // Callback resolution happens in the dispatcher because it
370            // needs the VM to invoke the closure. The default deny
371            // shape carries the audit; the dispatcher overrides
372            // `outcome` after the callback returns.
373            OnViolation::Callback(_) => NetPolicyDecision::Deny {
374                audit: NetPolicyAudit {
375                    method: method.to_string(),
376                    url: raw_url.to_string(),
377                    host: target.host.clone(),
378                    port: target.port,
379                    reason,
380                    outcome: "callback",
381                    bypass: false,
382                    matched_rule,
383                },
384                quarantine: false,
385            },
386        }
387    }
388}
389
390/// Construct the typed VM error returned to callers when a request is
391/// denied. Mirrors the shape of `crate::egress::EgressBlocked` so
392/// hosts can route on either consistently.
393pub fn violation_vm_error(audit: &NetPolicyAudit) -> VmError {
394    let mut dict = BTreeMap::new();
395    dict.put_str("type", "NetPolicyViolation");
396    dict.put_str("category", "net_policy_violation");
397    dict.put_str(
398        "message",
399        format!(
400            "harness.net.{} blocked {}: {}",
401            audit.method, audit.url, audit.reason
402        ),
403    );
404    dict.put_str("method", audit.method.as_str());
405    dict.put_str("url", audit.url.as_str());
406    dict.put_str("host", audit.host.as_str());
407    dict.insert(
408        "port".to_string(),
409        audit
410            .port
411            .map(|port| VmValue::Int(port as i64))
412            .unwrap_or(VmValue::Nil),
413    );
414    dict.put_str("reason", audit.reason.as_str());
415    dict.put_str("outcome", audit.outcome);
416    dict.insert(
417        "matched_rule".to_string(),
418        audit
419            .matched_rule
420            .as_deref()
421            .map(|raw| VmValue::String(arcstr::ArcStr::from(raw)))
422            .unwrap_or(VmValue::Nil),
423    );
424    if audit.bypass {
425        dict.insert("bypass".to_string(), VmValue::Bool(true));
426    }
427    VmError::Thrown(VmValue::dict(dict))
428}
429
430/// Build the request envelope handed to the user `on_violation`
431/// callback. Plain dict so the script can index with the usual
432/// optional-chaining and `?.` syntax.
433pub fn violation_request_value(audit: &NetPolicyAudit) -> VmValue {
434    let mut dict = BTreeMap::new();
435    dict.put_str("method", audit.method.as_str());
436    dict.put_str("url", audit.url.as_str());
437    dict.put_str("host", audit.host.as_str());
438    dict.insert(
439        "port".to_string(),
440        audit
441            .port
442            .map(|port| VmValue::Int(port as i64))
443            .unwrap_or(VmValue::Nil),
444    );
445    dict.put_str("reason", audit.reason.as_str());
446    dict.insert(
447        "matched_rule".to_string(),
448        audit
449            .matched_rule
450            .as_deref()
451            .map(|raw| VmValue::String(arcstr::ArcStr::from(raw)))
452            .unwrap_or(VmValue::Nil),
453    );
454    VmValue::dict(dict)
455}
456
457/// Emit a `harness.net.policy.audit` event to the active event log,
458/// if any. Returns silently when no event log is bound so unit tests
459/// that bypass the full runtime still exercise the matcher.
460pub async fn record_audit(audit: &NetPolicyAudit) {
461    let Some(log) = active_event_log() else {
462        return;
463    };
464    let Ok(topic) = Topic::new(NET_POLICY_AUDIT_TOPIC) else {
465        return;
466    };
467    let _ = log
468        .append(
469            &topic,
470            LogEvent::new("net.policy.evaluated", audit.to_json()),
471        )
472        .await;
473}
474
475/// Returns true when the bypass env var is set to a truthy value. The
476/// dispatcher still records the bypass with `bypass: true` so the
477/// audit trail keeps a record of the leak.
478pub fn bypass_enabled() -> bool {
479    match std::env::var(HARN_NET_POLICY_BYPASS_ENV) {
480        Ok(value) => matches!(
481            value.trim().to_ascii_lowercase().as_str(),
482            "1" | "true" | "yes" | "on"
483        ),
484        Err(_) => false,
485    }
486}
487
488fn normalize_host(host: &str) -> String {
489    host.trim()
490        .trim_end_matches('.')
491        .trim_matches('[')
492        .trim_matches(']')
493        .to_ascii_lowercase()
494}
495
496fn vm_error(message: impl Into<String>) -> VmError {
497    VmError::Thrown(VmValue::String(arcstr::ArcStr::from(message.into())))
498}
499
500/// VM-side helpers used by both the constructor builtins
501/// (`crate::stdlib::net_policy`) and the dispatcher when it accepts
502/// a `with_net_policy({...})` shorthand dict.
503pub mod parse {
504    use super::*;
505
506    /// Sentinel key used to recognise a tagged-dict policy rule.
507    pub const RULE_TAG_KEY: &str = "__net_policy_rule";
508    /// Sentinel key used to recognise a tagged-dict policy value.
509    pub const POLICY_TAG_KEY: &str = "__net_policy";
510
511    /// Inspect a `VmValue` and lift it into a `NetPolicyRule`. Accepts
512    /// the tagged-dict shape produced by the constructor builtins as
513    /// well as bare strings interpreted as `domain` (or
514    /// `domain_wildcard` when they start with `*.`).
515    pub fn rule_from_vm(value: &VmValue) -> Result<NetPolicyRule, VmError> {
516        match value {
517            VmValue::Dict(dict) => rule_from_dict(dict),
518            VmValue::String(raw) => {
519                let raw = raw.as_str();
520                if raw.starts_with("*.") {
521                    NetPolicyRule::parse_domain_wildcard(raw)
522                } else if raw.contains('/') {
523                    NetPolicyRule::parse_cidr(raw)
524                } else {
525                    NetPolicyRule::parse_domain(raw)
526                }
527            }
528            other => Err(vm_error(format!(
529                "NetPolicy: rule must be a tagged dict or string, got {}",
530                other.type_name()
531            ))),
532        }
533    }
534
535    fn rule_from_dict(dict: &crate::value::DictMap) -> Result<NetPolicyRule, VmError> {
536        let tag = dict
537            .get(RULE_TAG_KEY)
538            .and_then(|v| match v {
539                VmValue::String(s) => Some(s.to_string()),
540                _ => None,
541            })
542            .ok_or_else(|| {
543                vm_error(
544                    "NetPolicy: rule dict is missing the `__net_policy_rule` tag; build rules via NetPolicy.domain/.domain_wildcard/.cidr/.host",
545                )
546            })?;
547        match tag.as_str() {
548            "domain" => {
549                let host = require_string(dict, "host", "NetPolicy.domain")?;
550                NetPolicyRule::parse_domain(&host)
551            }
552            "domain_wildcard" => {
553                let pattern = require_string(dict, "pattern", "NetPolicy.domain_wildcard")?;
554                NetPolicyRule::parse_domain_wildcard(&pattern)
555            }
556            "cidr" => {
557                let range = require_string(dict, "range", "NetPolicy.cidr")?;
558                NetPolicyRule::parse_cidr(&range)
559            }
560            "host" => {
561                let host = require_string(dict, "host", "NetPolicy.host")?;
562                let ports = match dict.get("ports") {
563                    Some(VmValue::List(list)) => {
564                        let mut parsed = Vec::with_capacity(list.len());
565                        for value in list.iter() {
566                            let port = value
567                                .as_int()
568                                .and_then(|n| u16::try_from(n).ok())
569                                .ok_or_else(|| {
570                                    vm_error("NetPolicy.host: ports must be a list of u16 integers")
571                                })?;
572                            parsed.push(port);
573                        }
574                        Some(parsed)
575                    }
576                    Some(VmValue::Nil) | None => None,
577                    Some(_) => {
578                        return Err(vm_error(
579                            "NetPolicy.host: ports must be a list of u16 integers",
580                        ))
581                    }
582                };
583                NetPolicyRule::parse_host(&host, ports)
584            }
585            other => Err(vm_error(format!("NetPolicy: unknown rule kind `{other}`"))),
586        }
587    }
588
589    fn require_string(
590        dict: &crate::value::DictMap,
591        key: &str,
592        callee: &str,
593    ) -> Result<String, VmError> {
594        match dict.get(key) {
595            Some(VmValue::String(s)) => Ok(s.as_str().to_string()),
596            Some(other) => Err(vm_error(format!(
597                "{callee}: `{key}` must be a string, got {}",
598                other.type_name()
599            ))),
600            None => Err(vm_error(format!("{callee}: missing `{key}` field"))),
601        }
602    }
603
604    /// Build a `NetPolicy` value from the `{allow, deny, default,
605    /// on_violation}` dict produced by `NetPolicy.create(...)`.
606    pub fn policy_from_dict(dict: &crate::value::DictMap) -> Result<NetPolicy, VmError> {
607        let allow = parse_rule_list(dict.get("allow"), "allow")?;
608        let deny = parse_rule_list(dict.get("deny"), "deny")?;
609        let default = match dict.get("default") {
610            Some(VmValue::String(s)) => NetPolicyDefault::parse(s.as_str())?,
611            Some(VmValue::Nil) | None => NetPolicyDefault::Deny,
612            Some(other) => {
613                return Err(vm_error(format!(
614                    "NetPolicy.create: default must be a string, got {}",
615                    other.type_name()
616                )))
617            }
618        };
619        let on_violation = match dict.get("on_violation") {
620            Some(VmValue::String(s)) => OnViolation::parse_str(s.as_str())?,
621            Some(VmValue::Closure(closure)) => OnViolation::Callback(Arc::clone(closure)),
622            Some(VmValue::Nil) | None => OnViolation::Error,
623            Some(other) => {
624                return Err(vm_error(format!(
625                    "NetPolicy.create: on_violation must be a string or callback, got {}",
626                    other.type_name()
627                )))
628            }
629        };
630        Ok(NetPolicy {
631            allow: Arc::new(allow),
632            deny: Arc::new(deny),
633            default,
634            on_violation,
635        })
636    }
637
638    fn parse_rule_list(value: Option<&VmValue>, side: &str) -> Result<Vec<NetPolicyRule>, VmError> {
639        match value {
640            None | Some(VmValue::Nil) => Ok(Vec::new()),
641            Some(VmValue::List(items)) => items.iter().map(rule_from_vm).collect(),
642            Some(other) => Err(vm_error(format!(
643                "NetPolicy.create: `{side}` must be a list, got {}",
644                other.type_name()
645            ))),
646        }
647    }
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653
654    fn rule(raw: &str, ports: Option<Vec<u16>>) -> NetPolicyRule {
655        NetPolicyRule::parse_host(raw, ports).expect("rule parses")
656    }
657
658    fn cidr(raw: &str) -> NetPolicyRule {
659        NetPolicyRule::parse_cidr(raw).expect("cidr parses")
660    }
661
662    fn build(
663        allow: Vec<NetPolicyRule>,
664        deny: Vec<NetPolicyRule>,
665        default: NetPolicyDefault,
666    ) -> NetPolicy {
667        NetPolicy {
668            allow: Arc::new(allow),
669            deny: Arc::new(deny),
670            default,
671            on_violation: OnViolation::Error,
672        }
673    }
674
675    #[test]
676    fn exact_host_match_allows() {
677        let policy = build(
678            vec![rule("github.com", None)],
679            Vec::new(),
680            NetPolicyDefault::Deny,
681        );
682        let decision = policy
683            .evaluate("get", "https://github.com/foo")
684            .expect("evaluates");
685        assert!(matches!(decision, NetPolicyDecision::Allow { .. }));
686    }
687
688    #[test]
689    fn wildcard_does_not_match_bare_apex() {
690        let policy = build(
691            vec![NetPolicyRule::parse_domain_wildcard("*.github.com").unwrap()],
692            Vec::new(),
693            NetPolicyDefault::Deny,
694        );
695        let allow = policy.evaluate("get", "https://api.github.com/x").unwrap();
696        assert!(matches!(allow, NetPolicyDecision::Allow { .. }));
697        let deny = policy.evaluate("get", "https://github.com/x").unwrap();
698        assert!(matches!(deny, NetPolicyDecision::Deny { .. }));
699    }
700
701    #[test]
702    fn cidr_matches_ip_literal() {
703        let policy = build(vec![cidr("10.0.0.0/8")], Vec::new(), NetPolicyDefault::Deny);
704        let allowed = policy.evaluate("get", "http://10.5.5.5/x").unwrap();
705        assert!(matches!(allowed, NetPolicyDecision::Allow { .. }));
706        let denied = policy.evaluate("get", "http://192.168.1.1/x").unwrap();
707        assert!(matches!(denied, NetPolicyDecision::Deny { .. }));
708    }
709
710    #[test]
711    fn host_port_rule_requires_matching_port() {
712        let policy = build(
713            vec![rule("api.anthropic.com", Some(vec![443]))],
714            Vec::new(),
715            NetPolicyDefault::Deny,
716        );
717        let allow = policy
718            .evaluate("get", "https://api.anthropic.com/v1/messages")
719            .unwrap();
720        assert!(matches!(allow, NetPolicyDecision::Allow { .. }));
721        let deny = policy
722            .evaluate("get", "http://api.anthropic.com/v1/messages")
723            .unwrap();
724        assert!(matches!(deny, NetPolicyDecision::Deny { .. }));
725    }
726
727    #[test]
728    fn deny_overrides_allow() {
729        let policy = build(
730            vec![NetPolicyRule::parse_domain_wildcard("*.github.com").unwrap()],
731            vec![rule("evil.github.com", None)],
732            NetPolicyDefault::Deny,
733        );
734        let decision = policy.evaluate("get", "https://evil.github.com/x").unwrap();
735        match decision {
736            NetPolicyDecision::Deny { audit, .. } => {
737                assert!(audit.reason.contains("deny rule"));
738            }
739            other => panic!("expected deny, got {other:?}"),
740        }
741    }
742
743    #[test]
744    fn default_allow_lets_unmatched_through() {
745        let policy = build(Vec::new(), Vec::new(), NetPolicyDefault::Allow);
746        let allow = policy.evaluate("get", "https://example.test/x").unwrap();
747        assert!(matches!(allow, NetPolicyDecision::Allow { .. }));
748    }
749
750    #[test]
751    fn audit_only_allows_but_carries_audit() {
752        let mut policy = build(Vec::new(), Vec::new(), NetPolicyDefault::Deny);
753        policy.on_violation = OnViolation::AuditOnly;
754        let decision = policy
755            .evaluate("get", "https://blocked.test/x")
756            .expect("evaluates");
757        match decision {
758            NetPolicyDecision::Allow { audited, audit } => {
759                assert!(audited);
760                let audit = audit.expect("audit attached");
761                assert_eq!(audit.outcome, "audit_only");
762                assert_eq!(audit.host, "blocked.test");
763            }
764            other => panic!("expected audit_only allow, got {other:?}"),
765        }
766    }
767
768    #[test]
769    fn quarantine_denies_with_signal() {
770        let mut policy = build(Vec::new(), Vec::new(), NetPolicyDefault::Deny);
771        policy.on_violation = OnViolation::Quarantine;
772        match policy
773            .evaluate("get", "https://blocked.test/x")
774            .expect("evaluates")
775        {
776            NetPolicyDecision::Deny { audit, quarantine } => {
777                assert!(quarantine);
778                assert_eq!(audit.outcome, "quarantine");
779            }
780            other => panic!("expected quarantine deny, got {other:?}"),
781        }
782    }
783
784    #[test]
785    fn invalid_url_surfaces_typed_error() {
786        let policy = build(Vec::new(), Vec::new(), NetPolicyDefault::Deny);
787        let err = policy.evaluate("get", "not a url").unwrap_err();
788        match err {
789            VmError::Thrown(VmValue::String(s)) => {
790                assert!(s.contains("invalid URL"), "unexpected error: {s}");
791            }
792            other => panic!("expected Thrown, got {other:?}"),
793        }
794    }
795
796    #[test]
797    fn parse_string_rule_branches_on_shape() {
798        let domain =
799            parse::rule_from_vm(&VmValue::String(arcstr::ArcStr::from("github.com"))).unwrap();
800        assert!(matches!(domain.matcher, NetMatcher::Host(_)));
801        let wildcard =
802            parse::rule_from_vm(&VmValue::String(arcstr::ArcStr::from("*.github.com"))).unwrap();
803        assert!(matches!(wildcard.matcher, NetMatcher::Suffix(_)));
804        let cidr_rule =
805            parse::rule_from_vm(&VmValue::String(arcstr::ArcStr::from("10.0.0.0/8"))).unwrap();
806        assert!(matches!(cidr_rule.matcher, NetMatcher::Cidr(_)));
807    }
808
809    #[test]
810    fn bypass_env_recognised() {
811        let original = std::env::var(HARN_NET_POLICY_BYPASS_ENV).ok();
812        std::env::set_var(HARN_NET_POLICY_BYPASS_ENV, "1");
813        assert!(bypass_enabled());
814        std::env::set_var(HARN_NET_POLICY_BYPASS_ENV, "0");
815        assert!(!bypass_enabled());
816        match original {
817            Some(value) => std::env::set_var(HARN_NET_POLICY_BYPASS_ENV, value),
818            None => std::env::remove_var(HARN_NET_POLICY_BYPASS_ENV),
819        }
820    }
821}