zagens_runtime_adapters/tools/
network_gate.rs1use std::net::IpAddr;
4
5use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider, host_from_url};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum NetworkGateError {
10 Denied { host: String, tool: String },
11 PromptRequired { host: String, tool: String },
12}
13
14impl NetworkGateError {
15 #[must_use]
16 pub fn denial_message(&self) -> String {
17 match self {
18 Self::Denied { host, .. } => {
19 format!("network call to '{host}' blocked by network policy")
20 }
21 Self::PromptRequired { host, .. } => format!(
22 "network call to '{host}' requires approval; \
23 re-run after `/network allow {host}` or set network.default = \"allow\" in config"
24 ),
25 }
26 }
27}
28
29pub fn check_host_policy(
31 decider: Option<&NetworkPolicyDecider>,
32 tool_name: &str,
33 host: &str,
34) -> Result<(), NetworkGateError> {
35 let Some(decider) = decider else {
36 return Ok(());
37 };
38 match decider.evaluate(host, tool_name) {
39 Decision::Allow => Ok(()),
40 Decision::Deny => Err(NetworkGateError::Denied {
41 host: host.to_string(),
42 tool: tool_name.to_string(),
43 }),
44 Decision::Prompt => Err(NetworkGateError::PromptRequired {
45 host: host.to_string(),
46 tool: tool_name.to_string(),
47 }),
48 }
49}
50
51pub fn check_url_policy(
53 decider: Option<&NetworkPolicyDecider>,
54 tool_name: &str,
55 url: &str,
56) -> Result<Option<String>, NetworkGateError> {
57 let Some(host) = host_from_url(url) else {
58 return Ok(None);
59 };
60 check_host_policy(decider, tool_name, &host)?;
61 Ok(Some(host))
62}
63
64pub fn check_host_with_policy(
66 policy: &NetworkPolicy,
67 tool_name: &str,
68 host: &str,
69) -> Result<(), NetworkGateError> {
70 match policy.decide(host) {
71 Decision::Allow => Ok(()),
72 Decision::Deny => Err(NetworkGateError::Denied {
73 host: host.to_string(),
74 tool: tool_name.to_string(),
75 }),
76 Decision::Prompt => Err(NetworkGateError::PromptRequired {
77 host: host.to_string(),
78 tool: tool_name.to_string(),
79 }),
80 }
81}
82
83#[must_use]
85pub fn host_policy_decision(policy: &NetworkPolicy, host: &str) -> Decision {
86 policy.decide(host)
87}
88
89#[must_use]
91pub fn is_http_url(url: &str) -> bool {
92 let trimmed = url.trim();
93 trimmed.starts_with("http://") || trimmed.starts_with("https://")
94}
95
96#[must_use]
99pub fn is_restricted_ip(ip: &IpAddr) -> bool {
100 match ip {
101 IpAddr::V4(v4) => {
102 v4.is_loopback()
103 || v4.is_private()
104 || v4.is_link_local()
105 || v4.is_multicast()
106 || v4.is_broadcast()
107 || v4.is_unspecified()
108 || matches!(v4.octets(), [100, 64..=127, ..])
109 || *ip == IpAddr::V4(std::net::Ipv4Addr::new(169, 254, 169, 254))
110 || matches!(v4.octets(), [198, 18..=19, ..])
111 || v4.octets()[0] >= 240
112 }
113 IpAddr::V6(v6) => {
114 if v6.is_unspecified()
115 || matches!(v6.octets(), [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, ..])
116 {
117 return true;
118 }
119 if let Some(v4) = v6.to_ipv4_mapped() {
120 return is_restricted_ip(&IpAddr::V4(v4));
121 }
122 v6.is_loopback()
123 || v6.is_multicast()
124 || matches!(v6.segments(), [0xfc00..=0xfdff, ..])
125 || matches!(v6.segments(), [0xfe80..=0xfebf, ..])
126 }
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};
134
135 #[test]
136 fn restricted_ip_detects_loopback() {
137 assert!(is_restricted_ip(&"127.0.0.1".parse().unwrap()));
138 assert!(is_restricted_ip(&"::1".parse().unwrap()));
139 }
140
141 #[test]
142 fn restricted_ip_detects_private_ranges() {
143 assert!(is_restricted_ip(&"10.0.0.1".parse().unwrap()));
144 assert!(is_restricted_ip(&"172.16.0.1".parse().unwrap()));
145 assert!(is_restricted_ip(&"192.168.1.1".parse().unwrap()));
146 }
147
148 #[test]
149 fn restricted_ip_detects_metadata_and_cgnat() {
150 assert!(is_restricted_ip(&"169.254.169.254".parse().unwrap()));
151 assert!(is_restricted_ip(&"100.64.0.1".parse().unwrap()));
152 assert!(!is_restricted_ip(&"100.63.0.1".parse().unwrap()));
153 }
154
155 #[test]
156 fn restricted_ip_detects_link_local() {
157 assert!(is_restricted_ip(&"169.254.1.1".parse().unwrap()));
158 }
159
160 #[test]
161 fn restricted_ip_detects_ipv6_ula() {
162 assert!(is_restricted_ip(&"fc00::1".parse().unwrap()));
163 assert!(is_restricted_ip(&"fd12:3456::1".parse().unwrap()));
164 }
165
166 #[test]
167 fn restricted_ip_detects_unspecified() {
168 assert!(is_restricted_ip(&"::".parse().unwrap()));
169 }
170
171 #[test]
172 fn restricted_ip_allows_public() {
173 assert!(!is_restricted_ip(&"1.1.1.1".parse().unwrap()));
174 assert!(!is_restricted_ip(&"93.184.216.34".parse().unwrap()));
175 assert!(!is_restricted_ip(&"2606:4700::1".parse().unwrap()));
176 }
177
178 #[test]
179 fn restricted_ip_detects_ipv4_mapped_private() {
180 assert!(is_restricted_ip(&"::ffff:10.0.0.1".parse().unwrap()));
181 assert!(is_restricted_ip(&"::ffff:169.254.169.254".parse().unwrap()));
182 }
183
184 #[test]
185 fn check_url_policy_denies_blocked_host() {
186 let policy = NetworkPolicy {
187 default: Decision::Allow.into(),
188 allow: vec![],
189 deny: vec!["example.com".into()],
190 audit: false,
191 };
192 let decider = NetworkPolicyDecider::with_default_audit(policy);
193 let err = check_url_policy(Some(&decider), "fetch_url", "https://example.com/private")
194 .expect_err("deny");
195 assert!(matches!(err, NetworkGateError::Denied { .. }));
196 }
197
198 #[test]
199 fn check_host_with_policy_denies_blocked_host() {
200 let policy = NetworkPolicy {
201 default: Decision::Allow.into(),
202 allow: vec![],
203 deny: vec!["example.com".into()],
204 audit: false,
205 };
206 let err =
207 check_host_with_policy(&policy, "skills_install", "example.com").expect_err("deny");
208 assert!(matches!(err, NetworkGateError::Denied { .. }));
209 }
210
211 #[test]
212 fn check_host_policy_allows_when_decider_missing() {
213 check_host_policy(None, "fetch_url", "example.com").expect("permissive default");
214 }
215}