Compare commits

...

2 Commits

Author SHA1 Message Date
Edward Frazer
11bd91e876 fix: skip SSH config dependency roots in Windows sandbox 2026-04-17 16:26:52 -07:00
Edward Frazer
b04590fc1f fix: skip .tsh in Windows sandbox profile read roots 2026-04-17 14:51:26 -07:00
6 changed files with 318 additions and 0 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -3275,6 +3275,7 @@ dependencies = [
"codex-utils-string",
"dirs-next",
"dunce",
"glob",
"pretty_assertions",
"rand 0.8.5",
"serde",

View File

@@ -242,6 +242,7 @@ env_logger = "0.11.9"
eventsource-stream = "0.2.3"
futures = { version = "0.3", default-features = false }
gethostname = "1.1.0"
glob = "0.3"
globset = "0.4"
hmac = "0.12.1"
http = "1.3.1"

View File

@@ -31,6 +31,7 @@ codex-utils-pty = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-string = { workspace = true }
dunce = "1.0"
glob = { workspace = true }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tempfile = "3"

View File

@@ -2,6 +2,9 @@
// from the eventual unsafe cleanup.
#![allow(unsafe_op_in_unsafe_fn)]
#[cfg(any(target_os = "windows", test))]
mod ssh_config_dependencies;
macro_rules! windows_modules {
($($name:ident),+ $(,)?) => {
$(#[cfg(target_os = "windows")] mod $name;)+

View File

@@ -21,6 +21,7 @@ use crate::setup_error::SetupFailure;
use crate::setup_error::clear_setup_error_report;
use crate::setup_error::failure;
use crate::setup_error::read_setup_error_report;
use crate::ssh_config_dependencies::ssh_config_dependency_profile_entry_names;
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
@@ -42,6 +43,7 @@ const SECURITY_BUILTIN_DOMAIN_RID: u32 = 0x0000_0020;
const DOMAIN_ALIAS_RID_ADMINS: u32 = 0x0000_0220;
const USERPROFILE_READ_ROOT_EXCLUSIONS: &[&str] = &[
".ssh",
".tsh",
".gnupg",
".aws",
".azure",
@@ -327,6 +329,7 @@ fn profile_read_roots(user_profile: &Path) -> Vec<PathBuf> {
Ok(entries) => entries,
Err(_) => return vec![user_profile.to_path_buf()],
};
let ssh_dependency_entry_names = ssh_config_dependency_profile_entry_names(user_profile);
entries
.filter_map(Result::ok)
@@ -336,6 +339,9 @@ fn profile_read_roots(user_profile: &Path) -> Vec<PathBuf> {
!USERPROFILE_READ_ROOT_EXCLUSIONS
.iter()
.any(|excluded| name.eq_ignore_ascii_case(excluded))
&& !ssh_dependency_entry_names
.iter()
.any(|excluded| name.eq_ignore_ascii_case(excluded))
})
.map(|(_, path)| path)
.collect()
@@ -1031,11 +1037,13 @@ mod tests {
let allowed_dir = user_profile.join("Documents");
let allowed_file = user_profile.join(".gitconfig");
let excluded_dir = user_profile.join(".ssh");
let excluded_tsh_dir = user_profile.join(".tsh");
let excluded_case_variant = user_profile.join(".AWS");
fs::create_dir_all(&allowed_dir).expect("create allowed dir");
fs::write(&allowed_file, "safe").expect("create allowed file");
fs::create_dir_all(&excluded_dir).expect("create excluded dir");
fs::create_dir_all(&excluded_tsh_dir).expect("create excluded tsh dir");
fs::create_dir_all(&excluded_case_variant).expect("create excluded case variant");
let roots = profile_read_roots(user_profile);
@@ -1045,6 +1053,33 @@ mod tests {
assert_eq!(expected, actual);
}
#[test]
fn profile_read_roots_excludes_ssh_config_dependency_entries() {
let tmp = TempDir::new().expect("tempdir");
let user_profile = tmp.path();
let allowed_dir = user_profile.join("Documents");
let ssh_dir = user_profile.join(".ssh");
let key_dir = user_profile.join(".keys");
let include_dir = user_profile.join(".included");
fs::create_dir_all(&allowed_dir).expect("create allowed dir");
fs::create_dir_all(&ssh_dir).expect("create .ssh");
fs::create_dir_all(&key_dir).expect("create key dir");
fs::create_dir_all(&include_dir).expect("create include dir");
fs::write(
ssh_dir.join("config"),
"IdentityFile ~/.keys/id_ed25519\nInclude ~/.included/config\n",
)
.expect("write ssh config");
fs::write(include_dir.join("config"), "User git\n").expect("write included config");
let roots = profile_read_roots(user_profile);
let actual: HashSet<PathBuf> = roots.into_iter().collect();
let expected: HashSet<PathBuf> = [allowed_dir].into_iter().collect();
assert_eq!(expected, actual);
}
#[test]
fn profile_read_roots_falls_back_to_profile_root_when_enumeration_fails() {
let tmp = TempDir::new().expect("tempdir");

View File

@@ -0,0 +1,277 @@
use std::collections::BTreeSet;
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
const FILE_DIRECTIVES: &[&str] = &[
"certificatefile",
"controlpath",
"globalknownhostsfile",
"identityagent",
"identityfile",
"pkcs11provider",
"revokedhostkeys",
"securitykeyprovider",
"userknownhostsfile",
"xauthlocation",
];
const COMMAND_DIRECTIVES: &[&str] = &["knownhostscommand", "localcommand", "proxycommand"];
pub(crate) fn ssh_config_dependency_profile_entry_names(user_profile: &Path) -> BTreeSet<String> {
let ssh_dir = user_profile.join(".ssh");
let mut entries = BTreeSet::from([".ssh".to_string()]);
visit_config(
&ssh_dir.join("config"),
user_profile,
&ssh_dir,
&mut HashSet::new(),
&mut entries,
0,
);
entries
}
fn visit_config(
path: &Path,
user_profile: &Path,
ssh_dir: &Path,
visited: &mut HashSet<PathBuf>,
entries: &mut BTreeSet<String>,
depth: usize,
) {
if depth == 32 {
return;
}
let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
if !visited.insert(key) {
return;
}
let Ok(contents) = std::fs::read_to_string(path) else {
return;
};
for (key, args) in contents.lines().filter_map(directive) {
match key.to_ascii_lowercase().as_str() {
"include" => {
for arg in args {
for include in include_paths(&arg, user_profile, ssh_dir) {
record_profile_entry(user_profile, &include, entries);
visit_config(&include, user_profile, ssh_dir, visited, entries, depth + 1);
}
}
}
key if FILE_DIRECTIVES.contains(&key) => {
for arg in args {
if let Some(path) = profile_path_arg(&arg, user_profile, None) {
record_profile_entry(user_profile, &path, entries);
}
}
}
key if COMMAND_DIRECTIVES.contains(&key) => {
for arg in args {
for word in words(&arg) {
if let Some(path) = profile_path_arg(&word, user_profile, None) {
record_profile_entry(user_profile, &path, entries);
}
}
}
}
_ => {}
}
}
}
fn include_paths(arg: &str, user_profile: &Path, ssh_dir: &Path) -> Vec<PathBuf> {
let Some(pattern_path) = profile_path_arg(arg, user_profile, Some(ssh_dir)) else {
return Vec::new();
};
let pattern = pattern_path.to_string_lossy();
let Ok(paths) = glob::glob(&pattern) else {
return vec![glob_parent(pattern_path)];
};
let paths: Vec<PathBuf> = paths.filter_map(Result::ok).collect();
if paths.is_empty() {
vec![glob_parent(pattern_path)]
} else {
paths
}
}
fn directive(line: &str) -> Option<(String, Vec<String>)> {
let mut words = words(line);
let first = words.first()?.clone();
if let Some((key, value)) = first.split_once('=')
&& !key.is_empty()
{
let mut args = Vec::new();
if !value.is_empty() {
args.push(value.to_string());
}
args.extend(words.drain(1..));
Some((key.to_string(), args))
} else {
Some((words.remove(0), words))
}
}
fn words(line: &str) -> Vec<String> {
let mut out = Vec::new();
let mut word = String::new();
let mut quote = false;
let mut chars = line.chars();
while let Some(ch) = chars.next() {
match ch {
'#' if !quote => break,
'"' => quote = !quote,
'\\' if quote => word.extend(chars.next()),
ch if ch.is_whitespace() && !quote => {
if !word.is_empty() {
out.push(std::mem::take(&mut word));
}
}
ch => word.push(ch),
}
}
if !word.is_empty() {
out.push(word);
}
out
}
fn profile_path_arg(
arg: &str,
user_profile: &Path,
relative_base: Option<&Path>,
) -> Option<PathBuf> {
if arg.eq_ignore_ascii_case("none") {
return None;
}
if arg == "~" || arg == "%d" || arg == "${HOME}" {
return Some(user_profile.to_path_buf());
}
if let Some(rest) = arg
.strip_prefix("~/")
.or_else(|| arg.strip_prefix(r"~\"))
.or_else(|| arg.strip_prefix("%d/"))
.or_else(|| arg.strip_prefix(r"%d\"))
.or_else(|| arg.strip_prefix("${HOME}/"))
.or_else(|| arg.strip_prefix(r"${HOME}\"))
{
return Some(user_profile.join(rest));
}
let path = PathBuf::from(arg);
if path.is_absolute() {
Some(path)
} else {
relative_base.map(|base| base.join(path))
}
}
fn record_profile_entry(user_profile: &Path, path: &Path, entries: &mut BTreeSet<String>) {
let profile = user_profile.to_string_lossy().replace('\\', "/");
let path = path.to_string_lossy().replace('\\', "/");
let profile = profile.trim_end_matches('/');
let relative = if path.eq_ignore_ascii_case(profile) {
""
} else {
let prefix = format!("{profile}/");
path.strip_prefix(&prefix).unwrap_or_default()
};
if let Some(entry) = relative.split('/').find(|part| !part.is_empty()) {
entries.insert(entry.to_string());
}
}
fn glob_parent(path: PathBuf) -> PathBuf {
let path = path.to_string_lossy();
PathBuf::from(
path.split(['*', '?', '['])
.next()
.unwrap_or_default()
.trim_end_matches(['/', '\\']),
)
}
#[cfg(test)]
mod tests {
use super::ssh_config_dependency_profile_entry_names;
use pretty_assertions::assert_eq;
use std::collections::BTreeSet;
use std::fs;
use tempfile::TempDir;
#[test]
fn collects_file_directive_profile_entries() {
let tmp = TempDir::new().expect("tempdir");
let home = tmp.path();
fs::create_dir_all(home.join(".ssh")).expect("create .ssh");
fs::write(
home.join(".ssh/config"),
r#"
Host devbox
IdentityFile ~/.keys/id_ed25519
CertificateFile %d/.certs/devbox-cert.pub
UserKnownHostsFile ${HOME}/.known_hosts_custom
ControlPath ~/.ssh/control-%h-%p-%r
"#,
)
.expect("write config");
assert_eq!(
BTreeSet::from([
".certs".to_string(),
".keys".to_string(),
".known_hosts_custom".to_string(),
".ssh".to_string(),
]),
ssh_config_dependency_profile_entry_names(home)
);
}
#[test]
fn recursively_collects_include_dependencies() {
let tmp = TempDir::new().expect("tempdir");
let home = tmp.path();
let ssh_dir = home.join(".ssh");
fs::create_dir_all(ssh_dir.join("conf.d")).expect("create conf.d");
fs::write(ssh_dir.join("config"), "Include conf.d/*.conf\n").expect("write config");
fs::write(
ssh_dir.join("conf.d/devbox.conf"),
"CertificateFile ~/.included/devbox-cert.pub\n",
)
.expect("write include");
assert_eq!(
BTreeSet::from([".included".to_string(), ".ssh".to_string()]),
ssh_config_dependency_profile_entry_names(home)
);
}
#[test]
fn command_directives_only_record_explicit_profile_paths() {
let tmp = TempDir::new().expect("tempdir");
let home = tmp.path();
fs::create_dir_all(home.join(".ssh")).expect("create .ssh");
fs::write(
home.join(".ssh/config"),
r#"
Host devbox
ProxyCommand ~/.helpers/proxy --state ${HOME}/.proxy-state %h %p
KnownHostsCommand "%d/.known-hosts/bin" %H
"#,
)
.expect("write config");
assert_eq!(
BTreeSet::from([
".helpers".to_string(),
".known-hosts".to_string(),
".proxy-state".to_string(),
".ssh".to_string(),
]),
ssh_config_dependency_profile_entry_names(home)
);
}
}