Compare commits

...

4 Commits

Author SHA1 Message Date
viyatb-oai
9939649a99 test: adapt permissions test after rebase
Co-authored-by: Codex <noreply@openai.com>
2026-04-24 14:39:53 -07:00
viyatb-oai
c98052cfe2 test: fix CI expectations after symlink hardening
Co-authored-by: Codex <noreply@openai.com>
2026-04-24 14:27:19 -07:00
viyatb-oai
226364a543 docs: explain symlinked project config protection 2026-04-24 14:27:18 -07:00
viyatb-oai
1d29661fef fix: harden symlinked output and project config writes 2026-04-24 14:26:50 -07:00
7 changed files with 239 additions and 7 deletions

View File

@@ -972,6 +972,32 @@ async fn load_project_layers(
continue;
}
let config_file = dot_codex_abs.join(CONFIG_TOML_FILE);
match fs.get_metadata(&config_file, /*sandbox*/ None).await {
Ok(metadata) => {
if metadata.is_symlink {
let config_file_display = config_file.as_path().display();
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("Project config file {config_file_display} must not be a symlink"),
));
}
}
Err(err) => {
if err.kind() == io::ErrorKind::NotFound {
layers.push(project_layer_entry(
&dot_codex_abs,
TomlValue::Table(toml::map::Map::new()),
disabled_reason,
));
continue;
}
let config_file_display = config_file.as_path().display();
return Err(io::Error::new(
err.kind(),
format!("Failed to inspect project config file {config_file_display}: {err}"),
));
}
}
match fs.read_file_text(&config_file, /*sandbox*/ None).await {
Ok(contents) => {
let config: TomlValue = match toml::from_str(&contents) {

View File

@@ -29,6 +29,8 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use std::collections::HashMap;
#[cfg(unix)]
use std::os::unix::fs::symlink;
use std::path::Path;
use tempfile::tempdir;
use toml::Value as TomlValue;
@@ -1878,6 +1880,43 @@ async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io::
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn symlinked_project_config_is_rejected() -> std::io::Result<()> {
let tmp = tempdir()?;
let project_root = tmp.path().join("project");
let dot_codex = project_root.join(".codex");
let codex_home = tmp.path().join("home");
let payload = project_root.join("payload.toml");
tokio::fs::create_dir_all(&dot_codex).await?;
tokio::fs::create_dir_all(&codex_home).await?;
tokio::fs::write(project_root.join(".git"), "gitdir: here").await?;
tokio::fs::write(&payload, "sandbox_mode = \"danger-full-access\"\n").await?;
symlink(&payload, dot_codex.join(CONFIG_TOML_FILE)).expect("create config symlink");
make_config_for_test(
&codex_home,
&project_root,
TrustLevel::Trusted,
/*project_root_markers*/ None,
)
.await?;
let err = ConfigBuilder::default()
.codex_home(codex_home)
.fallback_cwd(Some(project_root))
.build()
.await
.expect_err("symlinked project config should fail");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert!(
err.to_string().contains("must not be a symlink"),
"unexpected error: {err}"
);
Ok(())
}
#[tokio::test]
async fn project_layer_without_config_toml_is_disabled_when_untrusted_or_unknown()
-> std::io::Result<()> {
@@ -2009,7 +2048,7 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<()
&codex_home,
&project_root,
TrustLevel::Trusted,
Some(vec![".hg".to_string()]),
/*project_root_markers*/ Some(vec![".hg".to_string()]),
)
.await?;

View File

@@ -37,6 +37,7 @@ codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-cli = { workspace = true }
codex-utils-oss = { workspace = true }
libc = { workspace = true }
owo-colors = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
@@ -63,7 +64,6 @@ assert_cmd = { workspace = true }
codex-apply-patch = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
core_test_support = { workspace = true }
libc = { workspace = true }
opentelemetry = { workspace = true }
opentelemetry_sdk = { workspace = true }
predicates = { workspace = true }

View File

@@ -1,3 +1,9 @@
#[cfg(unix)]
use std::fs::OpenOptions;
#[cfg(unix)]
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
use codex_app_server_protocol::ServerNotification;
@@ -41,8 +47,63 @@ pub(crate) fn handle_last_message(last_agent_message: Option<&str>, output_file:
fn write_last_message_file(contents: &str, last_message_path: Option<&Path>) {
if let Some(path) = last_message_path
&& let Err(e) = std::fs::write(path, contents)
&& let Err(e) = write_last_message_to_path(path, contents)
{
eprintln!("Failed to write last message file {path:?}: {e}");
}
}
#[cfg(unix)]
fn write_last_message_to_path(path: &Path, contents: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.custom_flags(libc::O_NOFOLLOW)
.open(path)?;
file.write_all(contents.as_bytes())
}
#[cfg(not(unix))]
fn write_last_message_to_path(path: &Path, contents: &str) -> std::io::Result<()> {
std::fs::write(path, contents)
}
#[cfg(test)]
mod tests {
use super::write_last_message_to_path;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
#[test]
fn writes_last_message_to_regular_file() {
let temp_dir = tempdir().expect("tempdir");
let output_path = temp_dir.path().join("output.md");
write_last_message_to_path(&output_path, "hello").expect("write output");
assert_eq!(
std::fs::read_to_string(&output_path).expect("read output"),
"hello"
);
}
#[cfg(unix)]
#[test]
fn rejects_symlinked_last_message_path() {
let temp_dir = tempdir().expect("tempdir");
let target_path = temp_dir.path().join("target.md");
let output_path = temp_dir.path().join("output.md");
std::fs::write(&target_path, "original").expect("write target");
std::os::unix::fs::symlink(&target_path, &output_path).expect("create symlink");
let err =
write_last_message_to_path(&output_path, "hello").expect_err("symlink should fail");
assert_eq!(err.raw_os_error(), Some(libc::ELOOP));
assert_eq!(
std::fs::read_to_string(&target_path).expect("read target"),
"original"
);
}
}

View File

@@ -1587,6 +1587,38 @@ mod tests {
);
}
#[cfg(unix)]
#[test]
fn split_policy_remounts_symlinked_project_config_target_read_only() {
let temp_dir = TempDir::new().expect("temp dir");
let writable_root = temp_dir.path().join("workspace");
let dot_codex = writable_root.join(".codex");
let payload = writable_root.join("payload.toml");
let config = dot_codex.join("config.toml");
std::fs::create_dir_all(&dot_codex).expect("create .codex");
std::fs::write(&payload, "sandbox_mode = \"danger-full-access\"").expect("write payload");
std::os::unix::fs::symlink(&payload, &config).expect("create config symlink");
let payload_str = path_to_string(&payload);
let writable_root =
AbsolutePathBuf::from_absolute_path(&writable_root).expect("absolute writable root");
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: writable_root,
},
access: FileSystemAccessMode::Write,
}]);
let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args");
assert!(
args.args
.windows(3)
.any(|window| window == ["--ro-bind", payload_str.as_str(), payload_str.as_str()]),
"expected symlinked config.toml target to be remounted read-only: {:#?}",
args.args
);
}
#[test]
fn split_policy_reenables_nested_writable_subpaths_after_read_only_parent() {
let temp_dir = TempDir::new().expect("temp dir");

View File

@@ -1406,7 +1406,7 @@ fn default_read_only_subpaths_for_writable_root(
}
let top_level_agents = writable_root.join(".agents");
if top_level_agents.as_path().is_dir() {
if path_exists_or_is_symlink(top_level_agents.as_path()) {
subpaths.push(top_level_agents);
}
@@ -1415,9 +1415,15 @@ fn default_read_only_subpaths_for_writable_root(
// directory exists so first-time creation still goes through the
// protected-path approval flow.
let top_level_codex = writable_root.join(".codex");
if protect_missing_dot_codex || top_level_codex.as_path().is_dir() {
if protect_missing_dot_codex || path_exists_or_is_symlink(top_level_codex.as_path()) {
subpaths.push(top_level_codex);
}
// A read-only `.codex/` directory does not stop writes through a
// symlinked `.codex/config.toml` that points back into the writable root.
let top_level_codex_config = writable_root.join(".codex/config.toml");
if path_exists_or_is_symlink(top_level_codex_config.as_path()) {
subpaths.push(top_level_codex_config);
}
dedup_absolute_paths(subpaths, /*normalize_effective_paths*/ false)
}
@@ -1517,6 +1523,13 @@ fn has_explicit_resolved_path_entry(
entries.iter().any(|entry| &entry.path == path)
}
fn path_exists_or_is_symlink(path: &Path) -> bool {
path.exists()
|| std::fs::symlink_metadata(path)
.map(|metadata| metadata.file_type().is_symlink())
.unwrap_or(false)
}
fn is_git_pointer_file(path: &AbsolutePathBuf) -> bool {
path.as_path().is_file() && path.as_path().file_name() == Some(OsStr::new(".git"))
}
@@ -1965,6 +1978,44 @@ mod tests {
);
}
#[cfg(unix)]
#[test]
fn writable_roots_preserve_symlinked_project_config_file() {
let cwd = TempDir::new().expect("tempdir");
let root = cwd.path().join("root");
let dot_codex = root.join(".codex");
let payload = root.join("payload.toml");
let config = dot_codex.join("config.toml");
fs::create_dir_all(&dot_codex).expect("create .codex");
fs::write(&payload, "sandbox_mode = \"danger-full-access\"").expect("write payload");
std::os::unix::fs::symlink(&payload, &config).expect("create config symlink");
let root =
AbsolutePathBuf::from_absolute_path(root.canonicalize().expect("canonicalize root"))
.expect("absolute root");
let expected_dot_codex = root.join(".codex");
let expected_config = root.join(".codex/config.toml");
let unexpected_payload =
AbsolutePathBuf::from_absolute_path(payload).expect("absolute payload");
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Path { path: root },
access: FileSystemAccessMode::Write,
}]);
let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
assert_eq!(writable_roots.len(), 1);
assert_eq!(
writable_roots[0].read_only_subpaths,
vec![expected_dot_codex, expected_config]
);
assert!(
!writable_roots[0]
.read_only_subpaths
.contains(&unexpected_payload)
);
}
#[cfg(unix)]
#[test]
fn writable_roots_preserve_explicit_symlinked_carveouts_under_symlinked_roots() {

View File

@@ -1426,7 +1426,7 @@ fn default_read_only_subpaths_for_writable_root(
}
let top_level_agents = writable_root.join(".agents");
if top_level_agents.as_path().is_dir() {
if path_exists_or_is_symlink(top_level_agents.as_path()) {
subpaths.push(top_level_agents);
}
@@ -1435,9 +1435,15 @@ fn default_read_only_subpaths_for_writable_root(
// directory exists so first-time creation still goes through the
// protected-path approval flow.
let top_level_codex = writable_root.join(".codex");
if protect_missing_dot_codex || top_level_codex.as_path().is_dir() {
if protect_missing_dot_codex || path_exists_or_is_symlink(top_level_codex.as_path()) {
subpaths.push(top_level_codex);
}
// A read-only `.codex/` directory does not stop writes through a
// symlinked `.codex/config.toml` that points back into the writable root.
let top_level_codex_config = writable_root.join(".codex/config.toml");
if path_exists_or_is_symlink(top_level_codex_config.as_path()) {
subpaths.push(top_level_codex_config);
}
let mut deduped = Vec::with_capacity(subpaths.len());
let mut seen = HashSet::new();
@@ -1449,6 +1455,13 @@ fn default_read_only_subpaths_for_writable_root(
deduped
}
fn path_exists_or_is_symlink(path: &Path) -> bool {
path.exists()
|| std::fs::symlink_metadata(path)
.map(|metadata| metadata.file_type().is_symlink())
.unwrap_or(false)
}
fn is_git_pointer_file(path: &AbsolutePathBuf) -> bool {
path.as_path().is_file() && path.as_path().file_name() == Some(OsStr::new(".git"))
}
@@ -4511,6 +4524,7 @@ mod tests {
let cwd = TempDir::new().expect("tempdir");
std::fs::create_dir_all(cwd.path().join(".agents")).expect("create .agents");
std::fs::create_dir_all(cwd.path().join(".codex")).expect("create .codex");
std::fs::write(cwd.path().join(".codex/config.toml"), "").expect("create config.toml");
let canonical_cwd = codex_utils_absolute_path::canonicalize_preserving_symlinks(cwd.path())
.expect("canonicalize cwd");
let cwd_absolute =
@@ -4522,6 +4536,9 @@ mod tests {
.expect("canonical .agents");
let expected_codex = AbsolutePathBuf::from_absolute_path(canonical_cwd.join(".codex"))
.expect("canonical .codex");
let expected_codex_config =
AbsolutePathBuf::from_absolute_path(canonical_cwd.join(".codex/config.toml"))
.expect("canonical .codex/config.toml");
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
@@ -4574,6 +4591,12 @@ mod tests {
.iter()
.any(|path| path.as_path() == expected_codex.as_path())
);
assert!(
writable_roots[0]
.read_only_subpaths
.iter()
.any(|path| path.as_path() == expected_codex_config.as_path())
);
}
#[test]