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