diff --git a/codex-rs/app-server/src/request_processors/feedback_doctor_report.rs b/codex-rs/app-server/src/request_processors/feedback_doctor_report.rs index d039cad99d..905f721f1d 100644 --- a/codex-rs/app-server/src/request_processors/feedback_doctor_report.rs +++ b/codex-rs/app-server/src/request_processors/feedback_doctor_report.rs @@ -20,7 +20,9 @@ use tokio::process::Command; use tokio::time::timeout; use tracing::warn; -const DOCTOR_FEEDBACK_REPORT_TIMEOUT: Duration = Duration::from_secs(25); +// Keep the existing 25-second diagnostic allowance plus the filesystem +// probe budget (eight seconds) and a small amount of process overhead. +const DOCTOR_FEEDBACK_REPORT_TIMEOUT: Duration = Duration::from_secs(35); const MAX_DOCTOR_TAG_VALUE_LEN: usize = 256; /// Redacted doctor report data that can be merged into a feedback upload. diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index 0618a44ee6..1108b35fab 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -74,6 +74,7 @@ use supports_color::Stream; mod background; mod desktop; mod disk; +mod filesystem_paths; mod git; mod network; mod output; @@ -155,6 +156,10 @@ const NARROW_TERMINAL_ROWS: u16 = 24; /// detailed diagnostics by default; --summary keeps the terminal output compact. #[derive(Debug, Parser)] pub struct DoctorCommand { + /// Internal isolated filesystem probe; exits before loading configuration. + #[arg(long, hide = true)] + probe_filesystem_path: Option, + /// Emit a redacted machine-readable report. #[arg(long, default_value_t = false)] json: bool, @@ -320,6 +325,9 @@ pub async fn run_doctor( interactive: &TuiCli, arg0_paths: &Arg0DispatchPaths, ) -> anyhow::Result<()> { + if let Some(path) = &command.probe_filesystem_path { + std::process::exit(filesystem_paths::probe_exit_code(path)); + } let report = build_report(&command, root_config_overrides, interactive, arg0_paths).await; if command.json { @@ -380,6 +388,14 @@ async fn build_report( })); match &config_result { Ok(config) => { + // Other checks below do synchronous work inside join!. Keep the + // probe deadlines independent of those checks' scheduler delays. + let filesystem_paths_check = run_async_check( + "filesystem paths", + progress.clone(), + filesystem_paths::check(config), + ) + .await; let auth_manager_result = AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true).await; let auth_manager = auth_manager_result.as_ref().ok().cloned(); @@ -487,6 +503,7 @@ async fn build_report( websocket_check, mcp_check, sandbox_check, + filesystem_paths_check, terminal_check, git_check, terminal_title_check, diff --git a/codex-rs/cli/src/doctor/filesystem_paths.rs b/codex-rs/cli/src/doctor/filesystem_paths.rs new file mode 100644 index 0000000000..a95126ceb4 --- /dev/null +++ b/codex-rs/cli/src/doctor/filesystem_paths.rs @@ -0,0 +1,385 @@ +//! Checks literal sandbox grants without traversing directory trees or probing deny rules. +//! +//! Filesystem calls run in disposable copies of this executable: a blocked mount +//! must not occupy a Tokio blocking worker and delay doctor's runtime shutdown. +//! Budgets bound helper waits, not synchronous process launch; policy is unchanged. +//! Windows only lists paths: even local-looking paths can redirect to network shares. +//! Restricted-read policies also only list paths: lexical checks cannot constrain symlinks. + +use std::collections::BTreeSet; +use std::collections::HashMap; +use std::io; +use std::path::Path; +use std::process::Stdio; +use std::time::Duration; +use std::time::Instant; + +use codex_config::ConfigPathContext; +use codex_config::format_config_layer_source; +use codex_config::permissions_toml::FilesystemPermissionToml; +use codex_core::config::Config; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::FileSystemSandboxPolicyContext; +use codex_protocol::permissions::ReadDenyMatcher; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::AbsolutePathBufGuard; +use codex_utils_path_uri::PathConvention; +use codex_utils_path_uri::PathUri; +use tokio::process::Command; + +use super::CheckStatus; +use super::DoctorCheck; +use super::DoctorIssue; + +const PROBE_TIMEOUT: Duration = Duration::from_secs(2); +const TOTAL_TIMEOUT: Duration = Duration::from_secs(8); +const SLOW_PROBE: Duration = Duration::from_secs(1); +const MAX_PATHS: usize = 32; + +pub(super) async fn check(config: &Config) -> DoctorCheck { + let mut check = DoctorCheck::new( + "sandbox.filesystem_paths", + "sandbox", + CheckStatus::Ok, + "configured filesystem paths respond promptly", + ); + // Resolve policy semantics lexically, without querying the configured paths. + let policy = config + .permissions + .permission_profile() + .file_system_sandbox_policy(); + let cwd = PathUri::from_abs_path(&config.cwd); + let roots = config.effective_workspace_roots(); + let home = AbsolutePathBufGuard::home_directory() + .and_then(|home| PathUri::from_host_native_path(home).ok()); + let temporary_directories = std::env::var_os("TMPDIR") + .filter(|path| !path.is_empty()) + .and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok()) + .map(PathUri::from) + .into_iter() + .collect::>(); + let context = FileSystemSandboxPolicyContext { + cwd: &cwd, + workspace_roots: &roots, + user_home_dir: home.as_ref(), + temporary_directories: Some(&temporary_directories), + }; + let paths = literal_paths(&policy, &context); + if paths.is_empty() { + check.summary = "no explicit filesystem paths to probe".to_string(); + return check; + } + let read_restricted = !policy.has_full_disk_read_access(); + if read_restricted { + check.summary = + "configured filesystem paths listed; read restrictions prevent probes".to_string(); + } else if cfg!(windows) { + check.summary = + "configured filesystem paths listed; probes disabled on Windows".to_string(); + } + if let Some(profile) = config.permissions.active_permission_profile() { + check + .details + .push(format!("permission profile: {}", profile.id)); + } + check + .details + .push("operation: resolve paths only; read/write access is not tested".to_string()); + check + .details + .push("probe budgets: 2 seconds per path, 8 seconds total, at most 32 paths".to_string()); + let started = Instant::now(); + let mut checked = 0; + for (path, access) in paths.iter().take(MAX_PATHS) { + let remaining = TOTAL_TIMEOUT.saturating_sub(started.elapsed()); + if remaining.is_zero() { + break; + } + let label = format!("path {}", checked + 1); + let probe_started = Instant::now(); + let outcome = match path.to_abs_path() { + Ok(_) if read_restricted => ProbeResult::SkippedReadRestrictions, + Ok(_) if cfg!(windows) => ProbeResult::SkippedWindows, + Ok(native) => { + let executable = match std::env::current_exe() { + Ok(executable) => executable, + Err(_) => { + check.status = CheckStatus::Warning; + check.summary = + "filesystem path probes unavailable: cannot locate Codex executable" + .to_string(); + return check; + } + }; + let mut command = Command::new(&executable); + command + .arg("doctor") + .arg("--probe-filesystem-path") + .arg(native.as_path()); + probe(&mut command, remaining.min(PROBE_TIMEOUT)).await + } + Err(_) => ProbeResult::Unsupported, + }; + let slow = probe_started.elapsed() >= SLOW_PROBE; + checked += 1; + let access = access + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "); + check.details.push(format!( + "{label}: {} ({access}); {}", + path.inferred_native_path_string(), + outcome.description() + )); + if slow { + check.details.push(format!( + "{label} latency: over 1 second (includes helper startup)" + )); + } + check + .details + .push(format!("{label} source: {}", source(config, path))); + if !matches!( + outcome, + ProbeResult::Resolved + | ProbeResult::Missing + | ProbeResult::SkippedWindows + | ProbeResult::SkippedReadRestrictions + ) || slow + { + check.status = CheckStatus::Warning; + check.summary = + "some configured filesystem paths are slow or could not be checked".to_string(); + check.issues.push( + DoctorIssue::new(CheckStatus::Warning, format!("{label}: {}{}", outcome.description(), if slow { "; slow path probe" } else { "" })) + .field(label) + .remedy("Check this path's mount and host permissions. Ask your administrator whether the entry is needed on this machine; do not remove required restrictions."), + ); + } + } + if checked < paths.len() { + check.status = CheckStatus::Warning; + check.summary = "filesystem path check is incomplete".to_string(); + check.details.push(format!( + "unchecked paths: {} (probe budget exhausted)", + paths.len() - checked + )); + } + check + .details + .push(format!("paths checked: {checked} of {}", paths.len())); + check +} + +fn literal_paths( + policy: &FileSystemSandboxPolicy, + context: &FileSystemSandboxPolicyContext<'_>, +) -> Vec<(PathUri, BTreeSet)> { + let mut paths = HashMap::<_, BTreeSet<_>>::new(); + let denials = ReadDenyMatcher::from_context(policy, context); + for entry in &policy.entries { + // Deny rules can contain private managed-policy paths. Doctor only + // reports their counts in the existing sandbox check. + if entry.access == FileSystemAccessMode::Deny { + continue; + } + match &entry.path { + FileSystemPath::Path { path } => { + if policy.can_read_path(path, context) + && !denials + .as_ref() + .is_some_and(|matcher| matcher.is_read_denied_uri(path, context)) + { + paths.entry(path.clone()).or_default().insert(entry.access); + } + } + FileSystemPath::GlobPattern { .. } | FileSystemPath::Special { .. } => {} + } + } + let mut paths = paths.into_iter().collect::>(); + paths.sort_by_cached_key(|(path, _)| path.to_string()); + paths +} + +fn source(config: &Config, path: &PathUri) -> String { + let active = config.permissions.active_permission_profile(); + let context = ConfigPathContext::new( + PathConvention::native(), + Some(PathUri::from_abs_path(&config.cwd)), + AbsolutePathBufGuard::home_directory() + .and_then(|home| PathUri::from_host_native_path(home).ok()), + ); + let effective = config.config_layer_stack.effective_config(); + let managed = config + .config_layer_stack + .requirements_toml() + .permissions + .as_ref(); + let matches_path = |base: &str, subpath: Option<&str>| { + let base = if subpath.is_none() { + base.strip_suffix("/**").unwrap_or(base) + } else { + base + }; + let Ok(base) = context.resolve_path(base) else { + return false; + }; + match subpath.map(|subpath| subpath.strip_suffix("/**").unwrap_or(subpath)) { + None | Some(".") => base == *path, + Some(subpath) => context + .resolve_against(subpath, &base) + .is_ok_and(|resolved| resolved == *path), + } + }; + let mut profiles = Vec::new(); + let mut next = active.as_ref().map(|profile| profile.id.as_str()); + while let Some(profile) = next { + if profiles.contains(&profile) { + break; + } + profiles.push(profile); + next = effective + .get("permissions") + .and_then(|permissions| permissions.get(profile)) + .and_then(|profile| profile.get("extends")) + .and_then(toml::Value::as_str) + .or_else(|| managed?.profiles.get(profile)?.extends.as_deref()); + } + for profile in profiles { + // Managed grants have no config-layer origin and can shadow a parent's grant. + if managed + .and_then(|managed| managed.profiles.get(profile)) + .and_then(|profile| profile.filesystem.as_ref()) + .is_some_and(|filesystem| { + filesystem.entries.iter().any(|(base, entry)| match entry { + FilesystemPermissionToml::Access(access) => { + *access != FileSystemAccessMode::Deny + && matches_path(base, /*subpath*/ None) + } + FilesystemPermissionToml::Scoped(entries) => { + entries.iter().any(|(subpath, access)| { + *access != FileSystemAccessMode::Deny + && matches_path(base, Some(subpath)) + }) + } + }) + }) + { + break; + } + let origins = config + .config_layer_stack + .origins_with_path_filter(|segments| { + if segments.len() < 4 || segments[0] != "permissions" || segments[2] != "filesystem" + { + return false; + } + if profile != segments[1] { + return false; + } + // Origins can retain leaves removed by a table/scalar replacement. + if !matches!( + segments + .iter() + .try_fold(&effective, |value, segment| value.get(segment)) + .and_then(toml::Value::as_str), + Some("read" | "write") + ) { + return false; + } + matches_path(&segments[3], segments.get(4).map(String::as_str)) + }); + let sources = origins + .into_iter() + .map(|(key, origin)| { + format!( + "{key} in {}", + format_config_layer_source(&origin.name, codex_config::CONFIG_TOML_FILE) + ) + }) + .collect::>(); + if !sources.is_empty() { + return sources.into_iter().collect::>().join("; "); + } + } + // Compiled requirements, legacy config, and runtime-added entries do not + // retain per-entry provenance. Do not misattribute them to a config file. + "effective filesystem policy (entry provenance unavailable)".to_string() +} + +#[derive(Debug, PartialEq, Eq)] +enum ProbeResult { + Resolved, + Missing, + Denied, + TimedOut, + Failed, + Unsupported, + SkippedWindows, + SkippedReadRestrictions, +} + +impl ProbeResult { + fn description(&self) -> &'static str { + match self { + Self::Resolved => "path resolved (read/write access not tested)", + Self::Missing => "missing (may be intentional)", + Self::Denied => "access denied to doctor", + Self::TimedOut => "path probe timed out", + Self::Failed => "path probe failed", + Self::Unsupported => "path is not native to this host; not probed", + Self::SkippedWindows => "not probed on Windows (network authentication risk)", + Self::SkippedReadRestrictions => "not probed (filesystem read restrictions apply)", + } + } +} + +async fn probe(command: &mut Command, budget: Duration) -> ProbeResult { + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(/*kill_on_drop*/ true); + let Ok(mut child) = command.spawn() else { + return ProbeResult::Failed; + }; + match tokio::time::timeout(budget, child.wait()).await { + Ok(Ok(status)) => match status.code() { + Some(0) => ProbeResult::Resolved, + Some(2) => ProbeResult::Missing, + Some(3) => ProbeResult::Denied, + Some(5) => ProbeResult::SkippedWindows, + _ => ProbeResult::Failed, + }, + Ok(Err(_)) => ProbeResult::Failed, + Err(_) => { + // Do not await termination: an uninterruptible filesystem syscall + // can delay even SIGKILL. Tokio reaps the child when it can exit. + let _ = child.start_kill(); + ProbeResult::TimedOut + } + } +} + +pub(super) fn probe_exit_code(path: &Path) -> i32 { + // Even drive-qualified paths can traverse reparse points to network shares. + // Do not initiate filesystem access on Windows from this unsandboxed helper. + if cfg!(windows) { + return 5; + } + match std::fs::canonicalize(path) { + Ok(_) => 0, + Err(error) => match error.kind() { + io::ErrorKind::NotFound => 2, + io::ErrorKind::PermissionDenied => 3, + _ => 4, + }, + } +} + +#[cfg(test)] +#[path = "filesystem_paths_tests.rs"] +mod tests; diff --git a/codex-rs/cli/src/doctor/filesystem_paths_tests.rs b/codex-rs/cli/src/doctor/filesystem_paths_tests.rs new file mode 100644 index 0000000000..00e6d928ea --- /dev/null +++ b/codex-rs/cli/src/doctor/filesystem_paths_tests.rs @@ -0,0 +1,286 @@ +use std::collections::BTreeSet; +use std::time::Duration; +use std::time::Instant; + +use codex_config::LoaderOverrides; +use codex_core::config::ConfigBuilder; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::FileSystemSandboxPolicyContext; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_utils_path_uri::PathUri; +use pretty_assertions::assert_eq; +use tokio::process::Command; + +use super::ProbeResult; +use super::literal_paths; +use super::probe; +use super::source; + +#[test] +fn deduplicates_literal_paths_without_expanding_globs_or_special_paths() { + let temp = tempfile::tempdir().unwrap(); + let path = PathUri::from_host_native_path(temp.path()).unwrap(); + let mut policy = FileSystemSandboxPolicy { + entries: vec![ + FileSystemSandboxEntry { + path: path.clone().into(), + access: FileSystemAccessMode::Read, + missing_path_behavior: None, + }, + FileSystemSandboxEntry { + path: path.clone().into(), + access: FileSystemAccessMode::Write, + missing_path_behavior: None, + }, + FileSystemSandboxEntry { + path: PathUri::from_host_native_path(temp.path().join("private-policy-path")) + .unwrap() + .into(), + access: FileSystemAccessMode::Deny, + missing_path_behavior: None, + }, + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: format!("{}/**/*.env", temp.path().display()), + }, + access: FileSystemAccessMode::Deny, + missing_path_behavior: None, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + missing_path_behavior: None, + }, + ], + ..Default::default() + }; + // Equal-specificity denials, denied ancestors, and deny globs all override grants. + for suffix in [ + "private-policy-path", + "private-policy-path/child", + "secret.env", + ] { + policy.entries.push(FileSystemSandboxEntry { + path: PathUri::from_host_native_path(temp.path().join(suffix)) + .unwrap() + .into(), + access: FileSystemAccessMode::Read, + missing_path_behavior: None, + }); + } + let context = FileSystemSandboxPolicyContext { + cwd: &path, + workspace_roots: std::slice::from_ref(&path), + user_home_dir: None, + temporary_directories: Some(&[]), + }; + assert_eq!( + literal_paths(&policy, &context), + vec![( + path, + BTreeSet::from([FileSystemAccessMode::Read, FileSystemAccessMode::Write]) + )] + ); +} + +#[tokio::test] +async fn attributes_paths_inherited_through_managed_profiles_to_their_config_layer() { + let home = tempfile::tempdir().unwrap(); + let configured = home.path().join("unavailable"); + let config_file = home.path().join("config.toml"); + let base = toml::Value::String(configured.display().to_string()); + std::fs::write( + &config_file, + format!( + r#" +default_permissions = "diagnostic" +[permissions.diagnostic] +extends = "middle" +[permissions.diagnostic.filesystem.{base}] +"." = "read" +[permissions.base] +extends = ":workspace" +[permissions.base.filesystem.{base}] +"." = "write" +".git" = "write" +"subtree/**" = "read" +"overridden" = "read" +"# + ), + ) + .unwrap(); + let requirements_file = home.path().join("requirements.toml"); + std::fs::write( + &requirements_file, + format!( + r#" +[permissions.middle] +extends = "base" +[permissions.middle.filesystem.{base}] +".git/**" = "deny" +"overridden" = "write" +"# + ), + ) + .unwrap(); + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_requirements_path = Some(requirements_file); + let config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .loader_overrides(overrides) + .build() + .await + .unwrap(); + let path = PathUri::from_host_native_path(configured.join(".git")).unwrap(); + assert_eq!( + source(&config, &path), + format!( + "permissions.base.filesystem.{}..git in user ({})", + configured.display(), + config_file.display() + ) + ); + let subtree = PathUri::from_host_native_path(configured.join("subtree")).unwrap(); + assert_eq!( + source(&config, &subtree), + format!( + "permissions.base.filesystem.{}.subtree/** in user ({})", + configured.display(), + config_file.display() + ) + ); + let overridden = PathUri::from_host_native_path(configured.join("overridden")).unwrap(); + assert_eq!( + source(&config, &overridden), + "effective filesystem policy (entry provenance unavailable)" + ); + let base_path = PathUri::from_host_native_path(&configured).unwrap(); + assert_eq!( + source(&config, &base_path), + format!( + "permissions.diagnostic.filesystem.{}.. in user ({})", + configured.display(), + config_file.display() + ) + ); + let mut check = super::check(&config).await; + for detail in &mut check.details { + *detail = detail + .replace(&home.path().display().to_string(), "FIXTURE") + .replace('\\', "/"); + } + let report = super::super::DoctorReport { + schema_version: 1, + generated_at: "2026-01-01T00:00:00Z".to_string(), + overall_status: check.status, + codex_version: "test".to_string(), + checks: vec![check], + }; + insta::assert_snapshot!( + "doctor_read_restricted_filesystem_paths", + super::super::render_human_report( + &report, + super::super::HumanOutputOptions { + show_details: true, + show_all: false, + ascii: true, + color_enabled: false, + }, + ) + ); +} + +#[tokio::test] +async fn returns_when_a_probe_process_does_not_exit() { + let mut command = Command::new(std::env::current_exe().unwrap()); + command.args([ + "--ignored", + "--exact", + "doctor::filesystem_paths::tests::blocked_probe_fixture", + ]); + let started = Instant::now(); + assert_eq!( + probe(&mut command, Duration::from_millis(/*millis*/ 150)).await, + ProbeResult::TimedOut + ); + assert!(started.elapsed() < Duration::from_secs(/*secs*/ 5)); +} + +#[test] +#[ignore = "subprocess fixture for the bounded filesystem probe test"] +fn blocked_probe_fixture() { + std::thread::sleep(Duration::from_secs(/*secs*/ 30)); +} + +#[tokio::test] +async fn attributes_home_relative_subtree_paths_to_their_config_layer() { + let home = tempfile::tempdir().unwrap(); + let config_file = home.path().join("config.toml"); + std::fs::write( + &config_file, + r#" +default_permissions = "diagnostic" +[permissions.diagnostic] +extends = ":read-only" +[permissions.diagnostic.filesystem] +"~/doctor-provenance/**" = "read" +"#, + ) + .unwrap(); + let config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .build() + .await + .unwrap(); + let user_home = codex_utils_absolute_path::AbsolutePathBufGuard::home_directory().unwrap(); + let path = PathUri::from_host_native_path(user_home.join("doctor-provenance")).unwrap(); + assert_eq!( + source(&config, &path), + format!( + "permissions.diagnostic.filesystem.~/doctor-provenance/** in user ({})", + config_file.display() + ) + ); +} + +#[tokio::test] +async fn excludes_scoped_origins_replaced_by_a_scalar_grant() { + let home = tempfile::tempdir().unwrap(); + let configured = home.path().join("cache.v1"); + let base = toml::Value::String(configured.display().to_string()); + std::fs::write( + home.path().join("config.toml"), + format!( + r#" +default_permissions = "diagnostic" +[permissions.diagnostic] +extends = ":read-only" +[permissions.diagnostic.filesystem.{base}] +"." = "read" +"# + ), + ) + .unwrap(); + let config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .cli_overrides(vec![( + "permissions.diagnostic.filesystem".to_string(), + format!("{{ {base} = \"write\" }}").parse().unwrap(), + )]) + .build() + .await + .unwrap(); + let path = PathUri::from_host_native_path(&configured).unwrap(); + assert_eq!( + source(&config, &path), + format!( + "permissions.diagnostic.filesystem.{} in session-flags", + configured.display() + ) + ); +} diff --git a/codex-rs/cli/src/doctor/snapshots/codex__doctor__filesystem_paths__tests__doctor_read_restricted_filesystem_paths.snap b/codex-rs/cli/src/doctor/snapshots/codex__doctor__filesystem_paths__tests__doctor_read_restricted_filesystem_paths.snap new file mode 100644 index 0000000000..c68f6e6d40 --- /dev/null +++ b/codex-rs/cli/src/doctor/snapshots/codex__doctor__filesystem_paths__tests__doctor_read_restricted_filesystem_paths.snap @@ -0,0 +1,26 @@ +--- +source: cli/src/doctor/filesystem_paths_tests.rs +expression: "super::super::render_human_report(&report, super::super::HumanOutputOptions\n{ show_details: true, show_all: false, ascii: true, color_enabled: false, },)" +--- +Codex Doctor vtest + +Configuration + [ok] sandbox configured filesystem paths listed; read restrictions prevent probes + permission profile diagnostic + operation resolve paths only; read/write access is not tested + probe budgets 2 seconds per path, 8 seconds total, at most 32 paths + path 1 FIXTURE/unavailable (read); not probed (filesystem read restrictions apply) + path 1 source permissions.diagnostic.filesystem.FIXTURE/unavailable.. in user (FIXTURE/config.toml) + path 2 FIXTURE/unavailable/.git (write); not probed (filesystem read restrictions apply) + path 2 source permissions.base.filesystem.FIXTURE/unavailable..git in user (FIXTURE/config.toml) + path 3 FIXTURE/unavailable/overridden (write); not probed (filesystem read restrictions apply) + path 3 source effective filesystem policy (entry provenance unavailable) + path 4 FIXTURE/unavailable/subtree (read); not probed (filesystem read restrictions apply) + path 4 source permissions.base.filesystem.FIXTURE/unavailable.subtree/** in user (FIXTURE/config.toml) + paths checked 4 of 4 + +------------------------------------------------------------- +1 ok | 0 warn | 0 fail ok + +--summary compact output --all expand truncated lists +--json redacted report diff --git a/codex-rs/cli/tests/doctor_path_safety.rs b/codex-rs/cli/tests/doctor_path_safety.rs index 7447d6cc9a..d25d99dc77 100644 --- a/codex-rs/cli/tests/doctor_path_safety.rs +++ b/codex-rs/cli/tests/doctor_path_safety.rs @@ -197,6 +197,80 @@ fn non_interactive_dumb_terminal_preserves_other_doctor_failures() -> Result<()> Ok(()) } +#[test] +fn doctor_reports_configured_filesystem_paths() -> Result<()> { + let fixture = Fixture::new()?; + let config_file = fixture.home.join("config.toml"); + let original = std::fs::read_to_string(&config_file)?; + let existing = fixture.workspace.join("probe-existing"); + let missing = fixture.workspace.join("probe-missing"); + std::fs::create_dir(&existing)?; + let existing_key = toml::Value::String(existing.display().to_string()); + let missing_key = toml::Value::String(missing.display().to_string()); + std::fs::write( + &config_file, + format!( + r#"default_permissions = "diagnostic" +{original} +[permissions.diagnostic] +extends = ":read-only" +[permissions.diagnostic.filesystem] +{existing_key} = "read" +{missing_key} = "write" +"# + ), + )?; + let output = fixture.command()?.args(["doctor", "--json"]).output()?; + let report: Value = serde_json::from_slice(&output.stdout)?; + #[cfg(windows)] + assert_eq!(report["checks"]["sandbox.filesystem_paths"]["status"], "ok"); + let mut details = report["checks"]["sandbox.filesystem_paths"]["details"].clone(); + // Snapshot outcomes and provenance independently of helper startup speed. + details + .as_object_mut() + .expect("path details") + .retain(|key, _| !key.ends_with(" latency")); + let canonical_root = fixture.root.path().canonicalize()?; + for value in details.as_object_mut().expect("path details").values_mut() { + let detail = value + .as_str() + .expect("scalar path detail") + .replace(&canonical_root.display().to_string(), "FIXTURE") + .replace(&fixture.root.path().display().to_string(), "FIXTURE") + .replace('\\', "/"); + *value = Value::String(detail); + } + let snapshot_name = if cfg!(windows) { + "doctor_configured_filesystem_paths_windows" + } else { + "doctor_configured_filesystem_paths" + }; + insta::assert_snapshot!(snapshot_name, serde_json::to_string_pretty(&details)?); + Ok(()) +} + +#[test] +fn filesystem_probe_does_not_load_configuration() -> Result<()> { + let fixture = Fixture::new()?; + std::fs::write(fixture.home.join("config.toml"), "invalid TOML = [")?; + for (path, expected) in [ + (&fixture.workspace, 0), + (&fixture.workspace.join("missing"), 2), + ] { + let output = fixture + .command()? + .args(["doctor", "--probe-filesystem-path"]) + .arg(path) + .output()?; + assert_eq!( + output.status.code(), + Some(if cfg!(windows) { 5 } else { expected }) + ); + assert!(output.stdout.is_empty()); + } + Ok(()) +} + #[cfg(unix)] #[tokio::test] async fn interactive_tmux_startup_does_not_execute_workspace_helpers() -> Result<()> { diff --git a/codex-rs/cli/tests/snapshots/doctor_path_safety__doctor_configured_filesystem_paths.snap b/codex-rs/cli/tests/snapshots/doctor_path_safety__doctor_configured_filesystem_paths.snap new file mode 100644 index 0000000000..2f50a7363c --- /dev/null +++ b/codex-rs/cli/tests/snapshots/doctor_path_safety__doctor_configured_filesystem_paths.snap @@ -0,0 +1,14 @@ +--- +source: cli/tests/doctor_path_safety.rs +expression: "serde_json::to_string_pretty(&details)?" +--- +{ + "operation": "resolve paths only; read/write access is not tested", + "path 1": "FIXTURE/workspace/probe-existing (read); path resolved (read/write access not tested)", + "path 1 source": "permissions.diagnostic.filesystem.FIXTURE/workspace/probe-existing in user (FIXTURE/home/config.toml)", + "path 2": "FIXTURE/workspace/probe-missing (write); missing (may be intentional)", + "path 2 source": "permissions.diagnostic.filesystem.FIXTURE/workspace/probe-missing in user (FIXTURE/home/config.toml)", + "paths checked": "2 of 2", + "permission profile": "diagnostic", + "probe budgets": "2 seconds per path, 8 seconds total, at most 32 paths" +} diff --git a/codex-rs/cli/tests/snapshots/doctor_path_safety__doctor_configured_filesystem_paths_windows.snap b/codex-rs/cli/tests/snapshots/doctor_path_safety__doctor_configured_filesystem_paths_windows.snap new file mode 100644 index 0000000000..c815e0ef67 --- /dev/null +++ b/codex-rs/cli/tests/snapshots/doctor_path_safety__doctor_configured_filesystem_paths_windows.snap @@ -0,0 +1,14 @@ +--- +source: cli/tests/doctor_path_safety.rs +expression: "serde_json::to_string_pretty(&details)?" +--- +{ + "operation": "resolve paths only; read/write access is not tested", + "path 1": "FIXTURE/workspace/probe-existing (read); not probed on Windows (network authentication risk)", + "path 1 source": "permissions.diagnostic.filesystem.FIXTURE/workspace/probe-existing in user (FIXTURE/home/config.toml)", + "path 2": "FIXTURE/workspace/probe-missing (write); not probed on Windows (network authentication risk)", + "path 2 source": "permissions.diagnostic.filesystem.FIXTURE/workspace/probe-missing in user (FIXTURE/home/config.toml)", + "paths checked": "2 of 2", + "permission profile": "diagnostic", + "probe budgets": "2 seconds per path, 8 seconds total, at most 32 paths" +}