From ab7f6a273fad82f4fc99a120eed087fc01c1742d Mon Sep 17 00:00:00 2001 From: John Myers Date: Mon, 6 Apr 2026 15:44:15 -0700 Subject: [PATCH 1/5] fix(sandbox): resolve symlinked binary paths in network policy matching Policy binary paths specified as symlinks (e.g., /usr/bin/python3) were silently denied because the kernel reports the canonical path via /proc//exe (e.g., /usr/bin/python3.11). The strict string equality in Rego never matched. Expand policy binary paths by resolving symlinks through the container filesystem (/proc//root/) after the entrypoint starts. The OPA data now contains both the original and resolved paths, so Rego's existing strict equality check naturally matches either. - Add resolve_binary_in_container() helper for Linux symlink resolution - Add from_proto_with_pid() and reload_from_proto_with_pid() to OpaEngine - Trigger one-shot OPA rebuild after entrypoint_pid is stored - Thread entrypoint_pid through run_policy_poll_loop for hot-reloads - Improve deny reason with symlink debugging hint - Add 18 new tests including hot-reload and Linux symlink e2e tests Closes #770 --- .../data/sandbox-policy.rego | 2 +- crates/openshell-sandbox/src/lib.rs | 53 +- crates/openshell-sandbox/src/opa.rs | 670 +++++++++++++++++- 3 files changed, 712 insertions(+), 13 deletions(-) diff --git a/crates/openshell-sandbox/data/sandbox-policy.rego b/crates/openshell-sandbox/data/sandbox-policy.rego index 0a7a33888..415bf83ca 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' (ancestors: [%s], cmdline: [%s]) not allowed in policy '%s' (hint: binary path is kernel-resolved via /proc//exe; if you specified a symlink like /usr/bin/python3, the actual binary may be /usr/bin/python3.11)", [input.exec.path, ancestors_str, cmdline_str, name]) ] 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 f9e8fb4c5..59fa14bfa 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -247,7 +247,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(), @@ -714,6 +714,25 @@ pub async fn run_sandbox( .build() ); + // 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 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 resolve binary symlinks in policy (non-fatal)" + ); + } else { + info!( + pid = pid, + "Resolved policy binary symlinks via container filesystem" + ); + } + } + // Spawn background policy poll task (gRPC mode only). if let (Some(id), Some(endpoint), Some(engine)) = (&sandbox_id, &openshell_endpoint, &opa_engine) @@ -722,6 +741,7 @@ pub async fn run_sandbox( let poll_endpoint = endpoint.clone(); let poll_engine = engine.clone(); let poll_ocsf_enabled = ocsf_enabled.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()) @@ -732,6 +752,7 @@ pub async fn run_sandbox( &poll_endpoint, &poll_id, &poll_engine, + &poll_pid, poll_interval_secs, &poll_ocsf_enabled, ) @@ -1426,13 +1447,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) { ocsf_emit!(ConfigStateChangeBuilder::new(ocsf_ctx()) @@ -1461,7 +1490,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 @@ -1524,11 +1553,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 @@ -1838,12 +1870,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, ocsf_enabled: &std::sync::atomic::AtomicBool, ) -> Result<()> { @@ -1924,7 +1960,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 { ocsf_emit!(ConfigStateChangeBuilder::new(ocsf_ctx()) diff --git a/crates/openshell-sandbox/src/opa.rs b/crates/openshell-sandbox/src/opa.rs index 970c9226c..c2f61336b 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) @@ -306,8 +317,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() @@ -601,6 +625,59 @@ 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 +#[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; + } + + let container_path = format!("/proc/{entrypoint_pid}/root{policy_path}"); + + // Quick check: is this even a symlink? + let meta = std::fs::symlink_metadata(&container_path).ok()?; + if !meta.file_type().is_symlink() { + return None; + } + + // Resolve through the container's filesystem (handles multi-level symlinks) + let canonical = std::fs::canonicalize(&container_path).ok()?; + + // Strip the /proc//root prefix to get the in-container absolute path + let prefix = format!("/proc/{entrypoint_pid}/root"); + let in_container = canonical.strip_prefix(&prefix).ok()?; + let resolved = std::path::PathBuf::from("/").join(in_container); + let resolved_str = resolved.to_string_lossy().into_owned(); + + if resolved_str == policy_path { + None + } else { + tracing::debug!( + 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 @@ -609,7 +686,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!({ @@ -725,7 +809,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(), @@ -2836,4 +2926,576 @@ process: "L7 on second port of multi-port should work" ); } + + // ======================================================================== + // Symlink resolution tests (issue #770) + // ======================================================================== + + #[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 the symlink hint for debugging + 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("kernel-resolved"), + "Deny reason should include symlink hint, got: {}", + decision.reason + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn resolve_binary_with_real_symlink() { + // 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() { + // 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() { + // 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() { + // 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() { + // 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 + ); + } } From b0b0ebd21c67fdd4a47c22c3dfbd78959c409bce Mon Sep 17 00:00:00 2001 From: John Myers Date: Mon, 6 Apr 2026 15:52:17 -0700 Subject: [PATCH 2/5] fix(sandbox): skip procfs-dependent tests when /proc//root/ is inaccessible The Linux-specific symlink resolution tests depend on /proc//root/ being readable, which requires CAP_SYS_PTRACE or permissive ptrace scope. This is unavailable in CI containers, rootless containers, and hardened hosts. Add a procfs_root_accessible() guard that skips these tests gracefully instead of failing. --- crates/openshell-sandbox/src/opa.rs | 36 +++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/crates/openshell-sandbox/src/opa.rs b/crates/openshell-sandbox/src/opa.rs index c2f61336b..98adb18df 100644 --- a/crates/openshell-sandbox/src/opa.rs +++ b/crates/openshell-sandbox/src/opa.rs @@ -3270,9 +3270,25 @@ network_policies: ); } + /// Check if `/proc//root/` is accessible for the current process. + /// In CI containers or restricted environments, this path may not be + /// readable even for the process's own PID. Tests that depend on + /// procfs root access should skip gracefully when this returns false. + #[cfg(target_os = "linux")] + fn procfs_root_accessible() -> bool { + let pid = std::process::id(); + let probe = format!("/proc/{pid}/root/tmp"); + std::fs::symlink_metadata(&probe).is_ok() + } + #[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; @@ -3304,6 +3320,11 @@ network_policies: #[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(); @@ -3323,6 +3344,11 @@ network_policies: #[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(); @@ -3349,6 +3375,11 @@ network_policies: #[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; @@ -3420,6 +3451,11 @@ network_policies: #[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; From 70bfde3bee008573d8246ccd135203116e03a3e6 Mon Sep 17 00:00:00 2001 From: John Myers Date: Mon, 6 Apr 2026 15:55:05 -0700 Subject: [PATCH 3/5] fix(sandbox): add explicit logging when symlink resolution fails and improve deny messages When /proc//root/ is inaccessible (restricted ptrace, rootless containers, hardened hosts), resolve_binary_in_container now logs a per-binary warning with the specific error, the path it tried, and actionable guidance (use canonical path or grant CAP_SYS_PTRACE). Previously this was completely silent. The Rego deny reason for binary mismatches now leads with 'SYMLINK HINT' and includes a concrete fix command ('readlink -f' inside the sandbox) plus what to look for in logs if automatic resolution isn't working. --- .../data/sandbox-policy.rego | 2 +- crates/openshell-sandbox/src/lib.rs | 11 +++- crates/openshell-sandbox/src/opa.rs | 50 ++++++++++++++++--- 3 files changed, 53 insertions(+), 10 deletions(-) diff --git a/crates/openshell-sandbox/data/sandbox-policy.rego b/crates/openshell-sandbox/data/sandbox-policy.rego index 415bf83ca..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' (hint: binary path is kernel-resolved via /proc//exe; if you specified a symlink like /usr/bin/python3, the actual binary may be /usr/bin/python3.11)", [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 59fa14bfa..c01f0cb43 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -718,17 +718,24 @@ pub async fn run_sandbox( // 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 resolve binary symlinks in policy (non-fatal)" + "Failed to rebuild OPA engine with symlink resolution (non-fatal, \ + falling back to literal path matching)" ); } else { info!( pid = pid, - "Resolved policy binary symlinks via container filesystem" + "Policy binary symlink resolution attempted via container filesystem \ + (check logs above for per-binary results)" ); } } diff --git a/crates/openshell-sandbox/src/opa.rs b/crates/openshell-sandbox/src/opa.rs index 98adb18df..4ecb0a9c6 100644 --- a/crates/openshell-sandbox/src/opa.rs +++ b/crates/openshell-sandbox/src/opa.rs @@ -645,14 +645,45 @@ fn resolve_binary_in_container(policy_path: &str, entrypoint_pid: u32) -> Option let container_path = format!("/proc/{entrypoint_pid}/root{policy_path}"); - // Quick check: is this even a symlink? - let meta = std::fs::symlink_metadata(&container_path).ok()?; + // Check if we can access the container filesystem at all. + // Failure here means /proc//root/ is inaccessible (missing + // CAP_SYS_PTRACE, restricted ptrace scope, rootless container, etc.). + let meta = match std::fs::symlink_metadata(&container_path) { + Ok(m) => m, + Err(e) => { + 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" + ); + return None; + } + }; + + // Not a symlink — no expansion needed (this is the common, expected case) if !meta.file_type().is_symlink() { return None; } // Resolve through the container's filesystem (handles multi-level symlinks) - let canonical = std::fs::canonicalize(&container_path).ok()?; + let canonical = match std::fs::canonicalize(&container_path) { + Ok(c) => c, + Err(e) => { + tracing::warn!( + path = %policy_path, + pid = entrypoint_pid, + error = %e, + "Symlink detected but canonicalize failed; \ + binary will be matched by original path only" + ); + return None; + } + }; // Strip the /proc//root prefix to get the in-container absolute path let prefix = format!("/proc/{entrypoint_pid}/root"); @@ -663,7 +694,7 @@ fn resolve_binary_in_container(policy_path: &str, entrypoint_pid: u32) -> Option if resolved_str == policy_path { None } else { - tracing::debug!( + tracing::info!( original = %policy_path, resolved = %resolved_str, pid = entrypoint_pid, @@ -3251,7 +3282,7 @@ network_policies: #[test] fn deny_reason_includes_symlink_hint() { - // Verify the deny reason includes the symlink hint for debugging + // Verify the deny reason includes an actionable symlink hint let engine = test_engine(); let input = NetworkInput { host: "api.anthropic.com".into(), @@ -3264,8 +3295,13 @@ network_policies: let decision = engine.evaluate_network(&input).unwrap(); assert!(!decision.allowed); assert!( - decision.reason.contains("kernel-resolved"), - "Deny reason should include symlink hint, got: {}", + 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 ); } From 907b9fecbb8203325218e89bcca39109d7fa2e31 Mon Sep 17 00:00:00 2001 From: John Myers Date: Mon, 6 Apr 2026 16:01:37 -0700 Subject: [PATCH 4/5] fix(sandbox): use read_link instead of canonicalize for symlink resolution std::fs::canonicalize resolves /proc//root itself (a kernel pseudo-symlink to /) which strips the prefix needed for path extraction. This caused resolution to silently fail in all environments, not just CI. Replace with an iterative read_link loop that walks the symlink chain within the container namespace without resolving the /proc mount point. Add normalize_path helper for relative symlink targets containing .. components. Update procfs_root_accessible test guard to actually probe the full resolution path instead of just checking path existence. --- crates/openshell-sandbox/src/opa.rs | 175 ++++++++++++++++++++-------- 1 file changed, 128 insertions(+), 47 deletions(-) diff --git a/crates/openshell-sandbox/src/opa.rs b/crates/openshell-sandbox/src/opa.rs index 4ecb0a9c6..0cf0c21b2 100644 --- a/crates/openshell-sandbox/src/opa.rs +++ b/crates/openshell-sandbox/src/opa.rs @@ -637,58 +637,103 @@ fn normalize_endpoint_ports(data: &mut serde_json::Value) { /// - 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; } - let container_path = format!("/proc/{entrypoint_pid}/root{policy_path}"); - - // Check if we can access the container filesystem at all. - // Failure here means /proc//root/ is inaccessible (missing - // CAP_SYS_PTRACE, restricted ptrace scope, rootless container, etc.). - let meta = match std::fs::symlink_metadata(&container_path) { - Ok(m) => m, - Err(e) => { - 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" - ); - 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; } - }; - // Not a symlink — no expansion needed (this is the common, expected case) - if !meta.file_type().is_symlink() { - return None; - } + 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; + } + }; - // Resolve through the container's filesystem (handles multi-level symlinks) - let canonical = match std::fs::canonicalize(&container_path) { - Ok(c) => c, - Err(e) => { - tracing::warn!( - path = %policy_path, - pid = entrypoint_pid, - error = %e, - "Symlink detected but canonicalize 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; + } } - }; + } - // Strip the /proc//root prefix to get the in-container absolute path - let prefix = format!("/proc/{entrypoint_pid}/root"); - let in_container = canonical.strip_prefix(&prefix).ok()?; - let resolved = std::path::PathBuf::from("/").join(in_container); let resolved_str = resolved.to_string_lossy().into_owned(); if resolved_str == policy_path { @@ -2962,6 +3007,27 @@ process: // 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 @@ -3306,15 +3372,30 @@ network_policies: ); } - /// Check if `/proc//root/` is accessible for the current process. - /// In CI containers or restricted environments, this path may not be - /// readable even for the process's own PID. Tests that depend on - /// procfs root access should skip gracefully when this returns false. + /// 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 probe = format!("/proc/{pid}/root/tmp"); - std::fs::symlink_metadata(&probe).is_ok() + 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")] From 5607fc265a591d4559affdaa57ccb8e206dec60b Mon Sep 17 00:00:00 2001 From: John Myers Date: Fri, 10 Apr 2026 07:00:07 -0700 Subject: [PATCH 5/5] fix(sandbox): defer symlink resolution until container filesystem is ready The one-shot resolve ran immediately after ProcessHandle::spawn, before the child's mount namespace and /proc//root/ were populated. This caused symlink_metadata to fail with ENOENT on every binary, and the poll loop never retried because it only reloads when the policy hash changes on the server. Replace the synchronous resolve with an async task that probes /proc//root/ with retries (10 attempts, 500ms apart, 5s total). The child's mount namespace is typically ready within a few hundred ms. Also inline error values into warning message strings so they appear in default log output (not just as structured tracing fields that may be elided), and add debug-level logs before each symlink_metadata call to aid diagnosis. --- crates/openshell-sandbox/src/lib.rs | 74 +++++++++++++++++++++-------- crates/openshell-sandbox/src/opa.rs | 43 ++++++++--------- 2 files changed, 73 insertions(+), 44 deletions(-) diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index c01f0cb43..5d7bda98f 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -714,30 +714,64 @@ pub async fn run_sandbox( .build() ); - // 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. + // Spawn a task to resolve policy binary symlinks after the container + // filesystem becomes 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. + // We cannot do this synchronously here because the child process has + // just been spawned and its mount namespace / procfs entries may not + // be fully populated yet. Instead, we probe with retries until + // /proc//root/ is accessible or we exhaust attempts. 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) { + let resolve_engine = engine.clone(); + let resolve_proto = proto.clone(); + let resolve_pid = entrypoint_pid.clone(); + tokio::spawn(async move { + let pid = resolve_pid.load(Ordering::Acquire); + let probe_path = format!("/proc/{pid}/root/"); + // Retry up to 10 times with 500ms intervals (5s total). + // The child's mount namespace is typically ready within a + // few hundred ms of spawn. + for attempt in 1..=10 { + tokio::time::sleep(Duration::from_millis(500)).await; + if std::fs::metadata(&probe_path).is_ok() { + info!( + pid = pid, + attempt = attempt, + "Container filesystem accessible, resolving policy binary symlinks" + ); + match resolve_engine.reload_from_proto_with_pid(&resolve_proto, pid) { + Ok(()) => { + info!( + pid = pid, + "Policy binary symlink resolution complete \ + (check logs above for per-binary results)" + ); + } + Err(e) => { + warn!( + "Failed to rebuild OPA engine with symlink resolution \ + (non-fatal, falling back to literal path matching): {e}" + ); + } + } + return; + } + debug!( + pid = pid, + attempt = attempt, + probe_path = %probe_path, + "Container filesystem not yet accessible, retrying symlink resolution" + ); + } 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)" + "Container filesystem /proc/{pid}/root/ not accessible after 10 attempts (5s); \ + binary symlink resolution skipped. Policy binary paths will be matched literally. \ + If binaries are symlinks, use canonical paths in your policy \ + (run 'readlink -f ' inside the sandbox)" ); - } + }); } // Spawn background policy poll task (gRPC mode only). diff --git a/crates/openshell-sandbox/src/opa.rs b/crates/openshell-sandbox/src/opa.rs index 0cf0c21b2..32f1d6c19 100644 --- a/crates/openshell-sandbox/src/opa.rs +++ b/crates/openshell-sandbox/src/opa.rs @@ -670,6 +670,10 @@ fn resolve_binary_in_container(policy_path: &str, entrypoint_pid: u32) -> Option for _ in 0..40 { let container_path = format!("/proc/{entrypoint_pid}/root{}", resolved.display()); + tracing::debug!( + "Symlink resolution: probing container_path={container_path} for policy_path={policy_path} pid={entrypoint_pid}" + ); + let meta = match std::fs::symlink_metadata(&container_path) { Ok(m) => m, Err(e) => { @@ -678,23 +682,18 @@ fn resolve_binary_in_container(policy_path: &str, entrypoint_pid: u32) -> Option // 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" + "Cannot access container filesystem for symlink resolution: \ + path={policy_path} container_path={container_path} pid={entrypoint_pid} \ + error={e}. Binary paths in policy will be matched literally. \ + If this 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" + "Symlink chain broken during resolution: \ + original={policy_path} current={} pid={entrypoint_pid} error={e}. \ + Binary will be matched by original path only.", + resolved.display() ); } return None; @@ -710,12 +709,10 @@ fn resolve_binary_in_container(policy_path: &str, entrypoint_pid: u32) -> Option 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" + "Symlink detected but read_link failed: \ + path={policy_path} current={} pid={entrypoint_pid} error={e}. \ + Binary will be matched by original path only.", + resolved.display() ); return None; } @@ -740,10 +737,8 @@ fn resolve_binary_in_container(policy_path: &str, entrypoint_pid: u32) -> Option None } else { tracing::info!( - original = %policy_path, - resolved = %resolved_str, - pid = entrypoint_pid, - "Resolved policy binary symlink via container filesystem" + "Resolved policy binary symlink via container filesystem: \ + original={policy_path} resolved={resolved_str} pid={entrypoint_pid}" ); Some(resolved_str) }