Skip to content

Commit 5519726

Browse files
committed
fix(supervisor): harden sandbox path startup repair
Signed-off-by: Evan Lezar <elezar@nvidia.com>
1 parent f5e8fc2 commit 5519726

2 files changed

Lines changed: 260 additions & 13 deletions

File tree

‎crates/openshell-supervisor-process/src/child_env.rs‎

Lines changed: 232 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
// SPDX-License-Identifier: Apache-2.0
33

4-
use std::io::Write;
4+
use std::io::{Read, Write};
55
use std::path::Path;
66

77
const LOCAL_NO_PROXY: &str = "127.0.0.1,localhost,::1";
@@ -11,6 +11,12 @@ const ENSURE_STANDARD_SBIN_PATHS_SCRIPT: &str = "for dir in /usr/local/sbin /usr
1111
const STARTUP_SNIPPET_MARKER: &str = "# OpenShell standard sbin PATH";
1212
const PROFILE_D_SNIPPET_PATH: &str = "/etc/profile.d/openshell-standard-sbin-path.sh";
1313

14+
enum StartupFile {
15+
Missing,
16+
Regular(String),
17+
Unsafe,
18+
}
19+
1420
pub fn child_path_from_env() -> String {
1521
std::env::var("PATH")
1622
.ok()
@@ -71,34 +77,158 @@ fn startup_snippet() -> String {
7177
}
7278

7379
fn write_profile_snippet(path: &Path) -> std::io::Result<()> {
74-
let snippet = startup_snippet();
75-
if std::fs::read_to_string(path).is_ok_and(|content| content == snippet) {
76-
return Ok(());
77-
}
7880
if let Some(parent) = path.parent() {
79-
std::fs::create_dir_all(parent)?;
81+
ensure_directory_without_symlink(parent)?;
82+
}
83+
84+
let snippet = startup_snippet();
85+
match read_startup_file(path)? {
86+
StartupFile::Regular(content) if content == snippet => return Ok(()),
87+
StartupFile::Regular(_) | StartupFile::Missing => {}
88+
StartupFile::Unsafe => return Ok(()),
8089
}
81-
std::fs::write(path, snippet)
90+
write_startup_file(path, &snippet)
8291
}
8392

8493
fn append_startup_snippet(path: &Path) -> std::io::Result<()> {
85-
let existing = match std::fs::read_to_string(path) {
86-
Ok(content) => content,
87-
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
88-
Err(error) => return Err(error),
94+
if let Some(parent) = path.parent()
95+
&& !existing_directory_without_symlink(parent)?
96+
{
97+
return Ok(());
98+
}
99+
100+
let existing = match read_startup_file(path)? {
101+
StartupFile::Regular(content) => content,
102+
StartupFile::Missing | StartupFile::Unsafe => return Ok(()),
89103
};
90104
if existing.contains(STARTUP_SNIPPET_MARKER) {
91105
return Ok(());
92106
}
93107

94108
let snippet = startup_snippet();
95-
let mut file = std::fs::OpenOptions::new().append(true).open(path)?;
109+
let mut file = open_startup_file_for_append(path)?;
96110
if !existing.is_empty() && !existing.ends_with('\n') {
97111
file.write_all(b"\n")?;
98112
}
99113
file.write_all(snippet.as_bytes())
100114
}
101115

116+
fn read_startup_file(path: &Path) -> std::io::Result<StartupFile> {
117+
let metadata = match std::fs::symlink_metadata(path) {
118+
Ok(metadata) => metadata,
119+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
120+
return Ok(StartupFile::Missing);
121+
}
122+
Err(error) => return Err(error),
123+
};
124+
let file_type = metadata.file_type();
125+
if file_type.is_symlink() || !file_type.is_file() {
126+
tracing::debug!(
127+
path = %path.display(),
128+
"skipping OpenShell PATH startup repair for non-regular file"
129+
);
130+
return Ok(StartupFile::Unsafe);
131+
}
132+
133+
let mut content = String::new();
134+
open_startup_file_for_read(path)?.read_to_string(&mut content)?;
135+
Ok(StartupFile::Regular(content))
136+
}
137+
138+
fn ensure_directory_without_symlink(path: &Path) -> std::io::Result<()> {
139+
match std::fs::symlink_metadata(path) {
140+
Ok(metadata) if metadata.file_type().is_symlink() => Err(std::io::Error::other(format!(
141+
"directory '{}' is a symlink",
142+
path.display()
143+
))),
144+
Ok(metadata) if metadata.is_dir() => Ok(()),
145+
Ok(_) => Err(std::io::Error::other(format!(
146+
"'{}' is not a directory",
147+
path.display()
148+
))),
149+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
150+
if let Some(parent) = path.parent() {
151+
ensure_directory_without_symlink(parent)?;
152+
}
153+
std::fs::create_dir(path)
154+
}
155+
Err(error) => Err(error),
156+
}
157+
}
158+
159+
fn existing_directory_without_symlink(path: &Path) -> std::io::Result<bool> {
160+
if let Some(parent) = path.parent()
161+
&& parent != path
162+
&& !parent.as_os_str().is_empty()
163+
&& !existing_directory_without_symlink(parent)?
164+
{
165+
return Ok(false);
166+
}
167+
168+
match std::fs::symlink_metadata(path) {
169+
Ok(metadata) if metadata.file_type().is_symlink() => {
170+
tracing::debug!(
171+
path = %path.display(),
172+
"skipping OpenShell PATH startup repair through symlink directory"
173+
);
174+
Ok(false)
175+
}
176+
Ok(metadata) if metadata.is_dir() => Ok(true),
177+
Ok(_) => {
178+
tracing::debug!(
179+
path = %path.display(),
180+
"skipping OpenShell PATH startup repair through non-directory path"
181+
);
182+
Ok(false)
183+
}
184+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
185+
Err(error) => Err(error),
186+
}
187+
}
188+
189+
fn write_startup_file(path: &Path, content: &str) -> std::io::Result<()> {
190+
let mut file = no_follow_options()
191+
.write(true)
192+
.create(true)
193+
.truncate(true)
194+
.open(path)?;
195+
ensure_opened_file_is_regular(&file, path)?;
196+
file.write_all(content.as_bytes())
197+
}
198+
199+
fn open_startup_file_for_read(path: &Path) -> std::io::Result<std::fs::File> {
200+
let file = no_follow_options().read(true).open(path)?;
201+
ensure_opened_file_is_regular(&file, path)?;
202+
Ok(file)
203+
}
204+
205+
fn open_startup_file_for_append(path: &Path) -> std::io::Result<std::fs::File> {
206+
let file = no_follow_options().append(true).open(path)?;
207+
ensure_opened_file_is_regular(&file, path)?;
208+
Ok(file)
209+
}
210+
211+
fn ensure_opened_file_is_regular(file: &std::fs::File, path: &Path) -> std::io::Result<()> {
212+
let metadata = file.metadata()?;
213+
if metadata.is_file() {
214+
return Ok(());
215+
}
216+
Err(std::io::Error::other(format!(
217+
"'{}' is not a regular file",
218+
path.display()
219+
)))
220+
}
221+
222+
fn no_follow_options() -> std::fs::OpenOptions {
223+
let mut options = std::fs::OpenOptions::new();
224+
#[cfg(unix)]
225+
{
226+
use std::os::unix::fs::OpenOptionsExt as _;
227+
options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
228+
}
229+
options
230+
}
231+
102232
pub fn proxy_env_vars(proxy_url: &str) -> [(&'static str, String); 9] {
103233
[
104234
("ALL_PROXY", proxy_url.to_owned()),
@@ -211,6 +341,96 @@ mod tests {
211341
assert!(bashrc.contains("export PATH=\"/sandbox/.venv/bin:/usr/local/bin:/usr/bin:/bin\""));
212342
}
213343

344+
#[cfg(unix)]
345+
#[test]
346+
fn profile_snippet_skips_symlink() {
347+
use std::os::unix::fs::symlink;
348+
349+
let dir = tempfile::tempdir().expect("tempdir");
350+
let target = dir.path().join("target");
351+
std::fs::write(&target, "keep me").expect("write target");
352+
let profile_path = dir.path().join("openshell-path.sh");
353+
symlink(&target, &profile_path).expect("symlink profile");
354+
355+
write_profile_snippet(&profile_path).expect("skip symlink profile");
356+
357+
assert_eq!(
358+
std::fs::read_to_string(&target).expect("read target"),
359+
"keep me"
360+
);
361+
assert!(
362+
std::fs::symlink_metadata(&profile_path)
363+
.expect("profile metadata")
364+
.file_type()
365+
.is_symlink()
366+
);
367+
}
368+
369+
#[cfg(unix)]
370+
#[test]
371+
fn profile_snippet_rejects_symlink_parent() {
372+
use std::os::unix::fs::symlink;
373+
374+
let dir = tempfile::tempdir().expect("tempdir");
375+
let target_dir = dir.path().join("target-dir");
376+
std::fs::create_dir(&target_dir).expect("target dir");
377+
let parent = dir.path().join("profile.d");
378+
symlink(&target_dir, &parent).expect("symlink parent");
379+
let profile_path = parent.join("openshell-path.sh");
380+
381+
let error = write_profile_snippet(&profile_path).expect_err("reject symlink parent");
382+
383+
assert!(error.to_string().contains("symlink"));
384+
assert!(!target_dir.join("openshell-path.sh").exists());
385+
}
386+
387+
#[cfg(unix)]
388+
#[test]
389+
fn bashrc_snippet_skips_symlink() {
390+
use std::os::unix::fs::symlink;
391+
392+
let dir = tempfile::tempdir().expect("tempdir");
393+
let target = dir.path().join("target");
394+
std::fs::write(&target, "keep me").expect("write target");
395+
let bashrc_path = dir.path().join(".bashrc");
396+
symlink(&target, &bashrc_path).expect("symlink bashrc");
397+
398+
append_startup_snippet(&bashrc_path).expect("skip symlink bashrc");
399+
400+
assert_eq!(
401+
std::fs::read_to_string(&target).expect("read target"),
402+
"keep me"
403+
);
404+
assert!(
405+
std::fs::symlink_metadata(&bashrc_path)
406+
.expect("bashrc metadata")
407+
.file_type()
408+
.is_symlink()
409+
);
410+
}
411+
412+
#[cfg(unix)]
413+
#[test]
414+
fn bashrc_snippet_skips_symlink_parent() {
415+
use std::os::unix::fs::symlink;
416+
417+
let dir = tempfile::tempdir().expect("tempdir");
418+
let target_dir = dir.path().join("target-dir");
419+
std::fs::create_dir(&target_dir).expect("target dir");
420+
std::fs::write(
421+
target_dir.join(".bashrc"),
422+
"export PATH=\"/sandbox/.venv/bin:/usr/local/bin:/usr/bin:/bin\"\n",
423+
)
424+
.expect("target bashrc");
425+
let home = dir.path().join("home");
426+
symlink(&target_dir, &home).expect("symlink home");
427+
428+
append_startup_snippet(&home.join(".bashrc")).expect("skip symlink home");
429+
430+
let bashrc = std::fs::read_to_string(target_dir.join(".bashrc")).expect("read target");
431+
assert!(!bashrc.contains(STARTUP_SNIPPET_MARKER));
432+
}
433+
214434
#[test]
215435
fn apply_proxy_env_includes_node_proxy_opt_in_and_local_bypass() {
216436
let mut cmd = Command::new("/usr/bin/env");

‎crates/openshell-supervisor-process/src/process.rs‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,11 @@ fn inject_provider_env(cmd: &mut Command, provider_env: &HashMap<String, String>
190190
if is_supervisor_only_env_var(key) {
191191
continue;
192192
}
193-
cmd.env(key, value);
193+
if key == "PATH" {
194+
cmd.env(key, child_env::path_with_standard_sbin_paths(value));
195+
} else {
196+
cmd.env(key, value);
197+
}
194198
}
195199
}
196200

@@ -3062,6 +3066,29 @@ mod tests {
30623066
assert!(stdout.contains("ANTHROPIC_API_KEY=openshell:resolve:env:ANTHROPIC_API_KEY"));
30633067
}
30643068

3069+
#[tokio::test]
3070+
async fn inject_provider_env_appends_standard_sbin_to_path() {
3071+
let mut cmd = Command::new("/usr/bin/env");
3072+
cmd.env_clear()
3073+
.stdin(StdStdio::null())
3074+
.stdout(StdStdio::piped())
3075+
.stderr(StdStdio::null());
3076+
3077+
let provider_env = HashMap::from([(
3078+
"PATH".to_string(),
3079+
"/sandbox/.venv/bin:/usr/local/bin:/usr/bin:/bin".to_string(),
3080+
)]);
3081+
3082+
inject_provider_env(&mut cmd, &provider_env);
3083+
3084+
let output = cmd.output().await.expect("spawn env");
3085+
assert!(output.status.success());
3086+
let stdout = String::from_utf8(output.stdout).expect("utf8");
3087+
assert!(stdout.lines().any(|line| {
3088+
line == "PATH=/sandbox/.venv/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin"
3089+
}));
3090+
}
3091+
30653092
#[cfg(unix)]
30663093
fn sandbox_policy_with_read_write(
30673094
path: PathBuf,

0 commit comments

Comments
 (0)