mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## Stack 1. Base PR: #18443 stops granting ACLs on `USERPROFILE`. 2. This PR: filters additional SSH-owned profile roots discovered from SSH config. ## Bug The base PR removes the broadest bad grant: `USERPROFILE` itself. That still leaves one important case. A user profile child can be SSH-owned even when its name is not one of our fixed exclusions. For example: ```sshconfig Host devbox IdentityFile ~/.keys/devbox CertificateFile ~/.certs/devbox-cert.pub UserKnownHostsFile ~/.known_hosts_custom Include ~/.ssh/conf.d/*.conf ``` After profile expansion, the sandbox might see these as normal profile children: ```text C:\Users\me\.keys C:\Users\me\.certs C:\Users\me\.known_hosts_custom C:\Users\me\.ssh ``` Those paths have another owner: OpenSSH and the tools that manage SSH identity and host-key state. Codex should not add sandbox ACLs to them. OpenSSH describes this dependency tree in [`ssh_config(5)`](https://man.openbsd.org/ssh_config.5), and the client parser follows the same shape in `readconf.c`: - `Include` recursively reads more config files and expands globs - `IdentityFile` and `CertificateFile` name authentication files - `UserKnownHostsFile`, `GlobalKnownHostsFile`, and `RevokedHostKeys` name host-key files - `ControlPath` and `IdentityAgent` can name profile-owned sockets or control files - these path directives can use forms such as `~`, `%d`, and `${HOME}` ## Change This PR adds a small SSH config dependency scanner. It starts at: ```text ~/.ssh/config ``` Then it returns concrete paths named by `Include` and by path-valued SSH config directives: ```text IdentityFile CertificateFile UserKnownHostsFile GlobalKnownHostsFile RevokedHostKeys ControlPath IdentityAgent ``` For example: ```sshconfig IdentityFile ~/.keys/devbox CertificateFile ~/.certs/devbox-cert.pub Include ~/.ssh/conf.d/*.conf ``` returns paths like: ```text C:\Users\me\.keys\devbox C:\Users\me\.certs\devbox-cert.pub C:\Users\me\.ssh\conf.d\devbox.conf ``` The setup code then maps those paths back to their top-level `USERPROFILE` child and filters matching sandbox roots out of both the writable and readable root lists. ## Why this shape The parser reports what SSH config references. The sandbox setup code decides which `USERPROFILE` roots are unsafe to grant. That keeps the policy simple: 1. expand broad profile grants 2. remove the profile root 3. remove fixed sensitive profile folders 4. remove profile folders referenced by SSH config dependencies If a path has two possible owners, the sandbox steps back. SSH keeps control of SSH config, keys, certificates, known-hosts files, sockets, and included config files. ## Tests - `cargo test -p codex-windows-sandbox --lib` - `just bazel-lock-check` - `just fix -p codex-windows-sandbox` - `git diff --check`
250 lines
7.3 KiB
Rust
250 lines
7.3 KiB
Rust
use std::collections::HashSet;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
|
|
const SSH_PROFILE_PATH_DIRECTIVES: &[&str] = &[
|
|
"certificatefile",
|
|
"controlpath",
|
|
"globalknownhostsfile",
|
|
"identityagent",
|
|
"identityfile",
|
|
"revokedhostkeys",
|
|
"userknownhostsfile",
|
|
];
|
|
|
|
pub(crate) fn ssh_config_dependency_paths(user_profile: &Path) -> Vec<PathBuf> {
|
|
let ssh_dir = user_profile.join(".ssh");
|
|
let mut paths = vec![ssh_dir.join("config")];
|
|
visit_config(
|
|
&ssh_dir.join("config"),
|
|
user_profile,
|
|
&ssh_dir,
|
|
&mut HashSet::new(),
|
|
&mut paths,
|
|
/*depth*/ 0,
|
|
);
|
|
paths
|
|
}
|
|
|
|
fn visit_config(
|
|
path: &Path,
|
|
user_profile: &Path,
|
|
ssh_dir: &Path,
|
|
visited: &mut HashSet<PathBuf>,
|
|
paths: &mut Vec<PathBuf>,
|
|
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) {
|
|
paths.push(include.clone());
|
|
visit_config(&include, user_profile, ssh_dir, visited, paths, depth + 1);
|
|
}
|
|
}
|
|
}
|
|
key if SSH_PROFILE_PATH_DIRECTIVES.contains(&key) => {
|
|
for arg in args {
|
|
if let Some(path) =
|
|
profile_path_arg(&arg, user_profile, /*relative_base*/ None)
|
|
{
|
|
paths.push(path);
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
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().replace('\\', "/");
|
|
let Ok(paths) = glob::glob(&pattern) else {
|
|
return Vec::new();
|
|
};
|
|
paths.filter_map(Result::ok).collect()
|
|
}
|
|
|
|
fn directive(line: &str) -> Option<(String, Vec<String>)> {
|
|
let mut words = words(line);
|
|
let first = words.first()?.to_string();
|
|
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 {
|
|
let key = words.remove(0);
|
|
if let Some(arg) = words.first_mut()
|
|
&& let Some(value) = arg.strip_prefix('=')
|
|
{
|
|
*arg = value.to_string();
|
|
}
|
|
words.retain(|arg| !arg.is_empty());
|
|
Some((key, words))
|
|
}
|
|
}
|
|
|
|
fn words(line: &str) -> Vec<String> {
|
|
let mut out = Vec::new();
|
|
let mut word = String::new();
|
|
let mut quote = None;
|
|
let mut chars = line.chars().peekable();
|
|
|
|
while let Some(ch) = chars.next() {
|
|
match ch {
|
|
'#' if quote.is_none() => break,
|
|
'\'' | '"' if quote == Some(ch) => quote = None,
|
|
'\'' | '"' if quote.is_none() => quote = Some(ch),
|
|
'\\' => {
|
|
if let Some(&next) = chars.peek() {
|
|
if matches!(next, '\'' | '"' | '\\') || (quote.is_none() && next == ' ') {
|
|
if let Some(escaped) = chars.next() {
|
|
word.push(escaped);
|
|
}
|
|
} else {
|
|
word.push(ch);
|
|
}
|
|
} else {
|
|
word.push(ch);
|
|
}
|
|
}
|
|
ch if ch.is_whitespace() && quote.is_none() => {
|
|
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))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::ssh_config_dependency_paths;
|
|
use pretty_assertions::assert_eq;
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
use tempfile::TempDir;
|
|
|
|
#[test]
|
|
fn collects_path_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
|
|
IdentityFile '~/.keys/quoted key'
|
|
CertificateFile = %d/.certs/devbox-cert.pub
|
|
UserKnownHostsFile ${HOME}/.known_hosts_custom
|
|
GlobalKnownHostsFile ~/.global_known_hosts
|
|
ControlPath ~/.control/%h-%p-%r
|
|
IdentityAgent=%d/.agent/socket
|
|
RevokedHostKeys ~/.revoked/keys
|
|
"#,
|
|
)
|
|
.expect("write config");
|
|
|
|
assert_eq!(
|
|
vec![
|
|
home.join(".ssh/config"),
|
|
home.join(".keys/id_ed25519"),
|
|
home.join(".keys/quoted key"),
|
|
home.join(".certs/devbox-cert.pub"),
|
|
home.join(".known_hosts_custom"),
|
|
home.join(".global_known_hosts"),
|
|
home.join(".control/%h-%p-%r"),
|
|
home.join(".agent/socket"),
|
|
home.join(".revoked/keys"),
|
|
],
|
|
slash_paths(ssh_config_dependency_paths(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!(
|
|
vec![
|
|
home.join(".ssh/config"),
|
|
ssh_dir.join("conf.d/devbox.conf"),
|
|
home.join(".included/devbox-cert.pub"),
|
|
],
|
|
slash_paths(ssh_config_dependency_paths(home))
|
|
);
|
|
}
|
|
|
|
fn slash_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
|
|
paths
|
|
.into_iter()
|
|
.map(|path| PathBuf::from(path.to_string_lossy().replace('\\', "/")))
|
|
.collect()
|
|
}
|
|
}
|