diff --git a/crates/openshell-sandbox/data/sandbox-policy.rego b/crates/openshell-sandbox/data/sandbox-policy.rego index 0a7a33888..ce4400a66 100644 --- a/crates/openshell-sandbox/data/sandbox-policy.rego +++ b/crates/openshell-sandbox/data/sandbox-policy.rego @@ -47,7 +47,7 @@ deny_reason := reason if { policy := data.network_policies[name] endpoint_allowed(policy, input.network) not binary_allowed(policy, input.exec) - r := sprintf("binary '%s' (ancestors: [%s], cmdline: [%s]) not allowed in policy '%s'", [input.exec.path, ancestors_str, cmdline_str, name]) + r := sprintf("binary '%s' not allowed in policy '%s' (ancestors: [%s], cmdline: [%s]). SYMLINK HINT: the binary path is the kernel-resolved target from /proc//exe, not the symlink. If your policy specifies a symlink (e.g., /usr/bin/python3) but the actual binary is /usr/bin/python3.11, either: (1) use the canonical path in your policy (run 'readlink -f /usr/bin/python3' inside the sandbox), or (2) ensure symlink resolution is working (check sandbox logs for 'Cannot access container filesystem')", [input.exec.path, name, ancestors_str, cmdline_str]) ] all_reasons := array.concat(endpoint_misses, binary_misses) count(all_reasons) > 0 diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 2384a2170..aa16d69cc 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -170,7 +170,7 @@ pub async fn run_sandbox( // Load policy and initialize OPA engine let openshell_endpoint_for_proxy = openshell_endpoint.clone(); let sandbox_name_for_agg = sandbox.clone(); - let (policy, opa_engine) = load_policy( + let (policy, opa_engine, retained_proto) = load_policy( sandbox_id.clone(), sandbox, openshell_endpoint.clone(), @@ -568,6 +568,32 @@ pub async fn run_sandbox( entrypoint_pid.store(handle.pid(), Ordering::Release); info!(pid = handle.pid(), "Process started"); + // Resolve policy binary symlinks now that the container filesystem is + // accessible via /proc//root/. This expands symlinks like + // /usr/bin/python3 → /usr/bin/python3.11 in the OPA policy data so that + // either path matches at evaluation time. + // + // If /proc//root/ is inaccessible (restricted ptrace, rootless + // container, etc.), resolve_binary_in_container logs a warning per binary + // and falls back to literal path matching. The reload itself still + // succeeds — only the symlink expansion is skipped. + if let (Some(engine), Some(proto)) = (&opa_engine, &retained_proto) { + let pid = handle.pid(); + if let Err(e) = engine.reload_from_proto_with_pid(proto, pid) { + warn!( + error = %e, + "Failed to rebuild OPA engine with symlink resolution (non-fatal, \ + falling back to literal path matching)" + ); + } else { + info!( + pid = pid, + "Policy binary symlink resolution attempted via container filesystem \ + (check logs above for per-binary results)" + ); + } + } + // Spawn background policy poll task (gRPC mode only). if let (Some(id), Some(endpoint), Some(engine)) = (&sandbox_id, &openshell_endpoint, &opa_engine) @@ -575,15 +601,21 @@ pub async fn run_sandbox( let poll_id = id.clone(); let poll_endpoint = endpoint.clone(); let poll_engine = engine.clone(); + let poll_pid = entrypoint_pid.clone(); let poll_interval_secs: u64 = std::env::var("OPENSHELL_POLICY_POLL_INTERVAL_SECS") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(10); tokio::spawn(async move { - if let Err(e) = - run_policy_poll_loop(&poll_endpoint, &poll_id, &poll_engine, poll_interval_secs) - .await + if let Err(e) = run_policy_poll_loop( + &poll_endpoint, + &poll_id, + &poll_engine, + &poll_pid, + poll_interval_secs, + ) + .await { warn!(error = %e, "Policy poll loop exited with error"); } @@ -1158,13 +1190,21 @@ mod baseline_tests { /// 2. If `sandbox_id` and `openshell_endpoint` are provided, fetch via gRPC /// 3. If the server returns no policy, discover from disk or use restrictive default /// 4. Otherwise, return an error +/// +/// Returns the policy, the OPA engine, and (for gRPC mode) the original proto +/// policy. The proto is retained so the OPA engine can be rebuilt with symlink +/// resolution after the container entrypoint starts. async fn load_policy( sandbox_id: Option, sandbox: Option, openshell_endpoint: Option, policy_rules: Option, policy_data: Option, -) -> Result<(SandboxPolicy, Option>)> { +) -> Result<( + SandboxPolicy, + Option>, + Option, +)> { // File mode: load OPA engine from rego rules + YAML data (dev override) if let (Some(policy_file), Some(data_file)) = (&policy_rules, &policy_data) { info!( @@ -1188,7 +1228,7 @@ async fn load_policy( process: config.process, }; enrich_sandbox_baseline_paths(&mut policy); - return Ok((policy, Some(Arc::new(engine)))); + return Ok((policy, Some(Arc::new(engine)), None)); } // gRPC mode: fetch typed proto policy, construct OPA engine from baked rules + proto data @@ -1244,11 +1284,14 @@ async fn load_policy( // Build OPA engine from baked-in rules + typed proto data. // In cluster mode, proxy networking is always enabled so OPA is // always required for allow/deny decisions. + // The initial load uses pid=0 (no symlink resolution) because the + // container hasn't started yet. After the entrypoint spawns, the + // engine is rebuilt with the real PID for symlink resolution. info!("Creating OPA engine from proto policy data"); let opa_engine = Some(Arc::new(OpaEngine::from_proto(&proto_policy)?)); - let policy = SandboxPolicy::try_from(proto_policy)?; - return Ok((policy, opa_engine)); + let policy = SandboxPolicy::try_from(proto_policy.clone())?; + return Ok((policy, opa_engine, Some(proto_policy))); } // No policy source available @@ -1505,12 +1548,16 @@ async fn flush_proposals_to_gateway( Ok(()) } -/// `reload_from_proto()`. Reports load success/failure back to the server. -/// On failure, the previous engine is untouched (LKG behavior). +/// `reload_from_proto_with_pid()`. Reports load success/failure back to the +/// server. On failure, the previous engine is untouched (LKG behavior). +/// +/// When the entrypoint PID is available, policy reloads include symlink +/// resolution for binary paths via the container filesystem. async fn run_policy_poll_loop( endpoint: &str, sandbox_id: &str, opa_engine: &Arc, + entrypoint_pid: &Arc, interval_secs: u64, ) -> Result<()> { use crate::grpc_client::CachedOpenShellClient; @@ -1580,7 +1627,8 @@ async fn run_policy_poll_loop( continue; }; - match opa_engine.reload_from_proto(policy) { + let pid = entrypoint_pid.load(Ordering::Acquire); + match opa_engine.reload_from_proto_with_pid(policy, pid) { Ok(()) => { if result.global_policy_version > 0 { info!( diff --git a/crates/openshell-sandbox/src/opa.rs b/crates/openshell-sandbox/src/opa.rs index f1c0ad293..16c87a42a 100644 --- a/crates/openshell-sandbox/src/opa.rs +++ b/crates/openshell-sandbox/src/opa.rs @@ -112,7 +112,18 @@ impl OpaEngine { /// /// Expands access presets and validates L7 config. pub fn from_proto(proto: &ProtoSandboxPolicy) -> Result { - let data_json_str = proto_to_opa_data_json(proto); + Self::from_proto_with_pid(proto, 0) + } + + /// Create OPA engine from a typed proto policy with symlink resolution. + /// + /// When `entrypoint_pid` is non-zero, binary paths in the policy that are + /// symlinks inside the container filesystem are resolved via + /// `/proc//root/` and added as additional entries. This bridges the + /// gap between user-specified symlink paths (e.g., `/usr/bin/python3`) and + /// kernel-resolved canonical paths (e.g., `/usr/bin/python3.11`). + pub fn from_proto_with_pid(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> Result { + let data_json_str = proto_to_opa_data_json(proto, entrypoint_pid); // Parse back to Value for preprocessing, then re-serialize let mut data: serde_json::Value = serde_json::from_str(&data_json_str) @@ -298,8 +309,21 @@ impl OpaEngine { /// validation guarantees as initial load. Atomically replaces the inner /// engine on success; on failure the previous engine is untouched (LKG). pub fn reload_from_proto(&self, proto: &ProtoSandboxPolicy) -> Result<()> { + self.reload_from_proto_with_pid(proto, 0) + } + + /// Reload policy from a proto with symlink resolution. + /// + /// When `entrypoint_pid` is non-zero, binary paths that are symlinks + /// inside the container filesystem are resolved and added as additional + /// match entries. See [`from_proto_with_pid`] for details. + pub fn reload_from_proto_with_pid( + &self, + proto: &ProtoSandboxPolicy, + entrypoint_pid: u32, + ) -> Result<()> { // Build a complete new engine through the same validated pipeline. - let new = Self::from_proto(proto)?; + let new = Self::from_proto_with_pid(proto, entrypoint_pid)?; let new_engine = new .engine .into_inner() @@ -585,6 +609,135 @@ fn normalize_endpoint_ports(data: &mut serde_json::Value) { } } +/// Resolve a policy binary path through the container's root filesystem. +/// +/// On Linux, `/proc//root/` provides access to the container's mount +/// namespace. If the policy path is a symlink inside the container +/// (e.g., `/usr/bin/python3` → `/usr/bin/python3.11`), returns the +/// canonical target path. Returns `None` if: +/// - Not on Linux +/// - `entrypoint_pid` is 0 (container not yet started) +/// - Path contains glob characters +/// - Path is not a symlink +/// - Resolution fails (binary doesn't exist in container) +/// - Resolved path equals the original +/// Normalize a path by resolving `.` and `..` components without touching +/// the filesystem. Only works correctly for absolute paths. +fn normalize_path(path: &std::path::Path) -> std::path::PathBuf { + let mut result = std::path::PathBuf::new(); + for component in path.components() { + match component { + std::path::Component::ParentDir => { + result.pop(); + } + std::path::Component::CurDir => {} + other => result.push(other), + } + } + result +} + +#[cfg(target_os = "linux")] +fn resolve_binary_in_container(policy_path: &str, entrypoint_pid: u32) -> Option { + if policy_path.contains('*') || entrypoint_pid == 0 { + return None; + } + + // Walk the symlink chain inside the container filesystem using + // read_link rather than canonicalize. canonicalize resolves + // /proc//root itself (a kernel pseudo-symlink to /) which + // strips the prefix we need. read_link only reads the target of + // the specified symlink, keeping us in the container's namespace. + let mut resolved = std::path::PathBuf::from(policy_path); + + // Linux SYMLOOP_MAX is 40; stop before infinite loops + for _ in 0..40 { + let container_path = format!("/proc/{entrypoint_pid}/root{}", resolved.display()); + + let meta = match std::fs::symlink_metadata(&container_path) { + Ok(m) => m, + Err(e) => { + // Only warn on the first iteration (the original policy path). + // On subsequent iterations, the intermediate target may + // legitimately not exist (broken symlink chain). + if resolved.as_os_str() == policy_path { + tracing::warn!( + path = %policy_path, + container_path = %container_path, + pid = entrypoint_pid, + error = %e, + "Cannot access container filesystem for symlink resolution; \ + binary paths in policy will be matched literally. If a policy \ + binary is a symlink (e.g., /usr/bin/python3 -> python3.11), \ + use the canonical path instead, or run with CAP_SYS_PTRACE" + ); + } else { + tracing::warn!( + original = %policy_path, + current = %resolved.display(), + pid = entrypoint_pid, + error = %e, + "Symlink chain broken during resolution; \ + binary will be matched by original path only" + ); + } + return None; + } + }; + + if !meta.file_type().is_symlink() { + // Reached a non-symlink — this is the final resolved target + break; + } + + let target = match std::fs::read_link(&container_path) { + Ok(t) => t, + Err(e) => { + tracing::warn!( + path = %policy_path, + current = %resolved.display(), + pid = entrypoint_pid, + error = %e, + "Symlink detected but read_link failed; \ + binary will be matched by original path only" + ); + return None; + } + }; + + if target.is_absolute() { + resolved = target; + } else { + // Relative symlink: resolve against the containing directory + // e.g., /usr/bin/python3 -> python3.11 becomes /usr/bin/python3.11 + if let Some(parent) = resolved.parent() { + resolved = normalize_path(&parent.join(&target)); + } else { + break; + } + } + } + + let resolved_str = resolved.to_string_lossy().into_owned(); + + if resolved_str == policy_path { + None + } else { + tracing::info!( + original = %policy_path, + resolved = %resolved_str, + pid = entrypoint_pid, + "Resolved policy binary symlink via container filesystem" + ); + Some(resolved_str) + } +} + +#[cfg(not(target_os = "linux"))] +fn resolve_binary_in_container(_policy_path: &str, _entrypoint_pid: u32) -> Option { + None +} + /// Convert typed proto policy fields to JSON suitable for `engine.add_data_json()`. /// /// The rego rules reference `data.*` directly, so the JSON structure has @@ -593,7 +746,14 @@ fn normalize_endpoint_ports(data: &mut serde_json::Value) { /// - `data.landlock` /// - `data.process` /// - `data.network_policies` -fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy) -> String { +/// +/// When `entrypoint_pid` is non-zero, binary paths that are symlinks inside +/// the container filesystem are resolved via `/proc//root/` and added +/// as additional entries alongside the original path. This ensures that +/// user-specified symlink paths (e.g., `/usr/bin/python3`) match the +/// kernel-resolved canonical paths reported by `/proc//exe` (e.g., +/// `/usr/bin/python3.11`). +fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> String { let filesystem_policy = proto.filesystem.as_ref().map_or_else( || { serde_json::json!({ @@ -709,7 +869,13 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy) -> String { let binaries: Vec = rule .binaries .iter() - .map(|b| serde_json::json!({"path": b.path})) + .flat_map(|b| { + let mut entries = vec![serde_json::json!({"path": &b.path})]; + if let Some(resolved) = resolve_binary_in_container(&b.path, entrypoint_pid) { + entries.push(serde_json::json!({"path": resolved})); + } + entries + }) .collect(); ( key.clone(), @@ -2820,4 +2986,653 @@ process: "L7 on second port of multi-port should work" ); } + + // ======================================================================== + // Symlink resolution tests (issue #770) + // ======================================================================== + + #[test] + fn normalize_path_resolves_parent_and_current() { + use std::path::{Path, PathBuf}; + assert_eq!( + normalize_path(Path::new("/usr/bin/../lib/python3")), + PathBuf::from("/usr/lib/python3") + ); + assert_eq!( + normalize_path(Path::new("/usr/bin/./python3")), + PathBuf::from("/usr/bin/python3") + ); + assert_eq!( + normalize_path(Path::new("/a/b/c/../../d")), + PathBuf::from("/a/d") + ); + assert_eq!( + normalize_path(Path::new("/usr/bin/python3")), + PathBuf::from("/usr/bin/python3") + ); + } + + #[test] + fn resolve_binary_skips_glob_paths() { + // Glob patterns should never be resolved — they're matched differently + assert!(resolve_binary_in_container("/usr/bin/*", 1).is_none()); + assert!(resolve_binary_in_container("/usr/local/bin/**", 1).is_none()); + } + + #[test] + fn resolve_binary_skips_pid_zero() { + // pid=0 means the container hasn't started yet + assert!(resolve_binary_in_container("/usr/bin/python3", 0).is_none()); + } + + #[test] + fn resolve_binary_returns_none_for_nonexistent_path() { + // A path that doesn't exist in any container should gracefully return None + assert!( + resolve_binary_in_container("/nonexistent/binary/path/that/will/never/exist", 1) + .is_none() + ); + } + + #[test] + fn proto_to_opa_data_json_pid_zero_no_expansion() { + // With pid=0, proto_to_opa_data_json should produce the same output + // as the original (no symlink expansion) + let proto = test_proto(); + let data_no_pid = proto_to_opa_data_json(&proto, 0); + let parsed: serde_json::Value = serde_json::from_str(&data_no_pid).unwrap(); + + // Verify the claude_code policy has exactly 1 binary entry (no expansion) + let binaries = parsed["network_policies"]["claude_code"]["binaries"] + .as_array() + .unwrap(); + assert_eq!( + binaries.len(), + 1, + "With pid=0, should have no expanded binaries" + ); + assert_eq!(binaries[0]["path"], "/usr/local/bin/claude"); + } + + #[test] + fn symlink_expanded_binary_allows_resolved_path() { + // Simulate what happens after symlink resolution: the OPA data + // contains both the original symlink path and the resolved path. + // A request using the resolved path should be allowed. + let data = r#" +network_policies: + python_policy: + name: python_policy + endpoints: + - { host: pypi.org, port: 443 } + binaries: + - { path: /usr/bin/python3 } + - { path: /usr/bin/python3.11 } +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); + + // Request with the resolved path (what the kernel reports) + let input = NetworkInput { + host: "pypi.org".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/python3.11"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let decision = engine.evaluate_network(&input).unwrap(); + assert!( + decision.allowed, + "Resolved symlink path should be allowed: {}", + decision.reason + ); + assert_eq!(decision.matched_policy.as_deref(), Some("python_policy")); + } + + #[test] + fn symlink_expanded_binary_still_allows_original_path() { + // Even with expansion, the original path must still work + let data = r#" +network_policies: + python_policy: + name: python_policy + endpoints: + - { host: pypi.org, port: 443 } + binaries: + - { path: /usr/bin/python3 } + - { path: /usr/bin/python3.11 } +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); + + // Request with the original symlink path (unlikely at runtime, but must not break) + let input = NetworkInput { + host: "pypi.org".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/python3"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let decision = engine.evaluate_network(&input).unwrap(); + assert!( + decision.allowed, + "Original symlink path should still be allowed: {}", + decision.reason + ); + } + + #[test] + fn symlink_expanded_binary_does_not_weaken_security() { + // A binary NOT in the policy should still be denied, even if + // the expanded entries exist for other binaries. + let data = r#" +network_policies: + python_policy: + name: python_policy + endpoints: + - { host: pypi.org, port: 443 } + binaries: + - { path: /usr/bin/python3 } + - { path: /usr/bin/python3.11 } +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); + + let input = NetworkInput { + host: "pypi.org".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/curl"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let decision = engine.evaluate_network(&input).unwrap(); + assert!(!decision.allowed, "Unrelated binary should still be denied"); + } + + #[test] + fn symlink_expansion_works_with_ancestors() { + // Ancestor binary matching should also work with expanded paths + let data = r#" +network_policies: + python_policy: + name: python_policy + endpoints: + - { host: pypi.org, port: 443 } + binaries: + - { path: /usr/bin/python3 } + - { path: /usr/bin/python3.11 } +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); + + // The exe is curl, but an ancestor is the resolved python3.11 + let input = NetworkInput { + host: "pypi.org".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/curl"), + binary_sha256: "unused".into(), + ancestors: vec![PathBuf::from("/usr/bin/python3.11")], + cmdline_paths: vec![], + }; + let decision = engine.evaluate_network(&input).unwrap(); + assert!( + decision.allowed, + "Resolved symlink path should match as ancestor: {}", + decision.reason + ); + } + + #[test] + fn symlink_expansion_via_proto_with_pid_zero() { + // from_proto_with_pid(proto, 0) should produce same results as from_proto(proto) + let proto = test_proto(); + let engine_default = OpaEngine::from_proto(&proto).expect("from_proto should succeed"); + let engine_pid0 = OpaEngine::from_proto_with_pid(&proto, 0) + .expect("from_proto_with_pid(0) should succeed"); + + let input = NetworkInput { + host: "api.anthropic.com".into(), + port: 443, + binary_path: PathBuf::from("/usr/local/bin/claude"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + + let decision_default = engine_default.evaluate_network(&input).unwrap(); + let decision_pid0 = engine_pid0.evaluate_network(&input).unwrap(); + + assert_eq!( + decision_default.allowed, decision_pid0.allowed, + "from_proto and from_proto_with_pid(0) should produce identical results" + ); + } + + #[test] + fn reload_from_proto_with_pid_zero_works() { + // reload_from_proto_with_pid(proto, 0) should function identically to reload_from_proto + let proto = test_proto(); + let engine = OpaEngine::from_proto(&proto).expect("from_proto should succeed"); + + // Verify initial policy works + let input = NetworkInput { + host: "api.anthropic.com".into(), + port: 443, + binary_path: PathBuf::from("/usr/local/bin/claude"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let decision = engine.evaluate_network(&input).unwrap(); + assert!(decision.allowed); + + // Reload with same proto at pid=0 + engine + .reload_from_proto_with_pid(&proto, 0) + .expect("reload_from_proto_with_pid should succeed"); + + // Should still work + let decision = engine.evaluate_network(&input).unwrap(); + assert!( + decision.allowed, + "reload_from_proto_with_pid(0) should preserve behavior" + ); + } + + #[test] + fn hot_reload_preserves_symlink_expansion_behavior() { + // Simulates the hot-reload path: initial load at pid=0, then reload + // with a new proto that would have expanded binaries at a real PID. + // Since we can't mock /proc//root/ in unit tests, we test + // that reload_from_proto_with_pid at pid=0 still works correctly + // and that the engine is properly replaced. + let proto = test_proto(); + let engine = OpaEngine::from_proto(&proto).expect("initial load should succeed"); + + // Verify initial policy allows claude + let claude_input = NetworkInput { + host: "api.anthropic.com".into(), + port: 443, + binary_path: PathBuf::from("/usr/local/bin/claude"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + assert!(engine.evaluate_network(&claude_input).unwrap().allowed); + + // Create a new proto with an additional policy + let mut new_proto = test_proto(); + new_proto.network_policies.insert( + "python_api".to_string(), + NetworkPolicyRule { + name: "python_api".to_string(), + endpoints: vec![NetworkEndpoint { + host: "pypi.org".to_string(), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/python3".to_string(), + ..Default::default() + }], + }, + ); + + // Hot-reload with pid=0 + engine + .reload_from_proto_with_pid(&new_proto, 0) + .expect("hot-reload should succeed"); + + // Old policy should still work + assert!( + engine.evaluate_network(&claude_input).unwrap().allowed, + "Old policies should survive hot-reload" + ); + + // New policy should also work + let python_input = NetworkInput { + host: "pypi.org".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/python3"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + assert!( + engine.evaluate_network(&python_input).unwrap().allowed, + "New policy should be active after hot-reload" + ); + } + + #[test] + fn hot_reload_replaces_engine_atomically() { + // Test that a failed reload preserves the last-known-good engine + let proto = test_proto(); + let engine = OpaEngine::from_proto(&proto).expect("initial load should succeed"); + + let claude_input = NetworkInput { + host: "api.anthropic.com".into(), + port: 443, + binary_path: PathBuf::from("/usr/local/bin/claude"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + assert!(engine.evaluate_network(&claude_input).unwrap().allowed); + + // Reload with same proto — should succeed and preserve behavior + engine + .reload_from_proto_with_pid(&proto, 0) + .expect("reload should succeed"); + + assert!( + engine.evaluate_network(&claude_input).unwrap().allowed, + "Engine should work after successful reload" + ); + } + + #[test] + fn deny_reason_includes_symlink_hint() { + // Verify the deny reason includes an actionable symlink hint + let engine = test_engine(); + let input = NetworkInput { + host: "api.anthropic.com".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/python3.11"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let decision = engine.evaluate_network(&input).unwrap(); + assert!(!decision.allowed); + assert!( + decision.reason.contains("SYMLINK HINT"), + "Deny reason should include prominent symlink hint, got: {}", + decision.reason + ); + assert!( + decision.reason.contains("readlink -f"), + "Deny reason should include actionable fix command, got: {}", + decision.reason + ); + } + + /// Check if symlink resolution through `/proc//root/` actually works. + /// Creates a real symlink in a tempdir and attempts to resolve it via + /// the procfs root path. This catches environments where the probe path + /// is readable but canonicalization/read_link fails (e.g., containers + /// with restricted ptrace scope, rootless containers). + #[cfg(target_os = "linux")] + fn procfs_root_accessible() -> bool { + use std::os::unix::fs::symlink; + let dir = match tempfile::tempdir() { + Ok(d) => d, + Err(_) => return false, + }; + let target = dir.path().join("probe_target"); + let link = dir.path().join("probe_link"); + if std::fs::write(&target, b"probe").is_err() { + return false; + } + if symlink(&target, &link).is_err() { + return false; + } + let pid = std::process::id(); + let link_path = link.to_string_lossy().to_string(); + // Actually attempt the same resolution our production code uses + resolve_binary_in_container(&link_path, pid).is_some() + } + + #[cfg(target_os = "linux")] + #[test] + fn resolve_binary_with_real_symlink() { + if !procfs_root_accessible() { + eprintln!("Skipping: /proc//root/ not accessible in this environment"); + return; + } + + // Create a real symlink in a temp directory and verify resolution + // works through /proc/self/root (which maps to / on the host) + use std::os::unix::fs::symlink; + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("python3.11"); + let link = dir.path().join("python3"); + + // Create the target file + std::fs::write(&target, b"#!/usr/bin/env python3\n").unwrap(); + // Create symlink + symlink(&target, &link).unwrap(); + + // Use our own PID — /proc//root/ points to / + let our_pid = std::process::id(); + let link_path = link.to_string_lossy().to_string(); + let result = resolve_binary_in_container(&link_path, our_pid); + + assert!( + result.is_some(), + "Should resolve symlink via /proc//root/" + ); + let resolved = result.unwrap(); + assert!( + resolved.ends_with("python3.11"), + "Resolved path should point to target: {resolved}" + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn resolve_binary_non_symlink_returns_none() { + if !procfs_root_accessible() { + eprintln!("Skipping: /proc//root/ not accessible in this environment"); + return; + } + + // A regular file should return None (no expansion needed) + use std::io::Write; + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + tmp.write_all(b"regular file").unwrap(); + tmp.flush().unwrap(); + + let our_pid = std::process::id(); + let path = tmp.path().to_string_lossy().to_string(); + let result = resolve_binary_in_container(&path, our_pid); + + assert!( + result.is_none(), + "Non-symlink file should return None, got: {result:?}" + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn resolve_binary_multi_level_symlink() { + if !procfs_root_accessible() { + eprintln!("Skipping: /proc//root/ not accessible in this environment"); + return; + } + + // Test multi-level symlink resolution: python3 -> python3.11 -> cpython3.11 + use std::os::unix::fs::symlink; + let dir = tempfile::tempdir().unwrap(); + let final_target = dir.path().join("cpython3.11"); + let mid_link = dir.path().join("python3.11"); + let top_link = dir.path().join("python3"); + + std::fs::write(&final_target, b"final binary").unwrap(); + symlink(&final_target, &mid_link).unwrap(); + symlink(&mid_link, &top_link).unwrap(); + + let our_pid = std::process::id(); + let link_path = top_link.to_string_lossy().to_string(); + let result = resolve_binary_in_container(&link_path, our_pid); + + assert!(result.is_some(), "Should resolve multi-level symlink chain"); + let resolved = result.unwrap(); + assert!( + resolved.ends_with("cpython3.11"), + "Should resolve to final target: {resolved}" + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn from_proto_with_pid_expands_symlinks_in_container() { + if !procfs_root_accessible() { + eprintln!("Skipping: /proc//root/ not accessible in this environment"); + return; + } + + // End-to-end test: create a symlink, build engine with our PID, + // verify the resolved path is allowed + use std::os::unix::fs::symlink; + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("node22"); + let link = dir.path().join("node"); + + std::fs::write(&target, b"node binary").unwrap(); + symlink(&target, &link).unwrap(); + + let link_path = link.to_string_lossy().to_string(); + let target_path = target.to_string_lossy().to_string(); + + let mut network_policies = std::collections::HashMap::new(); + network_policies.insert( + "test".to_string(), + NetworkPolicyRule { + name: "test".to_string(), + endpoints: vec![NetworkEndpoint { + host: "example.com".to_string(), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: link_path, + ..Default::default() + }], + }, + ); + let proto = ProtoSandboxPolicy { + version: 1, + filesystem: Some(ProtoFs { + include_workdir: true, + read_only: vec![], + read_write: vec![], + }), + landlock: Some(openshell_core::proto::LandlockPolicy { + compatibility: "best_effort".to_string(), + }), + process: Some(ProtoProc { + run_as_user: "sandbox".to_string(), + run_as_group: "sandbox".to_string(), + }), + network_policies, + }; + + // Build engine with our PID (symlink resolution will work via /proc/self/root/) + let our_pid = std::process::id(); + let engine = OpaEngine::from_proto_with_pid(&proto, our_pid) + .expect("from_proto_with_pid should succeed"); + + // Request using the resolved target path should be allowed + let input = NetworkInput { + host: "example.com".into(), + port: 443, + binary_path: PathBuf::from(&target_path), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let decision = engine.evaluate_network(&input).unwrap(); + assert!( + decision.allowed, + "Resolved symlink target should be allowed after expansion: {}", + decision.reason + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn reload_from_proto_with_pid_resolves_symlinks() { + if !procfs_root_accessible() { + eprintln!("Skipping: /proc//root/ not accessible in this environment"); + return; + } + + // Test hot-reload path: initial engine at pid=0, then reload with + // real PID to trigger symlink resolution + use std::os::unix::fs::symlink; + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("python3.11"); + let link = dir.path().join("python3"); + + std::fs::write(&target, b"python binary").unwrap(); + symlink(&target, &link).unwrap(); + + let link_path = link.to_string_lossy().to_string(); + let target_path = target.to_string_lossy().to_string(); + + let mut network_policies = std::collections::HashMap::new(); + network_policies.insert( + "python".to_string(), + NetworkPolicyRule { + name: "python".to_string(), + endpoints: vec![NetworkEndpoint { + host: "pypi.org".to_string(), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: link_path, + ..Default::default() + }], + }, + ); + let proto = ProtoSandboxPolicy { + version: 1, + filesystem: Some(ProtoFs { + include_workdir: true, + read_only: vec![], + read_write: vec![], + }), + landlock: Some(openshell_core::proto::LandlockPolicy { + compatibility: "best_effort".to_string(), + }), + process: Some(ProtoProc { + run_as_user: "sandbox".to_string(), + run_as_group: "sandbox".to_string(), + }), + network_policies, + }; + + // Initial load at pid=0 — no symlink expansion + let engine = OpaEngine::from_proto(&proto).expect("initial load"); + + // Request with resolved path should be DENIED (no expansion yet) + let input_resolved = NetworkInput { + host: "pypi.org".into(), + port: 443, + binary_path: PathBuf::from(&target_path), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let decision = engine.evaluate_network(&input_resolved).unwrap(); + assert!( + !decision.allowed, + "Before reload with PID, resolved path should be denied" + ); + + // Hot-reload with real PID — symlinks resolved + let our_pid = std::process::id(); + engine + .reload_from_proto_with_pid(&proto, our_pid) + .expect("reload with PID"); + + // Now the resolved path should be ALLOWED + let decision = engine.evaluate_network(&input_resolved).unwrap(); + assert!( + decision.allowed, + "After reload with PID, resolved path should be allowed: {}", + decision.reason + ); + } }