Skip to main content

gate4agent_node/
network_allowlist_catalog.rs

1//! Station **network allowlist** catalog (opaque policy ids + optional
2//! node-local permit-set / provider-native mapping).
3//!
4//! Operator startup path for `NodeShared::network_allowlist_catalog`. Empty
5//! when unset. **C2 / spawn wire stays id-only**; inventory lists ids.
6//! Never cookies / OAuth / proxy credentials.
7//!
8//! Dual file formats (Track B §3.2.1):
9//! - **v1 id-list** (backward compatible): one id per line; `#` comments /
10//!   blanks skipped. Entries are membership-only (empty permits).
11//! - **v2 JSON**: `{ "schema_version": 2, "entries": [ { "id", "permits?",
12//!   "provider_native?" } ] }`. Detected when the first non-comment /
13//!   non-blank content starts with `{`.
14//!
15//! Plan: `dig2browser-station-probe-and-network-permit-set-2026-10-02.md` §3.2.1.
16//! Dig2browser bind remains stubbed elsewhere. Codex provider-native first
17//! slice: `-c sandbox_workspace_write.network_access` under Moderate only.
18//! Claude: catalog `permits` → ProviderHome sidecar settings + documented
19//! `--settings` (Bash sandbox network; PTY / non-Windows). Kimi/Grok: no
20//! first-party network overlay — keep refuse invented `provider_native`.
21
22use 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
31/// Soft bound on station network allowlist catalog membership (ids only).
32pub use crate::protocol::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES;
33
34/// Soft bound on permit rows per catalog entry (dig2 `MAX_NETWORK_PERMITS` spirit).
35pub const MAX_NETWORK_PERMITS_PER_ENTRY: usize = 64;
36
37/// Soft bound on peer string bytes (non-secret host:port / CIDR sketch).
38pub const MAX_NETWORK_PERMIT_PEER_BYTES: usize = 256;
39
40/// Structured catalog schema version (JSON v2).
41pub const NETWORK_ALLOWLIST_CATALOG_SCHEMA_VERSION: u32 = 2;
42
43/// Env path to an optional allowlist catalog file (absolute regular file).
44/// Unset → empty catalog (deny unknown at resolve). Never a secret store.
45pub 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/// dig2browser-spirit peer+protocol permit (node-local; non-secret).
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct NetworkPermitSketch {
64    pub protocol: NetworkPermitProtocol,
65    pub peer: String,
66}
67
68/// Allowed permit protocols (string form in JSON: `tcp` / `udp`).
69#[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/// Honest, partial provider-native network mapping (node-local).
95///
96/// Only measured / documented knobs appear here. Unknown JSON keys refuse at
97/// load (`deny_unknown_fields`). Claude network is **settings-shaped**
98/// (`sandbox.network.allowedDomains` / `--settings`) — not a Codex-style
99/// bool argv; Kimi has **no** first-party network allowlist on the CLI;
100/// Grok child-network is **profile-shaped** (`--sandbox` /
101/// `restrict_network` in sandbox.toml), Linux-only, and does not block
102/// in-process web/API — not a `provider_native` bool. Omit invented
103/// `claude_*` / `kimi_*` / `grok_*` fields rather than fake flags; resolve
104/// refuses when a required Codex-only mapping is asked of a non-Codex
105/// provider. Inventories:
106/// hatchery-websession-docs
107/// `research/claude-kimi-network-argv-vs-station-catalog-2026-10-02.md`,
108/// `research/grok-linux-child-network-vs-station-catalog-2026-10-02.md`.
109#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct ProviderNativeNetworkSketch {
112    /// Codex `networkAccess`-shaped knob when measured. `None` = unmapped.
113    #[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/// One node-local catalog entry. Wire / inventory still use [`SpawnNetworkAllowlistId`].
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct NetworkAllowlistEntry {
126    pub id: SpawnNetworkAllowlistId,
127    /// Empty = id membership only (v1 id-list behavior).
128    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/// Empty-default station network allowlist catalog.
143#[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    /// Opaque ids in sorted order (LaunchInventory surface).
170    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    /// Insert or replace. Refuses when at capacity and `id` is new.
179    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    /// Convenience: register membership-only id (tests / in-memory config).
195    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            // Capacity: drop extras silently only in FromIterator would hide
208            // overflow — callers that need refuse use `insert` / loader.
209            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
225/// Load catalog from `path` (absolute regular file). Dual format.
226pub 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
248/// Parse catalog text: JSON v2 when first non-comment content is `{`, else
249/// v1 id-list (one id per line).
250pub 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
260/// Resolve optional catalog from an explicit path, else from
261/// [`NETWORK_ALLOWLIST_CATALOG_ENV`], else empty.
262pub 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        // Advance past leading blank lines already handled by trim_start.
340        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            // Non-secret sketch only — refuse obvious credential-shaped keys in peer.
406            if peer.contains('@') && peer.contains(':') && peer.split('@').next().is_some_and(|u| u.contains(':')) {
407                // user:pass@host — refuse; credentials never belong in catalog.
408                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
432/// Whether `provider_native` on a catalog entry can be honored for `provider`
433/// at the resolved [`ApprovalLevel`].
434///
435/// Codex first slice (research `codex-network-access-mapping-2026-10-02`):
436/// `codex_network_access` maps only under **Moderate** (workspace-write).
437/// ReadOnly / FullAuto / Unmanaged refuse clearly rather than silent ambient.
438/// Non-Codex providers refuse Codex-only knobs.
439pub 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
473/// Codex `-c` overlay for legacy `sandbox_workspace_write.network_access`.
474///
475/// Same argv channel as catalog `windows_wsl_setup_acknowledged=true`.
476pub 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
486/// Sidecar settings filename under ProviderHome (`CLAUDE_CONFIG_DIR` home/).
487///
488/// Additive `--settings` path — does not rewrite user `settings.json` or
489/// touch real `~/.claude`.
490pub const CLAUDE_STATION_NETWORK_SETTINGS_FILE: &str =
491    "gate4agent-station-network-settings.json";
492
493/// Vendor Bash sandbox network overlay is macOS / Linux / WSL2 only.
494/// Native Windows (and WSL1 host without Linux node) has no vendor sandbox —
495/// refuse rather than silent no-op or invented argv.
496pub 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
510/// Map catalog permit `peer` → Claude `sandbox.network.allowedDomains` host.
511///
512/// Peers are dig2-spirit `host` / `host:port` / `[ipv6]:port` sketches (never
513/// credentials). Claude settings are **domain-shaped** — ports are stripped;
514/// protocol is ignored. Returns `None` when the peer cannot yield a domain.
515pub 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    // Bracketed IPv6: `[::1]:443` → `[::1]`; `[::1]` stays.
521    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    // host:port when trailing segment is all digits; else whole peer is domain
533    // (incl. wildcards like `*.npmjs.org`).
534    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
546/// Deduplicated Claude `allowedDomains` from catalog permits (stable order).
547pub 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
561/// Settings JSON fragment for Claude Bash sandbox network (vendor documented
562/// `sandbox.enabled` + `sandbox.network.allowedDomains`).
563pub 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    // Stable, compact — file under ProviderHome; not pretty-printed secrets.
573    serde_json::to_string(&value).expect("claude station network settings serialize")
574}
575
576/// Documented Claude CLI argv: `--settings <absolute-path>`.
577pub 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
584/// Resolve provider-native launch overlay argv for a catalog entry, or refuse.
585///
586/// Empty when `native` is empty / unset. Codex Moderate + `Some(flag)` yields
587/// [`codex_network_access_config_overlay`]. Other cases refuse via
588/// [`provider_native_mapping_supported`].
589///
590/// Claude settings-domain overlay is **not** provider_native — see
591/// [`claude_allowed_domains_from_permits`] / server resolve path.
592pub 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        // Claude/Kimi/Grok have no Codex-style provider_native bool — invented
783        // keys must refuse at load (deny_unknown_fields), same honesty as
784        // UnsupportedNetworkAllowlistMapping at resolve for non-Codex.
785        // Grok child-network is --sandbox / sandbox.toml shaped (Linux-only).
786        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}