Skip to content

Commit 6be4e01

Browse files
kosaku-simclaude
andcommitted
feat(sandbox): add PTY devices to proxy-mode baseline read-write paths
VS Code Remote-SSH launches its server under the sandbox policy, and the server later allocates PTYs for the integrated terminal via node-pty. Landlock blocks device-file opens unless explicitly whitelisted, so PTY allocation fails with EACCES unless both the PTY multiplexer (/dev/ptmx) and the slave PTY directory (/dev/pts) are writable. Also extend unit tests: baseline_read_write_includes_core_runtime_and_pty_paths, enrich_proto_baseline_paths_adds_pty_paths_for_proxy_mode, and runtime_device_paths_are_not_prepared_for_chown. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4cb2f38 commit 6be4e01

1 file changed

Lines changed: 74 additions & 3 deletions

File tree

  • crates/openshell-sandbox/src

crates/openshell-sandbox/src/lib.rs

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1149,8 +1149,14 @@ const PROXY_BASELINE_READ_ONLY: &[&str] = &[
11491149
];
11501150

11511151
/// Minimum read-write paths required for a proxy-mode sandbox child process:
1152-
/// user working directory and temporary files.
1153-
const PROXY_BASELINE_READ_WRITE: &[&str] = &["/sandbox", "/tmp"];
1152+
/// user working directory, temporary files, and PTY devices.
1153+
///
1154+
/// `/dev/ptmx` and `/dev/pts`: VS Code Remote-SSH launches its server under the
1155+
/// sandbox policy, and the server later allocates PTYs for the integrated
1156+
/// terminal via `node-pty`. Landlock blocks device-file opens unless they are
1157+
/// explicitly whitelisted, so PTY allocation fails with `EACCES` unless both
1158+
/// the PTY multiplexer and the slave PTY directory are writable.
1159+
const PROXY_BASELINE_READ_WRITE: &[&str] = &["/sandbox", "/tmp", "/dev/ptmx", "/dev/pts"];
11541160

11551161
/// GPU read-only paths.
11561162
///
@@ -1388,10 +1394,45 @@ mod baseline_tests {
13881394
}
13891395

13901396
#[test]
1391-
fn baseline_read_write_always_includes_sandbox_and_tmp() {
1397+
fn baseline_read_write_includes_core_runtime_and_pty_paths() {
13921398
let (_ro, rw) = baseline_enrichment_paths();
13931399
assert!(rw.contains(&"/sandbox".to_string()));
13941400
assert!(rw.contains(&"/tmp".to_string()));
1401+
assert!(rw.contains(&"/dev/ptmx".to_string()));
1402+
assert!(rw.contains(&"/dev/pts".to_string()));
1403+
}
1404+
1405+
#[test]
1406+
fn enrich_proto_baseline_paths_adds_pty_paths_for_proxy_mode() {
1407+
let mut policy = openshell_core::proto::SandboxPolicy::default();
1408+
policy.network_policies.insert(
1409+
"test".to_string(),
1410+
openshell_core::proto::NetworkPolicyRule::default(),
1411+
);
1412+
1413+
let modified = enrich_proto_baseline_paths(&mut policy);
1414+
assert!(modified, "proxy-mode policy should be enriched");
1415+
1416+
let fs = policy
1417+
.filesystem
1418+
.as_ref()
1419+
.expect("filesystem policy should be created during enrichment");
1420+
assert!(
1421+
fs.read_write.iter().any(|p| p == "/sandbox"),
1422+
"proxy baseline should include /sandbox"
1423+
);
1424+
assert!(
1425+
fs.read_write.iter().any(|p| p == "/tmp"),
1426+
"proxy baseline should include /tmp"
1427+
);
1428+
assert!(
1429+
fs.read_write.iter().any(|p| p == "/dev/ptmx"),
1430+
"proxy baseline should include /dev/ptmx"
1431+
);
1432+
assert!(
1433+
fs.read_write.iter().any(|p| p == "/dev/pts"),
1434+
"proxy baseline should include /dev/pts"
1435+
);
13951436
}
13961437

13971438
#[test]
@@ -1406,6 +1447,15 @@ mod baseline_tests {
14061447
);
14071448
}
14081449

1450+
#[test]
1451+
fn runtime_device_paths_are_not_prepared_for_chown() {
1452+
assert!(is_runtime_device_path(std::path::Path::new("/dev/ptmx")));
1453+
assert!(is_runtime_device_path(std::path::Path::new("/dev/pts")));
1454+
assert!(is_runtime_device_path(std::path::Path::new("/proc")));
1455+
assert!(!is_runtime_device_path(std::path::Path::new("/sandbox")));
1456+
assert!(!is_runtime_device_path(std::path::Path::new("/tmp")));
1457+
}
1458+
14091459
#[test]
14101460
fn no_duplicate_paths_in_baseline() {
14111461
let (ro, rw) = baseline_enrichment_paths();
@@ -1750,11 +1800,25 @@ fn prepare_filesystem(policy: &SandboxPolicy) -> Result<()> {
17501800
// (e.g. /dev/null) are legitimate read_write entries and must be allowed.
17511801
if let Ok(meta) = std::fs::symlink_metadata(path) {
17521802
if meta.file_type().is_symlink() {
1803+
if is_runtime_device_path(path) {
1804+
debug!(
1805+
path = %path.display(),
1806+
"Skipping ownership change on runtime device symlink"
1807+
);
1808+
continue;
1809+
}
17531810
return Err(miette::miette!(
17541811
"read_write path '{}' is a symlink — refusing to chown (potential privilege escalation)",
17551812
path.display()
17561813
));
17571814
}
1815+
if is_runtime_device_path(path) {
1816+
debug!(
1817+
path = %path.display(),
1818+
"Skipping ownership change on runtime device path"
1819+
);
1820+
continue;
1821+
}
17581822
} else {
17591823
debug!(path = %path.display(), "Creating read_write directory");
17601824
std::fs::create_dir_all(path).into_diagnostic()?;
@@ -1767,6 +1831,13 @@ fn prepare_filesystem(policy: &SandboxPolicy) -> Result<()> {
17671831
Ok(())
17681832
}
17691833

1834+
#[cfg(unix)]
1835+
fn is_runtime_device_path(path: &std::path::Path) -> bool {
1836+
path.starts_with(std::path::Path::new("/dev"))
1837+
|| path.starts_with(std::path::Path::new("/proc"))
1838+
|| path.starts_with(std::path::Path::new("/sys"))
1839+
}
1840+
17701841
#[cfg(not(unix))]
17711842
fn prepare_filesystem(_policy: &SandboxPolicy) -> Result<()> {
17721843
Ok(())

0 commit comments

Comments
 (0)