diff --git a/crates/openshell-sandbox/data/sandbox-policy.rego b/crates/openshell-sandbox/data/sandbox-policy.rego index 0fa1e6be7..82b8de5f5 100644 --- a/crates/openshell-sandbox/data/sandbox-policy.rego +++ b/crates/openshell-sandbox/data/sandbox-policy.rego @@ -34,12 +34,16 @@ deny_reason := reason if { input.network input.exec not network_policy_for_request - endpoint_misses := [r | - some name - policy := data.network_policies[name] - not endpoint_allowed(policy, input.network) - r := sprintf("endpoint %s:%d not in policy '%s'", [input.network.host, input.network.port, name]) - ] + not endpoint_policy_for_request + count(data.network_policies) > 0 + reason := sprintf("endpoint %s:%d is not allowed by any policy", [input.network.host, input.network.port]) +} + +deny_reason := reason if { + input.network + input.exec + not network_policy_for_request + endpoint_policy_for_request ancestors_str := concat(" -> ", input.exec.ancestors) cmdline_str := concat(", ", input.exec.cmdline_paths) binary_misses := [r | @@ -49,9 +53,8 @@ deny_reason := reason if { not binary_allowed(policy, input.exec) 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 - reason := concat("; ", all_reasons) + count(binary_misses) > 0 + reason := concat("; ", binary_misses) } deny_reason := "network connections not allowed by policy" if { @@ -91,6 +94,12 @@ network_policy_for_request if { binary_allowed(data.network_policies[name], input.exec) } +endpoint_policy_for_request if { + some name + data.network_policies[name] + endpoint_allowed(data.network_policies[name], input.network) +} + # Endpoint matching: exact host (case-insensitive) + port in ports list. endpoint_allowed(policy, network) if { some endpoint diff --git a/crates/openshell-sandbox/src/opa.rs b/crates/openshell-sandbox/src/opa.rs index b49875b78..0acbbe93d 100644 --- a/crates/openshell-sandbox/src/opa.rs +++ b/crates/openshell-sandbox/src/opa.rs @@ -4585,6 +4585,25 @@ network_policies: ); } + #[test] + fn deny_reason_collapses_endpoint_misses() { + let engine = test_engine(); + let input = NetworkInput { + host: "not-configured.example.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); + assert_eq!( + decision.reason, + "endpoint not-configured.example.com:443 is not allowed by any policy" + ); + } + /// 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