diff --git a/crates/webcodex-cli/src/webcodex_cli/project.rs b/crates/webcodex-cli/src/webcodex_cli/project.rs index 9d8ad989..a8372ba4 100644 --- a/crates/webcodex-cli/src/webcodex_cli/project.rs +++ b/crates/webcodex-cli/src/webcodex_cli/project.rs @@ -57,10 +57,9 @@ fn effective_canonical_roots( configured: &[PathBuf], allow_cwd_anywhere: bool, ) -> Result, String> { - webcodex_runner_config::effective_allowed_roots(configured, allow_cwd_anywhere)? - .into_iter() - .map(|root| canonical_existing_directory(&root, "allowed root")) - .collect() + let effective = + webcodex_runner_config::effective_allowed_roots(configured, allow_cwd_anywhere)?; + Ok(webcodex_runner_config::paths::canonicalize_usable_allowed_roots(&effective)) } fn validate_project_authority( @@ -406,6 +405,131 @@ mod tests { assert!(!registry.exists()); } + #[test] + fn stale_first_root_does_not_block_later_matching_root() { + let tmp = tempfile::tempdir().unwrap(); + let stale = tmp.path().join("deleted-project"); + let root = tmp.path().join("root"); + let project = root.join("demo"); + let registry = tmp.path().join("registry"); + std::fs::create_dir_all(&project).unwrap(); + let config_path = tmp.path().join("runner.toml"); + config_with_policy(&config_path, ®istry, &[stale, root.clone()], false); + + let output = run_project_register(ProjectRegisterOptions { + config: config_path, + project, + json: true, + }) + .expect("a stale unrelated root must not block a later matching root"); + let output: serde_json::Value = serde_json::from_str(&output).unwrap(); + assert_eq!(output["project"]["already_registered"], false); + assert_eq!( + output["policy"]["allowed_roots"], + serde_json::json!([root.canonicalize().unwrap().to_string_lossy().to_string()]) + ); + assert!(registry.join("demo.toml").is_file()); + } + + #[test] + fn stale_root_and_valid_nonmatching_root_remain_denied() { + let tmp = tempfile::tempdir().unwrap(); + let stale = tmp.path().join("deleted-project"); + let allowed = tmp.path().join("allowed"); + let project = tmp.path().join("outside"); + let registry = tmp.path().join("registry"); + std::fs::create_dir_all(&allowed).unwrap(); + std::fs::create_dir_all(&project).unwrap(); + let config_path = tmp.path().join("runner.toml"); + config_with_policy(&config_path, ®istry, &[stale, allowed], false); + + let error = run_project_register(ProjectRegisterOptions { + config: config_path, + project, + json: false, + }) + .unwrap_err(); + assert!(error.contains("outside allowed_roots"), "{error}"); + assert!(!registry.exists()); + } + + #[test] + fn all_stale_roots_remain_denied() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("project"); + let registry = tmp.path().join("registry"); + std::fs::create_dir_all(&project).unwrap(); + let config_path = tmp.path().join("runner.toml"); + config_with_policy( + &config_path, + ®istry, + &[ + tmp.path().join("deleted-one"), + tmp.path().join("deleted-two"), + ], + false, + ); + + let error = run_project_register(ProjectRegisterOptions { + config: config_path, + project, + json: false, + }) + .unwrap_err(); + assert!(error.contains("outside allowed_roots"), "{error}"); + assert!(error.contains("allow_cwd_anywhere is false"), "{error}"); + assert!(!registry.exists()); + } + + #[cfg(unix)] + #[test] + fn project_symlink_escape_remains_denied() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("root"); + let outside = tmp.path().join("outside"); + let registry = tmp.path().join("registry"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let escape = root.join("escape"); + std::os::unix::fs::symlink(&outside, &escape).unwrap(); + let config_path = tmp.path().join("runner.toml"); + config(&config_path, ®istry, &root); + + let error = run_project_register(ProjectRegisterOptions { + config: config_path, + project: escape, + json: false, + }) + .unwrap_err(); + assert!(error.contains("outside allowed_roots"), "{error}"); + assert!(!registry.exists()); + } + + #[test] + fn stale_roots_do_not_relax_allow_cwd_anywhere_false() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("ordinary-project"); + std::fs::create_dir_all(&project).unwrap(); + let stale = tmp.path().join("deleted-project"); + + let denied_registry = tmp.path().join("denied-registry"); + let denied = register_existing_project( + &denied_registry, + &project, + std::slice::from_ref(&stale), + false, + None, + ) + .unwrap_err(); + assert!(denied.contains("allow_cwd_anywhere is false"), "{denied}"); + assert!(!denied_registry.exists()); + + let allowed_registry = tmp.path().join("allowed-registry"); + register_existing_project(&allowed_registry, &project, &[stale], true, None) + .expect("the existing allow_cwd_anywhere relaxation must remain unchanged"); + assert!(allowed_registry.join("ordinary-project.toml").is_file()); + } + #[cfg(windows)] #[test] fn raw_unc_is_rejected_before_canonicalization_even_when_explicitly_allowed() { diff --git a/crates/webcodex-runner-config/src/paths.rs b/crates/webcodex-runner-config/src/paths.rs index e5c909f2..c2debad9 100644 --- a/crates/webcodex-runner-config/src/paths.rs +++ b/crates/webcodex-runner-config/src/paths.rs @@ -386,6 +386,23 @@ fn is_windows_drive_root(_canonical_path: &Path) -> bool { false } +/// Canonicalize the `allowed_roots` entries that can currently provide path +/// authority. +/// +/// `allowed_roots` is an OR-set of independent authority candidates. A stale, +/// unmounted, unreadable, non-directory, or otherwise unresolvable candidate +/// cannot authorize anything, but it must not poison another usable root. +/// Callers still apply the authoritative path policy after this projection; +/// an empty result therefore remains fail-closed unless that policy explicitly +/// permits the target through `allow_cwd_anywhere`. +pub fn canonicalize_usable_allowed_roots(roots: &[PathBuf]) -> Vec { + roots + .iter() + .filter_map(|root| root.canonicalize().ok()) + .filter(|root| root.is_dir()) + .collect() +} + /// Authoritative pure path-policy check for Runner project registration. /// /// `canonical_path` and `canonical_allowed_roots` must already be canonicalized diff --git a/crates/webcodex-runner/src/webcodex_runner/projects.rs b/crates/webcodex-runner/src/webcodex_runner/projects.rs index 482e7f88..078c340c 100644 --- a/crates/webcodex-runner/src/webcodex_runner/projects.rs +++ b/crates/webcodex-runner/src/webcodex_runner/projects.rs @@ -889,11 +889,8 @@ pub(crate) fn validate_project_path_policy( policy: &RunnerPolicy, canonical_path: &Path, ) -> Result<(), String> { - let canonical_roots = policy - .allowed_roots - .iter() - .filter_map(|root| canonicalize_existing(root).ok()) - .collect::>(); + let canonical_roots = + webcodex_runner_config::paths::canonicalize_usable_allowed_roots(&policy.allowed_roots); webcodex_runner_config::paths::validate_project_path_policy( canonical_path, &canonical_roots,