Skip to content

Commit e28ca07

Browse files
authored
fix(sandbox): preserve explicit read-only baseline paths (#910)
Closes #905 Skip proxy baseline read-write enrichment when a path is already explicitly configured as read-only, add regression coverage for the proto and local policy code paths, and document the precedence rule in the sandbox architecture docs. Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
1 parent 78b685e commit e28ca07

3 files changed

Lines changed: 94 additions & 22 deletions

File tree

architecture/sandbox.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,7 @@ Kernel-level error behavior (e.g., Landlock ABI unavailable) depends on `Landloc
452452
- `BestEffort`: Log a warning and continue without filesystem isolation
453453
- `HardRequirement`: Return a fatal error, aborting the sandbox
454454

455-
**Baseline path filtering**: System-injected baseline paths (e.g., `/app`) are pre-filtered by `enrich_proto_baseline_paths()` / `enrich_sandbox_baseline_paths()` using `Path::exists()` before they reach Landlock. User-specified paths are not pre-filtered -- they are evaluated at Landlock apply time so misconfigurations surface as warnings or errors.
455+
**Baseline path filtering**: System-injected baseline paths (e.g., `/app`) are pre-filtered by `enrich_proto_baseline_paths()` / `enrich_sandbox_baseline_paths()` using `Path::exists()` before they reach Landlock. If a baseline `read_write` path is already present in `read_only`, enrichment skips the promotion so explicit policy intent is preserved. User-specified paths are not pre-filtered -- they are evaluated at Landlock apply time so misconfigurations surface as warnings or errors.
456456

457457
### Seccomp syscall filtering
458458

architecture/security-policy.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,7 @@ Controls Landlock LSM compatibility behavior. **Static field** -- immutable afte
393393

394394
**Per-path error handling**: `PathFd::new()` (which wraps `open(path, O_PATH | O_CLOEXEC)`) can fail for several reasons beyond path non-existence: `EACCES` (permission denied), `ELOOP` (symlink loop), `ENAMETOOLONG`, `ENOTDIR`. Each failure is classified with a human-readable reason in logs. In `best_effort` mode, the path is skipped and ruleset construction continues. In `hard_requirement` mode, the error is fatal.
395395

396-
**Baseline path filtering**: The enrichment functions (`enrich_proto_baseline_paths`, `enrich_sandbox_baseline_paths`) pre-filter system-injected baseline paths (e.g., `/app`) by checking `Path::exists()` before adding them to the policy. This prevents missing baseline paths from reaching Landlock at all. User-specified paths are not pre-filtered — they are evaluated at Landlock apply time so that misconfigurations surface as warnings (`best_effort`) or errors (`hard_requirement`).
396+
**Baseline path filtering**: The enrichment functions (`enrich_proto_baseline_paths`, `enrich_sandbox_baseline_paths`) pre-filter system-injected baseline paths (e.g., `/app`) by checking `Path::exists()` before adding them to the policy. This prevents missing baseline paths from reaching Landlock at all. If a baseline `read_write` path is explicitly configured in `read_only`, enrichment skips the promotion and preserves the stricter policy intent. User-specified paths are not pre-filtered — they are evaluated at Landlock apply time so that misconfigurations surface as warnings (`best_effort`) or errors (`hard_requirement`).
397397

398398
**Zero-rule safety check**: If all paths in the ruleset fail to open, `apply()` returns an error rather than calling `restrict_self()` on an empty ruleset. An empty Landlock ruleset with `restrict_self()` would block all filesystem access — the inverse of the intended degradation behavior. This error is caught by the outer `BestEffort` handler, which logs a warning and continues without Landlock.
399399

crates/openshell-sandbox/src/lib.rs

Lines changed: 92 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1347,17 +1347,18 @@ fn enrich_proto_baseline_paths(proto: &mut openshell_core::proto::SandboxPolicy)
13471347
}
13481348
}
13491349
for path in &rw {
1350-
if !fs.read_write.iter().any(|p| p == path) {
1351-
if !std::path::Path::new(path).exists() {
1352-
debug!(
1353-
path,
1354-
"Baseline read-write path does not exist, skipping enrichment"
1355-
);
1356-
continue;
1357-
}
1358-
fs.read_write.push(path.clone());
1359-
modified = true;
1350+
if fs.read_only.iter().any(|p| p == path) || fs.read_write.iter().any(|p| p == path) {
1351+
continue;
13601352
}
1353+
if !std::path::Path::new(path).exists() {
1354+
debug!(
1355+
path,
1356+
"Baseline read-write path does not exist, skipping enrichment"
1357+
);
1358+
continue;
1359+
}
1360+
fs.read_write.push(path.clone());
1361+
modified = true;
13611362
}
13621363

13631364
if modified {
@@ -1401,17 +1402,18 @@ fn enrich_sandbox_baseline_paths(policy: &mut SandboxPolicy) {
14011402
}
14021403
for path in &rw {
14031404
let p = std::path::PathBuf::from(path);
1404-
if !policy.filesystem.read_write.contains(&p) {
1405-
if !p.exists() {
1406-
debug!(
1407-
path,
1408-
"Baseline read-write path does not exist, skipping enrichment"
1409-
);
1410-
continue;
1411-
}
1412-
policy.filesystem.read_write.push(p);
1413-
modified = true;
1405+
if policy.filesystem.read_only.contains(&p) || policy.filesystem.read_write.contains(&p) {
1406+
continue;
14141407
}
1408+
if !p.exists() {
1409+
debug!(
1410+
path,
1411+
"Baseline read-write path does not exist, skipping enrichment"
1412+
);
1413+
continue;
1414+
}
1415+
policy.filesystem.read_write.push(p);
1416+
modified = true;
14151417
}
14161418

14171419
if modified {
@@ -1429,6 +1431,7 @@ fn enrich_sandbox_baseline_paths(policy: &mut SandboxPolicy) {
14291431
#[cfg(test)]
14301432
mod baseline_tests {
14311433
use super::*;
1434+
use crate::policy::{FilesystemPolicy, LandlockPolicy, ProcessPolicy};
14321435

14331436
#[test]
14341437
fn proc_not_in_both_read_only_and_read_write_when_gpu_present() {
@@ -1493,6 +1496,75 @@ mod baseline_tests {
14931496
);
14941497
}
14951498
}
1499+
1500+
#[test]
1501+
fn proto_enrichment_preserves_explicit_read_only_for_baseline_read_write_paths() {
1502+
let mut policy = openshell_policy::restrictive_default_policy();
1503+
policy.filesystem = Some(openshell_core::proto::FilesystemPolicy {
1504+
read_only: vec!["/tmp".to_string()],
1505+
read_write: vec![],
1506+
include_workdir: false,
1507+
});
1508+
policy.network_policies.insert(
1509+
"test".into(),
1510+
openshell_core::proto::NetworkPolicyRule {
1511+
name: "test-rule".into(),
1512+
endpoints: vec![openshell_core::proto::NetworkEndpoint {
1513+
host: "example.com".into(),
1514+
port: 443,
1515+
..Default::default()
1516+
}],
1517+
..Default::default()
1518+
},
1519+
);
1520+
1521+
enrich_proto_baseline_paths(&mut policy);
1522+
1523+
let filesystem = policy.filesystem.expect("filesystem policy");
1524+
assert!(
1525+
filesystem.read_only.contains(&"/tmp".to_string()),
1526+
"explicit read_only baseline path should be preserved"
1527+
);
1528+
assert!(
1529+
!filesystem.read_write.contains(&"/tmp".to_string()),
1530+
"baseline enrichment must not promote explicit read_only /tmp to read_write"
1531+
);
1532+
}
1533+
1534+
#[test]
1535+
fn local_enrichment_preserves_explicit_read_only_for_baseline_read_write_paths() {
1536+
let mut policy = SandboxPolicy {
1537+
version: 1,
1538+
filesystem: FilesystemPolicy {
1539+
read_only: vec![std::path::PathBuf::from("/tmp")],
1540+
read_write: vec![],
1541+
include_workdir: false,
1542+
},
1543+
network: NetworkPolicy {
1544+
mode: NetworkMode::Proxy,
1545+
proxy: Some(ProxyPolicy { http_addr: None }),
1546+
},
1547+
landlock: LandlockPolicy::default(),
1548+
process: ProcessPolicy::default(),
1549+
};
1550+
1551+
enrich_sandbox_baseline_paths(&mut policy);
1552+
1553+
assert!(
1554+
policy
1555+
.filesystem
1556+
.read_only
1557+
.contains(&std::path::PathBuf::from("/tmp")),
1558+
"explicit read_only baseline path should be preserved"
1559+
);
1560+
assert!(
1561+
!policy
1562+
.filesystem
1563+
.read_write
1564+
.contains(&std::path::PathBuf::from("/tmp")),
1565+
"baseline enrichment must not promote explicit read_only /tmp to read_write"
1566+
);
1567+
}
14961568
}
14971569

14981570
/// Load sandbox policy from local files or gRPC.

0 commit comments

Comments
 (0)