mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
Add bounded filesystem path diagnostics to codex doctor (#46543)
## Why Configured filesystem paths can be slow or inaccessible. Doctor needs to identify those paths and their configuration sources without letting a blocked filesystem call delay runtime shutdown. ## What changed - Add `sandbox.filesystem_paths` to report literal filesystem grants, access modes, resolution outcomes, and configuration sources when available. Exclude deny rules and denied paths without expanding globs or special paths. - Resolve paths in disposable helper processes, with wait budgets of two seconds per path and eight seconds total, checking at most 32 paths. Warn on slow or unsuccessful probes and incomplete checks; missing paths alone do not trigger warnings. Probes do not test read/write access. - List paths without probing on Windows or when filesystem read restrictions apply. - Increase the app-server doctor report timeout from 25 to 35 seconds to accommodate the probes. ## Testing Add unit and integration coverage for path deduplication and deny filtering, configuration provenance, blocked-helper timeouts, helper execution without loading configuration, and report snapshots for resolved, missing, Windows, and read-restricted paths. GitOrigin-RevId: b0732265fc2c83ea67acc24b442e39139d705f1b
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<PathBuf>,
|
||||
|
||||
/// 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,
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
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::<Vec<_>>()
|
||||
.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<FileSystemAccessMode>)> {
|
||||
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::<Vec<_>>();
|
||||
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::<BTreeSet<_>>();
|
||||
if !sources.is_empty() {
|
||||
return sources.into_iter().collect::<Vec<_>>().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;
|
||||
@@ -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()
|
||||
)
|
||||
);
|
||||
}
|
||||
+26
@@ -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
|
||||
@@ -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<()> {
|
||||
|
||||
+14
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user