Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 128 additions & 4 deletions crates/webcodex-cli/src/webcodex_cli/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,9 @@ fn effective_canonical_roots(
configured: &[PathBuf],
allow_cwd_anywhere: bool,
) -> Result<Vec<PathBuf>, 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(
Expand Down Expand Up @@ -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, &registry, &[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, &registry, &[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,
&registry,
&[
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, &registry, &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() {
Expand Down
17 changes: 17 additions & 0 deletions crates/webcodex-runner-config/src/paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf> {
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
Expand Down
7 changes: 2 additions & 5 deletions crates/webcodex-runner/src/webcodex_runner/projects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>();
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,
Expand Down
Loading