1use 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
32pub const NET_POLICY_AUDIT_TOPIC: &str = "harness.net.policy.audit";
34
35pub const HARN_NET_POLICY_BYPASS_ENV: &str = "HARN_NET_POLICY_BYPASS";
38
39#[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 Host(String),
52 Suffix(String),
54 Ip(IpAddr),
56 Cidr(IpNet),
58}
59
60#[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#[derive(Clone, Debug)]
89pub enum OnViolation {
90 Error,
92 AuditOnly,
95 Quarantine,
99 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#[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#[derive(Clone, Debug)]
130pub enum NetPolicyDecision {
131 Allow {
134 audited: bool,
135 audit: Option<NetPolicyAudit>,
136 },
137 Deny {
140 audit: NetPolicyAudit,
141 quarantine: bool,
142 },
143}
144
145#[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#[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
346pub(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 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 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
470pub 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
510pub 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
537pub 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
555pub 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
580pub mod parse {
584 use super::*;
585
586 pub const RULE_TAG_KEY: &str = "__net_policy_rule";
588 pub const POLICY_TAG_KEY: &str = "__net_policy";
590
591 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 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}