fix: trust-gate project hooks and exec policies (#14718)

## Summary
- trust-gate project `.codex` layers consistently, including repos that
have `.codex/hooks.json` or `.codex/execpolicy/*.rules` but no
`.codex/config.toml`
- keep disabled project layers in the config stack so nested trusted
project layers still resolve correctly, while preventing hooks and exec
policies from loading until the project is trusted
- update app-server/TUI onboarding copy to make the trust boundary
explicit and add regressions for loader, hooks, exec-policy, and
onboarding coverage

## Security
Before this change, an untrusted repo could auto-load project hooks or
exec policies from `.codex/` as long as `config.toml` was absent. This
makes trust the single gate for project-local config, hooks, and exec
policies.

## Stack
- Parent of #15936

## Test
- cargo test -p codex-core without_config_toml

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
viyatb-oai
2026-04-17 17:56:58 -07:00
committed by GitHub
co-authored by Codex
parent 06f8ec54db
commit 370bed4bf4
12 changed files with 684 additions and 123 deletions
+50 -20
View File
@@ -684,25 +684,21 @@ impl ConfigToml {
resolved_cwd: &Path,
repo_root: Option<&Path>,
) -> Option<ProjectConfig> {
let projects = self.projects.clone().unwrap_or_default();
let projects = self.projects.as_ref()?;
let resolved_cwd_key = project_trust_key(resolved_cwd);
let resolved_cwd_raw_key = resolved_cwd.to_string_lossy().to_string();
if let Some(project_config) = projects
.get(&resolved_cwd_key)
.or_else(|| projects.get(&resolved_cwd_raw_key))
{
return Some(project_config.clone());
for normalized_cwd in normalized_project_lookup_keys(resolved_cwd) {
if let Some(project_config) = project_config_for_lookup_key(projects, &normalized_cwd) {
return Some(project_config);
}
}
if let Some(repo_root) = repo_root {
let repo_root_key = project_trust_key(repo_root);
let repo_root_raw_key = repo_root.to_string_lossy().to_string();
if let Some(project_config_for_root) = projects
.get(&repo_root_key)
.or_else(|| projects.get(&repo_root_raw_key))
{
return Some(project_config_for_root.clone());
for normalized_repo_root in normalized_project_lookup_keys(repo_root) {
if let Some(project_config_for_root) =
project_config_for_lookup_key(projects, &normalized_repo_root)
{
return Some(project_config_for_root);
}
}
}
@@ -734,11 +730,45 @@ impl ConfigToml {
/// Canonicalize the path and convert it to a string to be used as a key in the
/// projects trust map. On Windows, strips UNC, when possible, to try to ensure
/// that different paths that point to the same location have the same key.
fn project_trust_key(project_path: &Path) -> String {
normalize_for_path_comparison(project_path)
.unwrap_or_else(|_| project_path.to_path_buf())
.to_string_lossy()
.to_string()
fn normalized_project_lookup_keys(path: &Path) -> Vec<String> {
let normalized_path = normalize_project_lookup_key(path.to_string_lossy().to_string());
let normalized_canonical_path = normalize_project_lookup_key(
normalize_for_path_comparison(path)
.unwrap_or_else(|_| path.to_path_buf())
.to_string_lossy()
.to_string(),
);
if normalized_path == normalized_canonical_path {
vec![normalized_canonical_path]
} else {
vec![normalized_canonical_path, normalized_path]
}
}
fn normalize_project_lookup_key(key: String) -> String {
if cfg!(windows) {
key.to_ascii_lowercase()
} else {
key
}
}
fn project_config_for_lookup_key(
projects: &HashMap<String, ProjectConfig>,
lookup_key: &str,
) -> Option<ProjectConfig> {
if let Some(project_config) = projects.get(lookup_key) {
return Some(project_config.clone());
}
let mut normalized_matches: Vec<_> = projects
.iter()
.filter(|(key, _)| normalize_project_lookup_key((*key).clone()) == lookup_key)
.collect();
normalized_matches.sort_by(|(left, _), (right, _)| left.cmp(right));
normalized_matches
.first()
.map(|(_, project_config)| (**project_config).clone())
}
pub fn validate_reserved_model_provider_ids(