From d2267f5d4c1e1ad5b26fd1c70929e4a609dbac86 Mon Sep 17 00:00:00 2001 From: Paolo Dettori Date: Fri, 5 Jun 2026 16:31:25 -0400 Subject: [PATCH 1/2] fix(proxy): fall through to OPA on identity resolution failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CONNECT proxy's process-identity resolution fails in Kubernetes/ container deployments because the ephemeral port seen by peer_addr doesn't match entries in /proc//net/tcp due to network namespace boundary timing issues. Previously this caused a hard deny without ever evaluating OPA policy. Additionally, policies that declare endpoints without a `binaries` field (the field is optional and defaults to empty) were unconditionally denied because the Rego binary_allowed rule iterates policy.binaries with `some b` which yields nothing on an empty array. This commit: - Adds a fallback in evaluate_opa_tcp that constructs a sentinel "" identity and lets OPA decide (instead of hard-deny) - Adds a Rego rule: empty binaries list means "any binary is allowed" (the endpoint declaration itself is the access gate) - Adds unit tests for both the happy path and security constraint The sentinel path "" contains angle brackets which are invalid filesystem characters, so it can never match a real binary entry — policies with explicit binary restrictions remain enforced. Signed-off-by: Paolo Dettori Assisted-By: Claude (Anthropic AI) Signed-off-by: Paolo Dettori --- .../data/sandbox-policy.rego | 11 ++++ crates/openshell-sandbox/src/opa.rs | 57 +++++++++++++++++++ crates/openshell-sandbox/src/proxy.rs | 20 +++++-- .../testdata/sandbox-policy.yaml | 5 ++ 4 files changed, 87 insertions(+), 6 deletions(-) diff --git a/crates/openshell-sandbox/data/sandbox-policy.rego b/crates/openshell-sandbox/data/sandbox-policy.rego index afcd28863..bc32abd2a 100644 --- a/crates/openshell-sandbox/data/sandbox-policy.rego +++ b/crates/openshell-sandbox/data/sandbox-policy.rego @@ -161,6 +161,13 @@ binary_allowed(policy, exec) if { glob.match(b.path, ["/"], p) } +# Binary matching: empty binaries list means no binary restriction. +# When a policy declares endpoints without specifying which binaries may +# access them, any binary is permitted (the endpoint itself is the gate). +binary_allowed(policy, _) if { + count(object.get(policy, "binaries", [])) == 0 +} + user_declared_binary_allowed(policy, exec) if { some b b := policy.binaries[_] @@ -187,6 +194,10 @@ user_declared_binary_allowed(policy, exec) if { glob.match(b.path, ["/"], p) } +user_declared_binary_allowed(policy, _) if { + count(object.get(policy, "binaries", [])) == 0 +} + # --- Network action (allow / deny) --- # # These rules are mutually exclusive by construction: diff --git a/crates/openshell-sandbox/src/opa.rs b/crates/openshell-sandbox/src/opa.rs index f73f3bc14..f5ff5923b 100644 --- a/crates/openshell-sandbox/src/opa.rs +++ b/crates/openshell-sandbox/src/opa.rs @@ -5165,4 +5165,61 @@ network_policies: let input = l7_input("h.test", 80, "HEAD", "/protected"); assert!(!eval_l7(&engine, &input)); } + + #[test] + fn empty_binaries_allows_any_binary() { + let engine = test_engine(); + let input = NetworkInput { + host: "open.example.com".into(), + port: 443, + binary_path: PathBuf::from("/any/random/binary"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let decision = engine.evaluate_network(&input).unwrap(); + assert!( + decision.allowed, + "Expected allow with empty binaries list, got deny: {}", + decision.reason + ); + assert_eq!(decision.matched_policy.as_deref(), Some("open_endpoint")); + } + + #[test] + fn unresolved_identity_allowed_with_empty_binaries() { + let engine = test_engine(); + let input = NetworkInput { + host: "open.example.com".into(), + port: 443, + binary_path: PathBuf::from(""), + binary_sha256: String::new(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let decision = engine.evaluate_network(&input).unwrap(); + assert!( + decision.allowed, + "Expected allow for unresolved identity with empty binaries, got deny: {}", + decision.reason + ); + } + + #[test] + fn unresolved_identity_denied_with_specific_binaries() { + let engine = test_engine(); + let input = NetworkInput { + host: "api.anthropic.com".into(), + port: 443, + binary_path: PathBuf::from(""), + binary_sha256: String::new(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let decision = engine.evaluate_network(&input).unwrap(); + assert!( + !decision.allowed, + "Expected deny for unresolved identity when policy requires specific binary" + ); + } } diff --git a/crates/openshell-sandbox/src/proxy.rs b/crates/openshell-sandbox/src/proxy.rs index a72f42e2a..9d40a9a8e 100644 --- a/crates/openshell-sandbox/src/proxy.rs +++ b/crates/openshell-sandbox/src/proxy.rs @@ -1400,13 +1400,21 @@ fn evaluate_opa_tcp( let identity = match resolve_process_identity(pid, peer_port, identity_cache) { Ok(id) => id, Err(err) => { - return deny( - err.reason, - err.binary, - err.binary_pid, - err.ancestors, - vec![], + // In container/K8s deployments, network namespace setup can cause + // ephemeral port mismatches between peer_addr and /proc/net/tcp. + // Fall through to OPA with an unresolved identity — policies without + // binary restrictions (empty binaries list) will still allow. + warn!( + "identity resolution failed (port={peer_port}): {}; using unresolved identity", + err.reason ); + ResolvedIdentity { + bin_path: PathBuf::from(""), + binary_pid: 0, + ancestors: vec![], + cmdline_paths: vec![], + bin_hash: String::new(), + } } }; diff --git a/crates/openshell-sandbox/testdata/sandbox-policy.yaml b/crates/openshell-sandbox/testdata/sandbox-policy.yaml index 297face21..f4941332b 100644 --- a/crates/openshell-sandbox/testdata/sandbox-policy.yaml +++ b/crates/openshell-sandbox/testdata/sandbox-policy.yaml @@ -82,3 +82,8 @@ network_policies: - { host: gitlab.com, port: 443 } binaries: - { path: /usr/bin/glab } + + open_endpoint: + name: open_endpoint + endpoints: + - { host: open.example.com, port: 443 } From 1c3e2c458389190bc3f9fd4122e0c3c60d61ecec Mon Sep 17 00:00:00 2001 From: Paolo Dettori Date: Fri, 5 Jun 2026 16:39:29 -0400 Subject: [PATCH 2/2] fix(proxy): include partial identity fields in fallback warning Include binary, binary_pid, and ancestors from IdentityError in the warn!() structured fields so they provide diagnostics in production. Fixes clippy dead_code lint on IdentityError fields. Signed-off-by: Paolo Dettori Assisted-By: Claude (Anthropic AI) Signed-off-by: Paolo Dettori --- crates/openshell-sandbox/src/proxy.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/openshell-sandbox/src/proxy.rs b/crates/openshell-sandbox/src/proxy.rs index 9d40a9a8e..c6126041a 100644 --- a/crates/openshell-sandbox/src/proxy.rs +++ b/crates/openshell-sandbox/src/proxy.rs @@ -1405,8 +1405,12 @@ fn evaluate_opa_tcp( // Fall through to OPA with an unresolved identity — policies without // binary restrictions (empty binaries list) will still allow. warn!( - "identity resolution failed (port={peer_port}): {}; using unresolved identity", - err.reason + port = peer_port, + reason = %err.reason, + binary = ?err.binary, + binary_pid = ?err.binary_pid, + ancestors = ?err.ancestors, + "identity resolution failed; using unresolved identity", ); ResolvedIdentity { bin_path: PathBuf::from(""),