Skip to content

Commit 70bfde3

Browse files
committed
fix(sandbox): add explicit logging when symlink resolution fails and improve deny messages
When /proc/<pid>/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.
1 parent b0b0ebd commit 70bfde3

File tree

3 files changed

+53
-10
lines changed

3 files changed

+53
-10
lines changed

crates/openshell-sandbox/data/sandbox-policy.rego

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ deny_reason := reason if {
4747
policy := data.network_policies[name]
4848
endpoint_allowed(policy, input.network)
4949
not binary_allowed(policy, input.exec)
50-
r := sprintf("binary '%s' (ancestors: [%s], cmdline: [%s]) not allowed in policy '%s' (hint: binary path is kernel-resolved via /proc/<pid>/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])
50+
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/<pid>/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])
5151
]
5252
all_reasons := array.concat(endpoint_misses, binary_misses)
5353
count(all_reasons) > 0

crates/openshell-sandbox/src/lib.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -718,17 +718,24 @@ pub async fn run_sandbox(
718718
// accessible via /proc/<pid>/root/. This expands symlinks like
719719
// /usr/bin/python3 → /usr/bin/python3.11 in the OPA policy data so that
720720
// either path matches at evaluation time.
721+
//
722+
// If /proc/<pid>/root/ is inaccessible (restricted ptrace, rootless
723+
// container, etc.), resolve_binary_in_container logs a warning per binary
724+
// and falls back to literal path matching. The reload itself still
725+
// succeeds — only the symlink expansion is skipped.
721726
if let (Some(engine), Some(proto)) = (&opa_engine, &retained_proto) {
722727
let pid = handle.pid();
723728
if let Err(e) = engine.reload_from_proto_with_pid(proto, pid) {
724729
warn!(
725730
error = %e,
726-
"Failed to resolve binary symlinks in policy (non-fatal)"
731+
"Failed to rebuild OPA engine with symlink resolution (non-fatal, \
732+
falling back to literal path matching)"
727733
);
728734
} else {
729735
info!(
730736
pid = pid,
731-
"Resolved policy binary symlinks via container filesystem"
737+
"Policy binary symlink resolution attempted via container filesystem \
738+
(check logs above for per-binary results)"
732739
);
733740
}
734741
}

crates/openshell-sandbox/src/opa.rs

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -645,14 +645,45 @@ fn resolve_binary_in_container(policy_path: &str, entrypoint_pid: u32) -> Option
645645

646646
let container_path = format!("/proc/{entrypoint_pid}/root{policy_path}");
647647

648-
// Quick check: is this even a symlink?
649-
let meta = std::fs::symlink_metadata(&container_path).ok()?;
648+
// Check if we can access the container filesystem at all.
649+
// Failure here means /proc/<pid>/root/ is inaccessible (missing
650+
// CAP_SYS_PTRACE, restricted ptrace scope, rootless container, etc.).
651+
let meta = match std::fs::symlink_metadata(&container_path) {
652+
Ok(m) => m,
653+
Err(e) => {
654+
tracing::warn!(
655+
path = %policy_path,
656+
container_path = %container_path,
657+
pid = entrypoint_pid,
658+
error = %e,
659+
"Cannot access container filesystem for symlink resolution; \
660+
binary paths in policy will be matched literally. If a policy \
661+
binary is a symlink (e.g., /usr/bin/python3 -> python3.11), \
662+
use the canonical path instead, or run with CAP_SYS_PTRACE"
663+
);
664+
return None;
665+
}
666+
};
667+
668+
// Not a symlink — no expansion needed (this is the common, expected case)
650669
if !meta.file_type().is_symlink() {
651670
return None;
652671
}
653672

654673
// Resolve through the container's filesystem (handles multi-level symlinks)
655-
let canonical = std::fs::canonicalize(&container_path).ok()?;
674+
let canonical = match std::fs::canonicalize(&container_path) {
675+
Ok(c) => c,
676+
Err(e) => {
677+
tracing::warn!(
678+
path = %policy_path,
679+
pid = entrypoint_pid,
680+
error = %e,
681+
"Symlink detected but canonicalize failed; \
682+
binary will be matched by original path only"
683+
);
684+
return None;
685+
}
686+
};
656687

657688
// Strip the /proc/<pid>/root prefix to get the in-container absolute path
658689
let prefix = format!("/proc/{entrypoint_pid}/root");
@@ -663,7 +694,7 @@ fn resolve_binary_in_container(policy_path: &str, entrypoint_pid: u32) -> Option
663694
if resolved_str == policy_path {
664695
None
665696
} else {
666-
tracing::debug!(
697+
tracing::info!(
667698
original = %policy_path,
668699
resolved = %resolved_str,
669700
pid = entrypoint_pid,
@@ -3251,7 +3282,7 @@ network_policies:
32513282

32523283
#[test]
32533284
fn deny_reason_includes_symlink_hint() {
3254-
// Verify the deny reason includes the symlink hint for debugging
3285+
// Verify the deny reason includes an actionable symlink hint
32553286
let engine = test_engine();
32563287
let input = NetworkInput {
32573288
host: "api.anthropic.com".into(),
@@ -3264,8 +3295,13 @@ network_policies:
32643295
let decision = engine.evaluate_network(&input).unwrap();
32653296
assert!(!decision.allowed);
32663297
assert!(
3267-
decision.reason.contains("kernel-resolved"),
3268-
"Deny reason should include symlink hint, got: {}",
3298+
decision.reason.contains("SYMLINK HINT"),
3299+
"Deny reason should include prominent symlink hint, got: {}",
3300+
decision.reason
3301+
);
3302+
assert!(
3303+
decision.reason.contains("readlink -f"),
3304+
"Deny reason should include actionable fix command, got: {}",
32693305
decision.reason
32703306
);
32713307
}

0 commit comments

Comments
 (0)