1use crate::protocol::SpawnNetworkAllowlistId;
23use gate4agent_types::ApprovalLevel;
24use serde::Deserialize;
25use std::collections::BTreeMap;
26use std::fs;
27use std::io;
28use std::path::{Path, PathBuf};
29use thiserror::Error;
30
31pub use crate::protocol::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES;
33
34pub const MAX_NETWORK_PERMITS_PER_ENTRY: usize = 64;
36
37pub const MAX_NETWORK_PERMIT_PEER_BYTES: usize = 256;
39
40pub const NETWORK_ALLOWLIST_CATALOG_SCHEMA_VERSION: u32 = 2;
42
43pub const NETWORK_ALLOWLIST_CATALOG_ENV: &str = "GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG";
46
47#[derive(Debug, Error)]
48pub enum NetworkAllowlistCatalogError {
49 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
50 InvalidPath(String),
51 #[error("network allowlist catalog could not be read: {0}")]
52 Io(#[source] io::Error),
53 #[error("network allowlist catalog line {line}: {message}")]
54 InvalidEntry { line: usize, message: String },
55 #[error("network allowlist catalog: {0}")]
56 InvalidCatalog(String),
57 #[error("network allowlist catalog exceeds the {max}-entry limit")]
58 Capacity { max: usize },
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct NetworkPermitSketch {
64 pub protocol: NetworkPermitProtocol,
65 pub peer: String,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum NetworkPermitProtocol {
71 Tcp,
72 Udp,
73}
74
75impl NetworkPermitProtocol {
76 pub fn parse(raw: &str) -> Result<Self, String> {
77 match raw.trim().to_ascii_lowercase().as_str() {
78 "tcp" => Ok(Self::Tcp),
79 "udp" => Ok(Self::Udp),
80 other => Err(format!(
81 "unsupported network permit protocol {other:?} (expected tcp or udp)"
82 )),
83 }
84 }
85
86 pub fn as_str(self) -> &'static str {
87 match self {
88 Self::Tcp => "tcp",
89 Self::Udp => "udp",
90 }
91 }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct ProviderNativeNetworkSketch {
112 #[serde(default)]
114 pub codex_network_access: Option<bool>,
115}
116
117impl ProviderNativeNetworkSketch {
118 pub fn is_empty(&self) -> bool {
119 self.codex_network_access.is_none()
120 }
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct NetworkAllowlistEntry {
126 pub id: SpawnNetworkAllowlistId,
127 pub permits: Vec<NetworkPermitSketch>,
129 pub provider_native: Option<ProviderNativeNetworkSketch>,
130}
131
132impl NetworkAllowlistEntry {
133 pub fn membership_only(id: SpawnNetworkAllowlistId) -> Self {
134 Self {
135 id,
136 permits: Vec::new(),
137 provider_native: None,
138 }
139 }
140}
141
142#[derive(Debug, Clone, Default, PartialEq, Eq)]
144pub struct NetworkAllowlistCatalog {
145 entries: BTreeMap<SpawnNetworkAllowlistId, NetworkAllowlistEntry>,
146}
147
148impl NetworkAllowlistCatalog {
149 pub fn new() -> Self {
150 Self::default()
151 }
152
153 pub fn is_empty(&self) -> bool {
154 self.entries.is_empty()
155 }
156
157 pub fn len(&self) -> usize {
158 self.entries.len()
159 }
160
161 pub fn contains(&self, id: &SpawnNetworkAllowlistId) -> bool {
162 self.entries.contains_key(id)
163 }
164
165 pub fn get(&self, id: &SpawnNetworkAllowlistId) -> Option<&NetworkAllowlistEntry> {
166 self.entries.get(id)
167 }
168
169 pub fn ids(&self) -> impl Iterator<Item = &SpawnNetworkAllowlistId> {
171 self.entries.keys()
172 }
173
174 pub fn iter(&self) -> impl Iterator<Item = &NetworkAllowlistEntry> {
175 self.entries.values()
176 }
177
178 pub fn insert(
180 &mut self,
181 entry: NetworkAllowlistEntry,
182 ) -> Result<(), NetworkAllowlistCatalogError> {
183 if self.entries.len() == MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES
184 && !self.entries.contains_key(&entry.id)
185 {
186 return Err(NetworkAllowlistCatalogError::Capacity {
187 max: MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
188 });
189 }
190 self.entries.insert(entry.id.clone(), entry);
191 Ok(())
192 }
193
194 pub fn insert_id(
196 &mut self,
197 id: SpawnNetworkAllowlistId,
198 ) -> Result<(), NetworkAllowlistCatalogError> {
199 self.insert(NetworkAllowlistEntry::membership_only(id))
200 }
201}
202
203impl FromIterator<SpawnNetworkAllowlistId> for NetworkAllowlistCatalog {
204 fn from_iter<T: IntoIterator<Item = SpawnNetworkAllowlistId>>(iter: T) -> Self {
205 let mut catalog = Self::new();
206 for id in iter {
207 let _ = catalog.insert_id(id);
210 }
211 catalog
212 }
213}
214
215impl FromIterator<NetworkAllowlistEntry> for NetworkAllowlistCatalog {
216 fn from_iter<T: IntoIterator<Item = NetworkAllowlistEntry>>(iter: T) -> Self {
217 let mut catalog = Self::new();
218 for entry in iter {
219 let _ = catalog.insert(entry);
220 }
221 catalog
222 }
223}
224
225pub fn load_network_allowlist_catalog_file(
227 path: impl AsRef<Path>,
228) -> Result<NetworkAllowlistCatalog, NetworkAllowlistCatalogError> {
229 let path = path.as_ref();
230 let display = path.to_string_lossy().into_owned();
231 if !path.is_absolute() {
232 return Err(NetworkAllowlistCatalogError::InvalidPath(display));
233 }
234 let metadata = fs::metadata(path).map_err(|error| {
235 if error.kind() == io::ErrorKind::NotFound {
236 NetworkAllowlistCatalogError::InvalidPath(display.clone())
237 } else {
238 NetworkAllowlistCatalogError::Io(error)
239 }
240 })?;
241 if !metadata.is_file() {
242 return Err(NetworkAllowlistCatalogError::InvalidPath(display));
243 }
244 let text = fs::read_to_string(path).map_err(NetworkAllowlistCatalogError::Io)?;
245 parse_network_allowlist_catalog_text(&text)
246}
247
248pub fn parse_network_allowlist_catalog_text(
251 text: &str,
252) -> Result<NetworkAllowlistCatalog, NetworkAllowlistCatalogError> {
253 if looks_like_json_catalog(text) {
254 parse_network_allowlist_catalog_json(text)
255 } else {
256 parse_network_allowlist_catalog_id_list(text)
257 }
258}
259
260pub fn resolve_network_allowlist_catalog(
263 explicit_path: Option<PathBuf>,
264) -> Result<NetworkAllowlistCatalog, NetworkAllowlistCatalogError> {
265 let path = match explicit_path {
266 Some(path) => Some(path),
267 None => std::env::var_os(NETWORK_ALLOWLIST_CATALOG_ENV).map(PathBuf::from),
268 };
269 match path {
270 Some(path) => load_network_allowlist_catalog_file(path),
271 None => Ok(NetworkAllowlistCatalog::new()),
272 }
273}
274
275fn looks_like_json_catalog(text: &str) -> bool {
276 for raw_line in text.lines() {
277 let trimmed = raw_line.trim();
278 if trimmed.is_empty() || trimmed.starts_with('#') {
279 continue;
280 }
281 return trimmed.starts_with('{');
282 }
283 false
284}
285
286fn parse_network_allowlist_catalog_id_list(
287 text: &str,
288) -> Result<NetworkAllowlistCatalog, NetworkAllowlistCatalogError> {
289 let mut catalog = NetworkAllowlistCatalog::new();
290 for (index, raw_line) in text.lines().enumerate() {
291 let line = index + 1;
292 let trimmed = raw_line.trim();
293 if trimmed.is_empty() || trimmed.starts_with('#') {
294 continue;
295 }
296 let id = SpawnNetworkAllowlistId::new(trimmed).map_err(|error| {
297 NetworkAllowlistCatalogError::InvalidEntry {
298 line,
299 message: error.to_string(),
300 }
301 })?;
302 catalog.insert(NetworkAllowlistEntry::membership_only(id))?;
303 }
304 Ok(catalog)
305}
306
307#[derive(Debug, Deserialize)]
308#[serde(deny_unknown_fields)]
309struct CatalogFileV2 {
310 schema_version: u32,
311 #[serde(default)]
312 entries: Vec<CatalogEntryV2>,
313}
314
315#[derive(Debug, Deserialize)]
316#[serde(deny_unknown_fields)]
317struct CatalogEntryV2 {
318 id: String,
319 #[serde(default)]
320 permits: Vec<CatalogPermitV2>,
321 #[serde(default)]
322 provider_native: Option<ProviderNativeNetworkSketch>,
323}
324
325#[derive(Debug, Deserialize)]
326#[serde(deny_unknown_fields)]
327struct CatalogPermitV2 {
328 protocol: String,
329 peer: String,
330}
331
332fn strip_leading_comments_and_blanks(text: &str) -> &str {
333 let mut rest = text;
334 loop {
335 let trimmed_start = rest.trim_start();
336 if trimmed_start.is_empty() {
337 return trimmed_start;
338 }
339 rest = trimmed_start;
341 if rest.starts_with('#') {
342 rest = match rest.split_once('\n') {
343 Some((_, after)) => after,
344 None => return "",
345 };
346 continue;
347 }
348 return rest;
349 }
350}
351
352fn parse_network_allowlist_catalog_json(
353 text: &str,
354) -> Result<NetworkAllowlistCatalog, NetworkAllowlistCatalogError> {
355 let json_text = strip_leading_comments_and_blanks(text);
356 let file: CatalogFileV2 = serde_json::from_str(json_text).map_err(|error| {
357 NetworkAllowlistCatalogError::InvalidCatalog(format!("invalid JSON catalog: {error}"))
358 })?;
359 if file.schema_version != NETWORK_ALLOWLIST_CATALOG_SCHEMA_VERSION {
360 return Err(NetworkAllowlistCatalogError::InvalidCatalog(format!(
361 "unsupported schema_version {} (expected {})",
362 file.schema_version, NETWORK_ALLOWLIST_CATALOG_SCHEMA_VERSION
363 )));
364 }
365 let mut catalog = NetworkAllowlistCatalog::new();
366 for (index, raw) in file.entries.into_iter().enumerate() {
367 let entry_label = index + 1;
368 let id = SpawnNetworkAllowlistId::new(&raw.id).map_err(|error| {
369 NetworkAllowlistCatalogError::InvalidCatalog(format!(
370 "entries[{entry_label}].id: {error}"
371 ))
372 })?;
373 if catalog.contains(&id) {
374 return Err(NetworkAllowlistCatalogError::InvalidCatalog(format!(
375 "duplicate catalog id {}",
376 id.as_str()
377 )));
378 }
379 if raw.permits.len() > MAX_NETWORK_PERMITS_PER_ENTRY {
380 return Err(NetworkAllowlistCatalogError::InvalidCatalog(format!(
381 "entries[{entry_label}]: exceeds {MAX_NETWORK_PERMITS_PER_ENTRY} permits"
382 )));
383 }
384 let mut permits = Vec::with_capacity(raw.permits.len());
385 for (permit_index, permit) in raw.permits.into_iter().enumerate() {
386 let protocol = NetworkPermitProtocol::parse(&permit.protocol).map_err(|message| {
387 NetworkAllowlistCatalogError::InvalidCatalog(format!(
388 "entries[{entry_label}].permits[{}]: {message}",
389 permit_index + 1
390 ))
391 })?;
392 let peer = permit.peer.trim();
393 if peer.is_empty() {
394 return Err(NetworkAllowlistCatalogError::InvalidCatalog(format!(
395 "entries[{entry_label}].permits[{}]: peer must be non-empty",
396 permit_index + 1
397 )));
398 }
399 if peer.len() > MAX_NETWORK_PERMIT_PEER_BYTES {
400 return Err(NetworkAllowlistCatalogError::InvalidCatalog(format!(
401 "entries[{entry_label}].permits[{}]: peer exceeds {MAX_NETWORK_PERMIT_PEER_BYTES} bytes",
402 permit_index + 1
403 )));
404 }
405 if peer.contains('@') && peer.contains(':') && peer.split('@').next().is_some_and(|u| u.contains(':')) {
407 return Err(NetworkAllowlistCatalogError::InvalidCatalog(format!(
409 "entries[{entry_label}].permits[{}]: peer must not carry credentials",
410 permit_index + 1
411 )));
412 }
413 permits.push(NetworkPermitSketch {
414 protocol,
415 peer: peer.to_owned(),
416 });
417 }
418 let provider_native = match raw.provider_native {
419 Some(native) if native.is_empty() => None,
420 other => other,
421 };
422 catalog.insert(NetworkAllowlistEntry {
423 id,
424 permits,
425 provider_native,
426 })?;
427 }
428 Ok(catalog)
429}
430
431
432pub fn provider_native_mapping_supported(
440 provider: &str,
441 approval_level: ApprovalLevel,
442 native: &ProviderNativeNetworkSketch,
443) -> Result<(), String> {
444 if native.is_empty() {
445 return Ok(());
446 }
447 if native.codex_network_access.is_some() {
448 if provider != "codex" {
449 return Err(format!(
450 "provider-native codex_network_access is unsupported for provider {provider:?}"
451 ));
452 }
453 match approval_level {
454 ApprovalLevel::Moderate => Ok(()),
455 ApprovalLevel::ReadOnly => Err(
456 "provider-native codex_network_access requires Codex Moderate (workspace-write); ReadOnly has no workspace-write network axis"
457 .to_owned(),
458 ),
459 ApprovalLevel::FullAuto => Err(
460 "provider-native codex_network_access is unsupported under Codex FullAuto (sandbox bypass); refuse clear mapping rather than silent no-op"
461 .to_owned(),
462 ),
463 ApprovalLevel::Unmanaged => Err(
464 "provider-native codex_network_access requires Codex Moderate (workspace-write); Unmanaged has no sandbox contract"
465 .to_owned(),
466 ),
467 }
468 } else {
469 Ok(())
470 }
471}
472
473pub fn codex_network_access_config_overlay(enabled: bool) -> Vec<String> {
477 vec![
478 "-c".to_owned(),
479 format!(
480 "sandbox_workspace_write.network_access={}",
481 if enabled { "true" } else { "false" }
482 ),
483 ]
484}
485
486pub const CLAUDE_STATION_NETWORK_SETTINGS_FILE: &str =
491 "gate4agent-station-network-settings.json";
492
493pub fn claude_bash_sandbox_network_os_supported() -> Result<(), String> {
497 #[cfg(windows)]
498 {
499 Err(
500 "Claude sandbox.network settings overlay requires vendor Bash sandbox (macOS/Linux/WSL2); native Windows has no vendor Bash sandbox"
501 .to_owned(),
502 )
503 }
504 #[cfg(not(windows))]
505 {
506 Ok(())
507 }
508}
509
510pub fn permit_peer_to_allowed_domain(peer: &str) -> Option<String> {
516 let peer = peer.trim();
517 if peer.is_empty() {
518 return None;
519 }
520 if let Some(rest) = peer.strip_prefix('[') {
522 let Some(end) = rest.find(']') else {
523 return None;
524 };
525 let host = format!("[{}]", &rest[..end]);
526 let after = &rest[end + 1..];
527 if after.is_empty() || after.starts_with(':') {
528 return Some(host);
529 }
530 return None;
531 }
532 if let Some((host, port)) = peer.rsplit_once(':') {
535 if !host.is_empty()
536 && !host.contains(':')
537 && !port.is_empty()
538 && port.chars().all(|c| c.is_ascii_digit())
539 {
540 return Some(host.to_owned());
541 }
542 }
543 Some(peer.to_owned())
544}
545
546pub fn claude_allowed_domains_from_permits(permits: &[NetworkPermitSketch]) -> Vec<String> {
548 let mut out = Vec::new();
549 let mut seen = std::collections::BTreeSet::new();
550 for permit in permits {
551 let Some(domain) = permit_peer_to_allowed_domain(&permit.peer) else {
552 continue;
553 };
554 if seen.insert(domain.clone()) {
555 out.push(domain);
556 }
557 }
558 out
559}
560
561pub fn claude_station_network_settings_json(allowed_domains: &[String]) -> String {
564 let value = serde_json::json!({
565 "sandbox": {
566 "enabled": true,
567 "network": {
568 "allowedDomains": allowed_domains,
569 }
570 }
571 });
572 serde_json::to_string(&value).expect("claude station network settings serialize")
574}
575
576pub fn claude_settings_network_overlay_args(settings_path: &Path) -> Vec<String> {
578 vec![
579 "--settings".to_owned(),
580 settings_path.to_string_lossy().into_owned(),
581 ]
582}
583
584pub fn resolve_provider_native_launch_overlay(
593 provider: &str,
594 approval_level: ApprovalLevel,
595 native: &ProviderNativeNetworkSketch,
596) -> Result<Vec<String>, String> {
597 provider_native_mapping_supported(provider, approval_level, native)?;
598 match native.codex_network_access {
599 Some(flag) if provider == "codex" && approval_level == ApprovalLevel::Moderate => {
600 Ok(codex_network_access_config_overlay(flag))
601 }
602 _ => Ok(Vec::new()),
603 }
604}
605
606#[cfg(test)]
607mod tests {
608 use super::*;
609 use std::time::{SystemTime, UNIX_EPOCH};
610
611 fn temp_catalog_path(label: &str) -> PathBuf {
612 let unique = SystemTime::now()
613 .duration_since(UNIX_EPOCH)
614 .unwrap()
615 .as_nanos();
616 std::env::temp_dir().join(format!(
617 "gate4agent-node-allowlist-{label}-{}-{unique}.txt",
618 std::process::id(),
619 ))
620 }
621
622 #[test]
623 fn unset_path_yields_empty_catalog_when_env_absent() {
624 let previous = std::env::var_os(NETWORK_ALLOWLIST_CATALOG_ENV);
625 std::env::remove_var(NETWORK_ALLOWLIST_CATALOG_ENV);
626 let catalog = resolve_network_allowlist_catalog(None).unwrap();
627 assert!(catalog.is_empty());
628 match previous {
629 Some(value) => std::env::set_var(NETWORK_ALLOWLIST_CATALOG_ENV, value),
630 None => std::env::remove_var(NETWORK_ALLOWLIST_CATALOG_ENV),
631 }
632 }
633
634 #[test]
635 fn explicit_temp_path_wins_over_absent_env() {
636 let previous = std::env::var_os(NETWORK_ALLOWLIST_CATALOG_ENV);
637 std::env::remove_var(NETWORK_ALLOWLIST_CATALOG_ENV);
638 let path = temp_catalog_path("explicit");
639 fs::write(&path, "from-explicit\n").unwrap();
640 let catalog = resolve_network_allowlist_catalog(Some(path.clone())).unwrap();
641 assert!(catalog.contains(&SpawnNetworkAllowlistId::new("from-explicit").unwrap()));
642 let _ = fs::remove_file(&path);
643 match previous {
644 Some(value) => std::env::set_var(NETWORK_ALLOWLIST_CATALOG_ENV, value),
645 None => std::env::remove_var(NETWORK_ALLOWLIST_CATALOG_ENV),
646 }
647 }
648
649 #[test]
650 fn load_temp_file_registers_ids_and_skips_comments() {
651 let path = temp_catalog_path("ok");
652 fs::write(
653 &path,
654 "# station network allowlist ids (opaque)\n\
655 egress-default\n\
656 \n\
657 # another comment\n\
658 lab-egress\n\
659 egress-default\n",
660 )
661 .unwrap();
662 let catalog = load_network_allowlist_catalog_file(&path).unwrap();
663 assert_eq!(catalog.len(), 2);
664 assert!(catalog.contains(&SpawnNetworkAllowlistId::new("egress-default").unwrap()));
665 assert!(catalog.contains(&SpawnNetworkAllowlistId::new("lab-egress").unwrap()));
666 let entry = catalog
667 .get(&SpawnNetworkAllowlistId::new("egress-default").unwrap())
668 .unwrap();
669 assert!(entry.permits.is_empty());
670 assert!(entry.provider_native.is_none());
671 let _ = fs::remove_file(&path);
672 }
673
674 #[test]
675 fn empty_file_yields_empty_catalog() {
676 let path = temp_catalog_path("empty");
677 fs::write(&path, "# only comments\n\n").unwrap();
678 let catalog = load_network_allowlist_catalog_file(&path).unwrap();
679 assert!(catalog.is_empty());
680 let _ = fs::remove_file(&path);
681 }
682
683 #[test]
684 fn relative_path_refuses() {
685 let error = load_network_allowlist_catalog_file("relative-allowlist.txt").unwrap_err();
686 assert!(matches!(
687 error,
688 NetworkAllowlistCatalogError::InvalidPath(_)
689 ));
690 }
691
692 #[test]
693 fn missing_file_refuses() {
694 let path = temp_catalog_path("missing");
695 let _ = fs::remove_file(&path);
696 let error = load_network_allowlist_catalog_file(&path).unwrap_err();
697 assert!(matches!(
698 error,
699 NetworkAllowlistCatalogError::InvalidPath(_)
700 ));
701 }
702
703 #[test]
704 fn invalid_id_line_refuses_with_line_number() {
705 let path = temp_catalog_path("bad-id");
706 fs::write(&path, "good-id\nbad/id\n").unwrap();
707 let error = load_network_allowlist_catalog_file(&path).unwrap_err();
708 match error {
709 NetworkAllowlistCatalogError::InvalidEntry { line, message } => {
710 assert_eq!(line, 2);
711 assert!(!message.is_empty());
712 }
713 other => panic!("expected InvalidEntry, got {other:?}"),
714 }
715 let _ = fs::remove_file(&path);
716 }
717
718 #[test]
719 fn parse_text_capacity_refuses_overflow() {
720 let mut body = String::new();
721 for index in 0..=MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
722 body.push_str(&format!("allow-{index}\n"));
723 }
724 let error = parse_network_allowlist_catalog_text(&body).unwrap_err();
725 assert!(matches!(
726 error,
727 NetworkAllowlistCatalogError::Capacity {
728 max: MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES
729 }
730 ));
731 }
732
733 #[test]
734 fn json_v2_loads_permits_and_provider_native() {
735 let text = r#"
736 {
737 "schema_version": 2,
738 "entries": [
739 {
740 "id": "egress-default",
741 "permits": [
742 { "protocol": "tcp", "peer": "127.0.0.1:443" },
743 { "protocol": "UDP", "peer": "10.0.0.1:53" }
744 ],
745 "provider_native": { "codex_network_access": true }
746 },
747 { "id": "lab-egress" }
748 ]
749 }
750 "#;
751 let catalog = parse_network_allowlist_catalog_text(text).unwrap();
752 assert_eq!(catalog.len(), 2);
753 let entry = catalog
754 .get(&SpawnNetworkAllowlistId::new("egress-default").unwrap())
755 .unwrap();
756 assert_eq!(entry.permits.len(), 2);
757 assert_eq!(entry.permits[0].protocol, NetworkPermitProtocol::Tcp);
758 assert_eq!(entry.permits[0].peer, "127.0.0.1:443");
759 assert_eq!(entry.permits[1].protocol, NetworkPermitProtocol::Udp);
760 let native = entry.provider_native.as_ref().unwrap();
761 assert_eq!(native.codex_network_access, Some(true));
762 let bare = catalog
763 .get(&SpawnNetworkAllowlistId::new("lab-egress").unwrap())
764 .unwrap();
765 assert!(bare.permits.is_empty());
766 assert!(bare.provider_native.is_none());
767 }
768
769 #[test]
770 fn json_v2_refuses_unsupported_schema_version() {
771 let text = r#"{ "schema_version": 99, "entries": [] }"#;
772 let error = parse_network_allowlist_catalog_text(text).unwrap_err();
773 assert!(matches!(
774 error,
775 NetworkAllowlistCatalogError::InvalidCatalog(_)
776 ));
777 assert!(error.to_string().contains("schema_version"));
778 }
779
780 #[test]
781 fn json_v2_refuses_unknown_provider_native_keys() {
782 for bad_key in [
787 "claude_network",
788 "kimi_network",
789 "allowed_domains",
790 "grok_network",
791 "grok_linux_child_network",
792 "restrict_network",
793 ] {
794 let text = format!(
795 r#"{{
796 "schema_version": 2,
797 "entries": [{{
798 "id": "egress-default",
799 "provider_native": {{ "{bad_key}": true }}
800 }}]
801 }}"#
802 );
803 let error = parse_network_allowlist_catalog_text(&text).unwrap_err();
804 assert!(matches!(
805 error,
806 NetworkAllowlistCatalogError::InvalidCatalog(_)
807 ));
808 let message = error.to_string();
809 assert!(
810 message.contains(bad_key) || message.contains("unknown field"),
811 "key={bad_key} message={message}"
812 );
813 }
814 }
815
816 #[test]
817 fn json_v2_refuses_unsupported_permit_protocol() {
818 let text = r#"
819 {
820 "schema_version": 2,
821 "entries": [{
822 "id": "egress-default",
823 "permits": [{ "protocol": "quic", "peer": "127.0.0.1:443" }]
824 }]
825 }
826 "#;
827 let error = parse_network_allowlist_catalog_text(text).unwrap_err();
828 assert!(error.to_string().contains("quic"));
829 }
830
831 #[test]
832 fn json_v2_comment_preamble_still_detects_json() {
833 let text = "# operator catalog\n{ \"schema_version\": 2, \"entries\": [{\"id\":\"a\"}] }\n";
834 let catalog = parse_network_allowlist_catalog_text(text).unwrap();
835 assert!(catalog.contains(&SpawnNetworkAllowlistId::new("a").unwrap()));
836 }
837
838 #[test]
839 fn provider_native_mapping_refuses_non_codex() {
840 let native = ProviderNativeNetworkSketch {
841 codex_network_access: Some(true),
842 };
843 let err = provider_native_mapping_supported(
844 "claude",
845 ApprovalLevel::Moderate,
846 &native,
847 )
848 .unwrap_err();
849 assert!(err.contains("claude"));
850 assert!(provider_native_mapping_supported(
851 "codex",
852 ApprovalLevel::Moderate,
853 &native,
854 )
855 .is_ok());
856 assert!(provider_native_mapping_supported(
857 "claude",
858 ApprovalLevel::Moderate,
859 &ProviderNativeNetworkSketch::default()
860 )
861 .is_ok());
862 }
863
864 #[test]
865 fn provider_native_mapping_refuses_codex_readonly_and_full_auto() {
866 let native = ProviderNativeNetworkSketch {
867 codex_network_access: Some(true),
868 };
869 let ro = provider_native_mapping_supported(
870 "codex",
871 ApprovalLevel::ReadOnly,
872 &native,
873 )
874 .unwrap_err();
875 assert!(ro.contains("ReadOnly") || ro.contains("workspace-write"));
876 let fa = provider_native_mapping_supported(
877 "codex",
878 ApprovalLevel::FullAuto,
879 &native,
880 )
881 .unwrap_err();
882 assert!(fa.contains("FullAuto"));
883 let un = provider_native_mapping_supported(
884 "codex",
885 ApprovalLevel::Unmanaged,
886 &native,
887 )
888 .unwrap_err();
889 assert!(un.contains("Unmanaged") || un.contains("workspace-write"));
890 }
891
892 #[test]
893 fn codex_network_access_overlay_uses_legacy_c_channel() {
894 assert_eq!(
895 codex_network_access_config_overlay(true),
896 ["-c", "sandbox_workspace_write.network_access=true"]
897 );
898 assert_eq!(
899 codex_network_access_config_overlay(false),
900 ["-c", "sandbox_workspace_write.network_access=false"]
901 );
902 let overlay = resolve_provider_native_launch_overlay(
903 "codex",
904 ApprovalLevel::Moderate,
905 &ProviderNativeNetworkSketch {
906 codex_network_access: Some(true),
907 },
908 )
909 .unwrap();
910 assert_eq!(overlay, codex_network_access_config_overlay(true));
911 assert!(resolve_provider_native_launch_overlay(
912 "codex",
913 ApprovalLevel::FullAuto,
914 &ProviderNativeNetworkSketch {
915 codex_network_access: Some(false),
916 },
917 )
918 .is_err());
919 }
920
921 #[test]
922 fn permit_peer_maps_host_port_and_ipv6_to_allowed_domain() {
923 assert_eq!(
924 permit_peer_to_allowed_domain("github.com:443").as_deref(),
925 Some("github.com")
926 );
927 assert_eq!(
928 permit_peer_to_allowed_domain("127.0.0.1:443").as_deref(),
929 Some("127.0.0.1")
930 );
931 assert_eq!(
932 permit_peer_to_allowed_domain("*.npmjs.org").as_deref(),
933 Some("*.npmjs.org")
934 );
935 assert_eq!(
936 permit_peer_to_allowed_domain("[::1]:443").as_deref(),
937 Some("[::1]")
938 );
939 assert_eq!(permit_peer_to_allowed_domain("").as_deref(), None);
940 }
941
942 #[test]
943 fn claude_allowed_domains_dedupe_preserve_order() {
944 let permits = vec![
945 NetworkPermitSketch {
946 protocol: NetworkPermitProtocol::Tcp,
947 peer: "github.com:443".to_owned(),
948 },
949 NetworkPermitSketch {
950 protocol: NetworkPermitProtocol::Udp,
951 peer: "github.com:53".to_owned(),
952 },
953 NetworkPermitSketch {
954 protocol: NetworkPermitProtocol::Tcp,
955 peer: "registry.npmjs.org".to_owned(),
956 },
957 ];
958 assert_eq!(
959 claude_allowed_domains_from_permits(&permits),
960 ["github.com", "registry.npmjs.org"]
961 );
962 }
963
964 #[test]
965 fn claude_station_network_settings_json_enables_sandbox_domains() {
966 let json = claude_station_network_settings_json(&[
967 "github.com".to_owned(),
968 "*.npmjs.org".to_owned(),
969 ]);
970 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
971 assert_eq!(value["sandbox"]["enabled"], true);
972 assert_eq!(
973 value["sandbox"]["network"]["allowedDomains"],
974 serde_json::json!(["github.com", "*.npmjs.org"])
975 );
976 let path = std::env::temp_dir().join("gate4agent-claude-settings-test.json");
977 assert_eq!(
978 claude_settings_network_overlay_args(&path),
979 [
980 "--settings".to_owned(),
981 path.to_string_lossy().into_owned()
982 ]
983 );
984 }
985
986 #[test]
987 fn claude_bash_sandbox_network_os_gate_matches_cfg() {
988 #[cfg(windows)]
989 assert!(claude_bash_sandbox_network_os_supported().is_err());
990 #[cfg(not(windows))]
991 assert!(claude_bash_sandbox_network_os_supported().is_ok());
992 }
993}