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
158/// How one `HarnessNet` method relates to destination attenuation.
159///
160/// `NetPolicy` directly gates operations whose call contract exposes a URL.
161/// The remaining methods operate on local state/already-authorized handles, or
162/// (for connector calls) do not expose their destination at this method seam.
163/// Treating arg0 of those calls as a URL would misclassify a handle, cookie
164/// name, provider name, or local path as an HTTP destination.
165#[derive(Clone, Copy, Debug, Eq, PartialEq)]
166pub(crate) enum NetPolicyMethodContract {
167    OutboundUrl { argument: usize },
168    NotUrlAddressed,
169}
170
171impl NetPolicyMethodContract {
172    pub(crate) fn for_method(method: &str) -> Option<Self> {
173        use NetPolicyMethodContract::{NotUrlAddressed, OutboundUrl};
174
175        Some(match method {
176            "get" | "post" | "put" | "patch" | "delete" | "download" | "stream_open"
177            | "jsonrpc_call" | "jsonrpc_batch" | "websocket_connect" => OutboundUrl { argument: 0 },
178            "request" | "sse_connect" => OutboundUrl { argument: 1 },
179            "session_request" => OutboundUrl { argument: 2 },
180            "egress_policy"
181            | "connector_call"
182            | "cookie_delete"
183            | "stream_read"
184            | "stream_info"
185            | "stream_close"
186            | "session"
187            | "session_close"
188            | "server"
189            | "server_route"
190            | "server_before"
191            | "server_after"
192            | "server_request"
193            | "server_test"
194            | "server_set_ready"
195            | "server_readiness"
196            | "server_ready"
197            | "server_on_shutdown"
198            | "server_shutdown"
199            | "server_tls_plain"
200            | "server_tls_edge"
201            | "server_tls_pem"
202            | "server_tls_self_signed_dev"
203            | "server_security_headers"
204            | "unix_socket_json_request"
205            | "sse_receive"
206            | "sse_close"
207            | "sse_server_response"
208            | "sse_server_send"
209            | "sse_server_heartbeat"
210            | "sse_server_flush"
211            | "sse_server_close"
212            | "sse_server_cancel"
213            | "sse_server_status"
214            | "sse_server_disconnected"
215            | "sse_server_cancelled"
216            | "websocket_server"
217            | "websocket_route"
218            | "websocket_accept"
219            | "websocket_send"
220            | "websocket_receive"
221            | "websocket_close"
222            | "websocket_server_close" => NotUrlAddressed,
223            _ => return None,
224        })
225    }
226
227    pub(crate) fn url(self, args: &[VmValue]) -> Option<&str> {
228        let Self::OutboundUrl { argument } = self else {
229            return None;
230        };
231        match args.get(argument) {
232            Some(VmValue::String(url)) => Some(url.as_str()),
233            _ => None,
234        }
235    }
236}
237
238impl NetPolicyAudit {
239    fn to_json(&self) -> serde_json::Value {
240        json!({
241            "method": self.method,
242            "url": self.url,
243            "host": self.host,
244            "port": self.port,
245            "reason": self.reason,
246            "outcome": self.outcome,
247            "bypass": self.bypass,
248            "matched_rule": self.matched_rule,
249        })
250    }
251}
252
253#[derive(Clone, Debug)]
254pub struct NetTarget {
255    pub host: String,
256    pub ip: Option<IpAddr>,
257    pub port: Option<u16>,
258}
259
260impl NetTarget {
261    pub fn parse(raw_url: &str) -> Result<Self, VmError> {
262        let parsed = Url::parse(raw_url)
263            .map_err(|error| vm_error(format!("harness.net: invalid URL `{raw_url}`: {error}")))?;
264        let host = parsed.host_str().ok_or_else(|| {
265            vm_error(format!(
266                "harness.net: URL `{raw_url}` does not include a host"
267            ))
268        })?;
269        let host = normalize_host(host);
270        let ip = IpAddr::from_str(&host).ok();
271        Ok(Self {
272            host,
273            ip,
274            port: parsed.port_or_known_default(),
275        })
276    }
277}
278
279impl NetPolicyRule {
280    pub fn parse_host(raw: &str, ports: Option<Vec<u16>>) -> Result<Self, VmError> {
281        let raw = raw.trim();
282        if raw.is_empty() {
283            return Err(vm_error("NetPolicy.host: empty host"));
284        }
285        let host = normalize_host(raw);
286        let matcher = if let Some(suffix) = host.strip_prefix("*.") {
287            if suffix.is_empty() {
288                return Err(vm_error(format!(
289                    "NetPolicy.domain_wildcard: invalid wildcard `{raw}`"
290                )));
291            }
292            NetMatcher::Suffix(suffix.to_string())
293        } else if let Ok(ip) = IpAddr::from_str(&host) {
294            NetMatcher::Ip(ip)
295        } else {
296            NetMatcher::Host(host)
297        };
298        Ok(Self {
299            raw: raw.to_string(),
300            matcher,
301            ports,
302        })
303    }
304
305    pub fn parse_domain(raw: &str) -> Result<Self, VmError> {
306        Self::parse_host(raw, None)
307    }
308
309    pub fn parse_domain_wildcard(raw: &str) -> Result<Self, VmError> {
310        let trimmed = raw.trim();
311        if !trimmed.starts_with("*.") {
312            return Err(vm_error(format!(
313                "NetPolicy.domain_wildcard: pattern must start with `*.`, got `{raw}`"
314            )));
315        }
316        Self::parse_host(trimmed, None)
317    }
318
319    pub fn parse_cidr(raw: &str) -> Result<Self, VmError> {
320        let trimmed = raw.trim();
321        let net = IpNet::from_str(trimmed)
322            .map_err(|error| vm_error(format!("NetPolicy.cidr: invalid CIDR `{raw}`: {error}")))?;
323        Ok(Self {
324            raw: trimmed.to_string(),
325            matcher: NetMatcher::Cidr(net),
326            ports: None,
327        })
328    }
329
330    pub fn matches(&self, target: &NetTarget) -> bool {
331        if let Some(ports) = &self.ports {
332            match target.port {
333                Some(port) if ports.contains(&port) => {}
334                _ => return false,
335            }
336        }
337        match &self.matcher {
338            NetMatcher::Host(host) => target.host == *host,
339            NetMatcher::Suffix(suffix) => host_has_dns_suffix(&target.host, suffix),
340            NetMatcher::Ip(ip) => target.ip == Some(*ip),
341            NetMatcher::Cidr(net) => target.ip.is_some_and(|ip| net.contains(&ip)),
342        }
343    }
344}
345
346/// True when `host` is a strict dot-delimited subdomain of `suffix`
347/// (e.g. `api.example.com` matches suffix `example.com`, but `notexample.com`
348/// and a bare `example.com` do not). Shared by the harness and egress
349/// suffix matchers so the boundary rule stays identical.
350pub(crate) fn host_has_dns_suffix(host: &str, suffix: &str) -> bool {
351    host.len() > suffix.len()
352        && host.ends_with(suffix)
353        && host.as_bytes().get(host.len() - suffix.len() - 1) == Some(&b'.')
354}
355
356impl NetPolicy {
357    /// Resolve the decision for a single request. The caller decides
358    /// what to do with the audit + quarantine signal — see the
359    /// dispatcher in `vm::methods::harness`.
360    pub fn evaluate(&self, method: &str, raw_url: &str) -> Result<NetPolicyDecision, VmError> {
361        let target = NetTarget::parse(raw_url)?;
362        if let Some(rule) = self.deny.iter().find(|rule| rule.matches(&target)) {
363            return Ok(self.deny_decision(
364                method,
365                raw_url,
366                &target,
367                format!("matched deny rule `{}`", rule.raw),
368                Some(rule.raw.clone()),
369            ));
370        }
371        if let Some(rule) = self.allow.iter().find(|rule| rule.matches(&target)) {
372            return Ok(NetPolicyDecision::Allow {
373                audited: false,
374                audit: Some(NetPolicyAudit {
375                    method: method.to_string(),
376                    url: raw_url.to_string(),
377                    host: target.host,
378                    port: target.port,
379                    reason: format!("matched allow rule `{}`", rule.raw),
380                    outcome: "allow",
381                    bypass: false,
382                    matched_rule: Some(rule.raw.clone()),
383                }),
384            });
385        }
386        if self.default == NetPolicyDefault::Allow {
387            return Ok(NetPolicyDecision::Allow {
388                audited: false,
389                audit: None,
390            });
391        }
392        Ok(self.deny_decision(
393            method,
394            raw_url,
395            &target,
396            "no allow rule matched (default deny)".to_string(),
397            None,
398        ))
399    }
400
401    fn deny_decision(
402        &self,
403        method: &str,
404        raw_url: &str,
405        target: &NetTarget,
406        reason: String,
407        matched_rule: Option<String>,
408    ) -> NetPolicyDecision {
409        match &self.on_violation {
410            OnViolation::Error => NetPolicyDecision::Deny {
411                audit: NetPolicyAudit {
412                    method: method.to_string(),
413                    url: raw_url.to_string(),
414                    host: target.host.clone(),
415                    port: target.port,
416                    reason,
417                    outcome: "error",
418                    bypass: false,
419                    matched_rule,
420                },
421                quarantine: false,
422            },
423            OnViolation::AuditOnly => NetPolicyDecision::Allow {
424                audited: true,
425                audit: Some(NetPolicyAudit {
426                    method: method.to_string(),
427                    url: raw_url.to_string(),
428                    host: target.host.clone(),
429                    port: target.port,
430                    reason,
431                    outcome: "audit_only",
432                    bypass: false,
433                    matched_rule,
434                }),
435            },
436            OnViolation::Quarantine => NetPolicyDecision::Deny {
437                audit: NetPolicyAudit {
438                    method: method.to_string(),
439                    url: raw_url.to_string(),
440                    host: target.host.clone(),
441                    port: target.port,
442                    reason,
443                    outcome: "quarantine",
444                    bypass: false,
445                    matched_rule,
446                },
447                quarantine: true,
448            },
449            // Callback resolution happens in the dispatcher because it
450            // needs the VM to invoke the closure. The default deny
451            // shape carries the audit; the dispatcher overrides
452            // `outcome` after the callback returns.
453            OnViolation::Callback(_) => NetPolicyDecision::Deny {
454                audit: NetPolicyAudit {
455                    method: method.to_string(),
456                    url: raw_url.to_string(),
457                    host: target.host.clone(),
458                    port: target.port,
459                    reason,
460                    outcome: "callback",
461                    bypass: false,
462                    matched_rule,
463                },
464                quarantine: false,
465            },
466        }
467    }
468}
469
470/// Construct the typed VM error returned to callers when a request is
471/// denied. Mirrors the shape of `crate::egress::EgressBlocked` so
472/// hosts can route on either consistently.
473pub fn violation_vm_error(audit: &NetPolicyAudit) -> VmError {
474    let mut dict = BTreeMap::new();
475    dict.put_str("type", "NetPolicyViolation");
476    dict.put_str("category", "net_policy_violation");
477    dict.put_str(
478        "message",
479        format!(
480            "harness.net.{} blocked {}: {}",
481            audit.method, audit.url, audit.reason
482        ),
483    );
484    dict.put_str("method", audit.method.as_str());
485    dict.put_str("url", audit.url.as_str());
486    dict.put_str("host", audit.host.as_str());
487    dict.insert(
488        "port".to_string(),
489        audit
490            .port
491            .map(|port| VmValue::Int(port as i64))
492            .unwrap_or(VmValue::Nil),
493    );
494    dict.put_str("reason", audit.reason.as_str());
495    dict.put_str("outcome", audit.outcome);
496    dict.insert(
497        "matched_rule".to_string(),
498        audit
499            .matched_rule
500            .as_deref()
501            .map(|raw| VmValue::String(arcstr::ArcStr::from(raw)))
502            .unwrap_or(VmValue::Nil),
503    );
504    if audit.bypass {
505        dict.insert("bypass".to_string(), VmValue::Bool(true));
506    }
507    VmError::Thrown(VmValue::dict(dict))
508}
509
510/// Build the request envelope handed to the user `on_violation`
511/// callback. Plain dict so the script can index with the usual
512/// optional-chaining and `?.` syntax.
513pub fn violation_request_value(audit: &NetPolicyAudit) -> VmValue {
514    let mut dict = BTreeMap::new();
515    dict.put_str("method", audit.method.as_str());
516    dict.put_str("url", audit.url.as_str());
517    dict.put_str("host", audit.host.as_str());
518    dict.insert(
519        "port".to_string(),
520        audit
521            .port
522            .map(|port| VmValue::Int(port as i64))
523            .unwrap_or(VmValue::Nil),
524    );
525    dict.put_str("reason", audit.reason.as_str());
526    dict.insert(
527        "matched_rule".to_string(),
528        audit
529            .matched_rule
530            .as_deref()
531            .map(|raw| VmValue::String(arcstr::ArcStr::from(raw)))
532            .unwrap_or(VmValue::Nil),
533    );
534    VmValue::dict(dict)
535}
536
537/// Emit a `harness.net.policy.audit` event to the active event log,
538/// if any. Returns silently when no event log is bound so unit tests
539/// that bypass the full runtime still exercise the matcher.
540pub async fn record_audit(audit: &NetPolicyAudit) {
541    let Some(log) = active_event_log() else {
542        return;
543    };
544    let Ok(topic) = Topic::new(NET_POLICY_AUDIT_TOPIC) else {
545        return;
546    };
547    let _ = log
548        .append(
549            &topic,
550            LogEvent::new("net.policy.evaluated", audit.to_json()),
551        )
552        .await;
553}
554
555/// Returns true when the bypass env var is set to a truthy value. The
556/// dispatcher still records the bypass with `bypass: true` so the
557/// audit trail keeps a record of the leak.
558pub fn bypass_enabled() -> bool {
559    match std::env::var(HARN_NET_POLICY_BYPASS_ENV) {
560        Ok(value) => matches!(
561            value.trim().to_ascii_lowercase().as_str(),
562            "1" | "true" | "yes" | "on"
563        ),
564        Err(_) => false,
565    }
566}
567
568fn normalize_host(host: &str) -> String {
569    host.trim()
570        .trim_end_matches('.')
571        .trim_matches('[')
572        .trim_matches(']')
573        .to_ascii_lowercase()
574}
575
576fn vm_error(message: impl Into<String>) -> VmError {
577    VmError::Thrown(VmValue::String(arcstr::ArcStr::from(message.into())))
578}
579
580/// VM-side helpers used by both the constructor builtins
581/// (`crate::stdlib::net_policy`) and the dispatcher when it accepts
582/// a `with_net_policy({...})` shorthand dict.
583pub mod parse {
584    use super::*;
585
586    /// Sentinel key used to recognise a tagged-dict policy rule.
587    pub const RULE_TAG_KEY: &str = "__net_policy_rule";
588    /// Sentinel key used to recognise a tagged-dict policy value.
589    pub const POLICY_TAG_KEY: &str = "__net_policy";
590
591    /// Inspect a `VmValue` and lift it into a `NetPolicyRule`. Accepts
592    /// the tagged-dict shape produced by the constructor builtins as
593    /// well as bare strings interpreted as `domain` (or
594    /// `domain_wildcard` when they start with `*.`).
595    pub fn rule_from_vm(value: &VmValue) -> Result<NetPolicyRule, VmError> {
596        match value {
597            VmValue::Dict(dict) => rule_from_dict(dict),
598            VmValue::String(raw) => {
599                let raw = raw.as_str();
600                if raw.starts_with("*.") {
601                    NetPolicyRule::parse_domain_wildcard(raw)
602                } else if raw.contains('/') {
603                    NetPolicyRule::parse_cidr(raw)
604                } else {
605                    NetPolicyRule::parse_domain(raw)
606                }
607            }
608            other => Err(vm_error(format!(
609                "NetPolicy: rule must be a tagged dict or string, got {}",
610                other.type_name()
611            ))),
612        }
613    }
614
615    fn rule_from_dict(dict: &crate::value::DictMap) -> Result<NetPolicyRule, VmError> {
616        let tag = dict
617            .get(RULE_TAG_KEY)
618            .and_then(|v| match v {
619                VmValue::String(s) => Some(s.to_string()),
620                _ => None,
621            })
622            .ok_or_else(|| {
623                vm_error(
624                    "NetPolicy: rule dict is missing the `__net_policy_rule` tag; build rules via NetPolicy.domain/.domain_wildcard/.cidr/.host",
625                )
626            })?;
627        match tag.as_str() {
628            "domain" => {
629                let host = require_string(dict, "host", "NetPolicy.domain")?;
630                NetPolicyRule::parse_domain(&host)
631            }
632            "domain_wildcard" => {
633                let pattern = require_string(dict, "pattern", "NetPolicy.domain_wildcard")?;
634                NetPolicyRule::parse_domain_wildcard(&pattern)
635            }
636            "cidr" => {
637                let range = require_string(dict, "range", "NetPolicy.cidr")?;
638                NetPolicyRule::parse_cidr(&range)
639            }
640            "host" => {
641                let host = require_string(dict, "host", "NetPolicy.host")?;
642                let ports = match dict.get("ports") {
643                    Some(VmValue::List(list)) => {
644                        let mut parsed = Vec::with_capacity(list.len());
645                        for value in list.iter() {
646                            let port = value
647                                .as_int()
648                                .and_then(|n| u16::try_from(n).ok())
649                                .ok_or_else(|| {
650                                    vm_error("NetPolicy.host: ports must be a list of u16 integers")
651                                })?;
652                            parsed.push(port);
653                        }
654                        Some(parsed)
655                    }
656                    Some(VmValue::Nil) | None => None,
657                    Some(_) => {
658                        return Err(vm_error(
659                            "NetPolicy.host: ports must be a list of u16 integers",
660                        ))
661                    }
662                };
663                NetPolicyRule::parse_host(&host, ports)
664            }
665            other => Err(vm_error(format!("NetPolicy: unknown rule kind `{other}`"))),
666        }
667    }
668
669    fn require_string(
670        dict: &crate::value::DictMap,
671        key: &str,
672        callee: &str,
673    ) -> Result<String, VmError> {
674        match dict.get(key) {
675            Some(VmValue::String(s)) => Ok(s.as_str().to_string()),
676            Some(other) => Err(vm_error(format!(
677                "{callee}: `{key}` must be a string, got {}",
678                other.type_name()
679            ))),
680            None => Err(vm_error(format!("{callee}: missing `{key}` field"))),
681        }
682    }
683
684    /// Build a `NetPolicy` value from the `{allow, deny, default,
685    /// on_violation}` dict produced by `NetPolicy.create(...)`.
686    pub fn policy_from_dict(dict: &crate::value::DictMap) -> Result<NetPolicy, VmError> {
687        let allow = parse_rule_list(dict.get("allow"), "allow")?;
688        let deny = parse_rule_list(dict.get("deny"), "deny")?;
689        let default = match dict.get("default") {
690            Some(VmValue::String(s)) => NetPolicyDefault::parse(s.as_str())?,
691            Some(VmValue::Nil) | None => NetPolicyDefault::Deny,
692            Some(other) => {
693                return Err(vm_error(format!(
694                    "NetPolicy.create: default must be a string, got {}",
695                    other.type_name()
696                )))
697            }
698        };
699        let on_violation = match dict.get("on_violation") {
700            Some(VmValue::String(s)) => OnViolation::parse_str(s.as_str())?,
701            Some(VmValue::Closure(closure)) => OnViolation::Callback(Arc::clone(closure)),
702            Some(VmValue::Nil) | None => OnViolation::Error,
703            Some(other) => {
704                return Err(vm_error(format!(
705                    "NetPolicy.create: on_violation must be a string or callback, got {}",
706                    other.type_name()
707                )))
708            }
709        };
710        Ok(NetPolicy {
711            allow: Arc::new(allow),
712            deny: Arc::new(deny),
713            default,
714            on_violation,
715        })
716    }
717
718    fn parse_rule_list(value: Option<&VmValue>, side: &str) -> Result<Vec<NetPolicyRule>, VmError> {
719        match value {
720            None | Some(VmValue::Nil) => Ok(Vec::new()),
721            Some(VmValue::List(items)) => items.iter().map(rule_from_vm).collect(),
722            Some(other) => Err(vm_error(format!(
723                "NetPolicy.create: `{side}` must be a list, got {}",
724                other.type_name()
725            ))),
726        }
727    }
728}
729
730#[cfg(test)]
731mod tests {
732    use super::*;
733
734    fn rule(raw: &str, ports: Option<Vec<u16>>) -> NetPolicyRule {
735        NetPolicyRule::parse_host(raw, ports).expect("rule parses")
736    }
737
738    fn cidr(raw: &str) -> NetPolicyRule {
739        NetPolicyRule::parse_cidr(raw).expect("cidr parses")
740    }
741
742    fn build(
743        allow: Vec<NetPolicyRule>,
744        deny: Vec<NetPolicyRule>,
745        default: NetPolicyDefault,
746    ) -> NetPolicy {
747        NetPolicy {
748            allow: Arc::new(allow),
749            deny: Arc::new(deny),
750            default,
751            on_violation: OnViolation::Error,
752        }
753    }
754
755    #[test]
756    fn exact_host_match_allows() {
757        let policy = build(
758            vec![rule("github.com", None)],
759            Vec::new(),
760            NetPolicyDefault::Deny,
761        );
762        let decision = policy
763            .evaluate("get", "https://github.com/foo")
764            .expect("evaluates");
765        assert!(matches!(decision, NetPolicyDecision::Allow { .. }));
766    }
767
768    #[test]
769    fn wildcard_does_not_match_bare_apex() {
770        let policy = build(
771            vec![NetPolicyRule::parse_domain_wildcard("*.github.com").unwrap()],
772            Vec::new(),
773            NetPolicyDefault::Deny,
774        );
775        let allow = policy.evaluate("get", "https://api.github.com/x").unwrap();
776        assert!(matches!(allow, NetPolicyDecision::Allow { .. }));
777        let deny = policy.evaluate("get", "https://github.com/x").unwrap();
778        assert!(matches!(deny, NetPolicyDecision::Deny { .. }));
779    }
780
781    #[test]
782    fn cidr_matches_ip_literal() {
783        let policy = build(vec![cidr("10.0.0.0/8")], Vec::new(), NetPolicyDefault::Deny);
784        let allowed = policy.evaluate("get", "http://10.5.5.5/x").unwrap();
785        assert!(matches!(allowed, NetPolicyDecision::Allow { .. }));
786        let denied = policy.evaluate("get", "http://192.168.1.1/x").unwrap();
787        assert!(matches!(denied, NetPolicyDecision::Deny { .. }));
788    }
789
790    #[test]
791    fn host_port_rule_requires_matching_port() {
792        let policy = build(
793            vec![rule("api.anthropic.com", Some(vec![443]))],
794            Vec::new(),
795            NetPolicyDefault::Deny,
796        );
797        let allow = policy
798            .evaluate("get", "https://api.anthropic.com/v1/messages")
799            .unwrap();
800        assert!(matches!(allow, NetPolicyDecision::Allow { .. }));
801        let deny = policy
802            .evaluate("get", "http://api.anthropic.com/v1/messages")
803            .unwrap();
804        assert!(matches!(deny, NetPolicyDecision::Deny { .. }));
805    }
806
807    #[test]
808    fn deny_overrides_allow() {
809        let policy = build(
810            vec![NetPolicyRule::parse_domain_wildcard("*.github.com").unwrap()],
811            vec![rule("evil.github.com", None)],
812            NetPolicyDefault::Deny,
813        );
814        let decision = policy.evaluate("get", "https://evil.github.com/x").unwrap();
815        match decision {
816            NetPolicyDecision::Deny { audit, .. } => {
817                assert!(audit.reason.contains("deny rule"));
818            }
819            other => panic!("expected deny, got {other:?}"),
820        }
821    }
822
823    #[test]
824    fn default_allow_lets_unmatched_through() {
825        let policy = build(Vec::new(), Vec::new(), NetPolicyDefault::Allow);
826        let allow = policy.evaluate("get", "https://example.test/x").unwrap();
827        assert!(matches!(allow, NetPolicyDecision::Allow { .. }));
828    }
829
830    #[test]
831    fn audit_only_allows_but_carries_audit() {
832        let mut policy = build(Vec::new(), Vec::new(), NetPolicyDefault::Deny);
833        policy.on_violation = OnViolation::AuditOnly;
834        let decision = policy
835            .evaluate("get", "https://blocked.test/x")
836            .expect("evaluates");
837        match decision {
838            NetPolicyDecision::Allow { audited, audit } => {
839                assert!(audited);
840                let audit = audit.expect("audit attached");
841                assert_eq!(audit.outcome, "audit_only");
842                assert_eq!(audit.host, "blocked.test");
843            }
844            other => panic!("expected audit_only allow, got {other:?}"),
845        }
846    }
847
848    #[test]
849    fn quarantine_denies_with_signal() {
850        let mut policy = build(Vec::new(), Vec::new(), NetPolicyDefault::Deny);
851        policy.on_violation = OnViolation::Quarantine;
852        match policy
853            .evaluate("get", "https://blocked.test/x")
854            .expect("evaluates")
855        {
856            NetPolicyDecision::Deny { audit, quarantine } => {
857                assert!(quarantine);
858                assert_eq!(audit.outcome, "quarantine");
859            }
860            other => panic!("expected quarantine deny, got {other:?}"),
861        }
862    }
863
864    #[test]
865    fn invalid_url_surfaces_typed_error() {
866        let policy = build(Vec::new(), Vec::new(), NetPolicyDefault::Deny);
867        let err = policy.evaluate("get", "not a url").unwrap_err();
868        match err {
869            VmError::Thrown(VmValue::String(s)) => {
870                assert!(s.contains("invalid URL"), "unexpected error: {s}");
871            }
872            other => panic!("expected Thrown, got {other:?}"),
873        }
874    }
875
876    #[test]
877    fn parse_string_rule_branches_on_shape() {
878        let domain =
879            parse::rule_from_vm(&VmValue::String(arcstr::ArcStr::from("github.com"))).unwrap();
880        assert!(matches!(domain.matcher, NetMatcher::Host(_)));
881        let wildcard =
882            parse::rule_from_vm(&VmValue::String(arcstr::ArcStr::from("*.github.com"))).unwrap();
883        assert!(matches!(wildcard.matcher, NetMatcher::Suffix(_)));
884        let cidr_rule =
885            parse::rule_from_vm(&VmValue::String(arcstr::ArcStr::from("10.0.0.0/8"))).unwrap();
886        assert!(matches!(cidr_rule.matcher, NetMatcher::Cidr(_)));
887    }
888
889    #[test]
890    fn bypass_env_recognised() {
891        let original = std::env::var(HARN_NET_POLICY_BYPASS_ENV).ok();
892        std::env::set_var(HARN_NET_POLICY_BYPASS_ENV, "1");
893        assert!(bypass_enabled());
894        std::env::set_var(HARN_NET_POLICY_BYPASS_ENV, "0");
895        assert!(!bypass_enabled());
896        match original {
897            Some(value) => std::env::set_var(HARN_NET_POLICY_BYPASS_ENV, value),
898            None => std::env::remove_var(HARN_NET_POLICY_BYPASS_ENV),
899        }
900    }
901
902    #[test]
903    fn outbound_method_contract_extracts_each_nonleading_url() {
904        let request_args = [
905            VmValue::String("GET".into()),
906            VmValue::String("https://request.example.test/path".into()),
907        ];
908        let session_args = [
909            VmValue::String("session-handle".into()),
910            VmValue::String("POST".into()),
911            VmValue::String("https://session.example.test/path".into()),
912        ];
913        let sse_args = [
914            VmValue::String("GET".into()),
915            VmValue::String("https://events.example.test/stream".into()),
916        ];
917
918        assert_eq!(
919            NetPolicyMethodContract::for_method("request").and_then(|c| c.url(&request_args)),
920            Some("https://request.example.test/path")
921        );
922        assert_eq!(
923            NetPolicyMethodContract::for_method("session_request")
924                .and_then(|c| c.url(&session_args)),
925            Some("https://session.example.test/path")
926        );
927        assert_eq!(
928            NetPolicyMethodContract::for_method("sse_connect").and_then(|c| c.url(&sse_args)),
929            Some("https://events.example.test/stream")
930        );
931        assert_eq!(
932            NetPolicyMethodContract::for_method("session_close").and_then(|c| c.url(&session_args)),
933            None
934        );
935    }
936
937    #[test]
938    fn every_declared_harness_net_method_has_a_destination_contract() {
939        crate::stdlib::force_link();
940        let unclassified = crate::stdlib::all_builtin_manifest()
941            .iter()
942            .filter_map(|entry| match entry.contract.exposure {
943                harn_builtin_meta::BuiltinExposure::HarnessMethod {
944                    capability: harn_builtin_meta::CapabilityId::Net,
945                    method,
946                } if NetPolicyMethodContract::for_method(method).is_none() => Some(method),
947                _ => None,
948            })
949            .collect::<std::collections::BTreeSet<_>>();
950
951        assert!(
952            unclassified.is_empty(),
953            "HarnessNet methods missing a NetPolicy destination contract: {unclassified:?}"
954        );
955    }
956}