Compare commits

...

2 Commits

Author SHA1 Message Date
Chris Bookholt
0ccc69e28c Fix clippy panic in trust gate helper 2026-03-03 09:24:08 -08:00
Chris Bookholt
415d174d78 Honor trust state for repo skill discovery 2026-03-03 09:09:33 -08:00
3 changed files with 376 additions and 9 deletions

View File

@@ -18,9 +18,9 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use dunce::canonicalize as normalize_path;
use serde::Deserialize;
use std::collections::HashSet;
use std::io;
use std::path::Path;
#[cfg(windows)]
use std::path::PathBuf;
use toml::Value as TomlValue;
@@ -701,6 +701,125 @@ async fn project_trust_context(
})
}
fn project_trust_context_sync(
merged_config: &TomlValue,
cwd: &AbsolutePathBuf,
project_root_markers: &[String],
user_config_file: &AbsolutePathBuf,
) -> io::Result<ProjectTrustContext> {
let project_trust_config: ProjectTrustConfigToml = merged_config
.clone()
.try_into()
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?;
let project_root = find_project_root_sync(cwd, project_root_markers)?;
let projects = project_trust_config.projects.unwrap_or_default();
let project_root_key = project_root.as_path().to_string_lossy().to_string();
let repo_root = resolve_root_git_project_for_trust(cwd.as_path());
let repo_root_key = repo_root
.as_ref()
.map(|root| root.to_string_lossy().to_string());
let projects_trust = projects
.into_iter()
.filter_map(|(key, project)| project.trust_level.map(|trust_level| (key, trust_level)))
.collect();
Ok(ProjectTrustContext {
project_root,
project_root_key,
repo_root_key,
projects_trust,
user_config_file: user_config_file.clone(),
})
}
pub(crate) fn trust_disabled_project_dirs_from_layer_stack(
config_layer_stack: &ConfigLayerStack,
cwd: &Path,
) -> HashSet<PathBuf> {
let Ok(cwd_abs) = AbsolutePathBuf::from_absolute_path(cwd) else {
return HashSet::new();
};
let mut merged = TomlValue::Table(toml::map::Map::new());
for layer in
config_layer_stack.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false)
{
if matches!(layer.name, ConfigLayerSource::Project { .. }) {
continue;
}
merge_toml_values(&mut merged, &layer.config);
}
let project_root_markers = match project_root_markers_from_config(&merged) {
Ok(Some(markers)) => markers,
Ok(None) => default_project_root_markers(),
Err(err) => {
tracing::warn!("invalid project_root_markers while gating skills: {err}");
default_project_root_markers()
}
};
let user_config_file = config_layer_stack
.get_user_layer()
.and_then(|layer| match &layer.name {
ConfigLayerSource::User { file } => Some(file.clone()),
_ => None,
})
.or_else(|| cwd_abs.join(CONFIG_TOML_FILE).ok());
let Some(user_config_file) = user_config_file else {
tracing::warn!("failed to synthesize a user config path while gating skills");
return HashSet::new();
};
let trust_context = match project_trust_context_sync(
&merged,
&cwd_abs,
&project_root_markers,
&user_config_file,
) {
Ok(context) => context,
Err(err) => {
tracing::warn!("failed to evaluate project trust while gating skills: {err}");
return HashSet::new();
}
};
let mut disabled = HashSet::new();
let dirs = cwd_abs
.as_path()
.ancestors()
.scan(false, |done, ancestor| {
if *done {
None
} else {
if ancestor == trust_context.project_root.as_path() {
*done = true;
}
Some(ancestor)
}
})
.collect::<Vec<_>>();
for dir in dirs {
let Ok(dir_abs) = AbsolutePathBuf::from_absolute_path(dir) else {
continue;
};
if trust_context.decision_for_dir(&dir_abs).is_trusted() {
continue;
}
disabled.insert(dir_abs.to_path_buf());
let normalized =
normalize_path(dir_abs.as_path()).unwrap_or_else(|_| dir_abs.to_path_buf());
disabled.insert(normalized);
}
disabled
}
/// Takes a `toml::Value` parsed from a config.toml file and walks through it,
/// resolving any `AbsolutePathBuf` fields against `base_dir`, returning a new
/// `toml::Value` with the same shape but with paths resolved.
@@ -780,6 +899,25 @@ async fn find_project_root(
Ok(cwd.clone())
}
fn find_project_root_sync(
cwd: &AbsolutePathBuf,
project_root_markers: &[String],
) -> io::Result<AbsolutePathBuf> {
if project_root_markers.is_empty() {
return Ok(cwd.clone());
}
for ancestor in cwd.as_path().ancestors() {
for marker in project_root_markers {
if ancestor.join(marker).exists() {
return AbsolutePathBuf::from_absolute_path(ancestor);
}
}
}
Ok(cwd.clone())
}
/// Return the appropriate list of layers (each with
/// [ConfigLayerSource::Project] as the source) between `cwd` and
/// `project_root`, inclusive. The list is ordered in _increasing_ precdence,

View File

@@ -3,6 +3,7 @@ use crate::config_loader::ConfigLayerStackOrdering;
use crate::config_loader::default_project_root_markers;
use crate::config_loader::merge_toml_values;
use crate::config_loader::project_root_markers_from_config;
use crate::config_loader::trust_disabled_project_dirs_from_layer_stack;
use crate::plugins::plugin_namespace_for_skill_path;
use crate::skills::model::SkillDependencies;
use crate::skills::model::SkillError;
@@ -211,7 +212,13 @@ fn skill_roots_with_home_dir(
path,
scope: SkillScope::User,
}));
roots.extend(repo_agents_skill_roots(config_layer_stack, cwd));
let trust_disabled_project_dirs =
trust_disabled_project_dirs_from_layer_stack(config_layer_stack, cwd);
roots.extend(repo_agents_skill_roots(
config_layer_stack,
cwd,
&trust_disabled_project_dirs,
));
dedupe_skill_roots_by_path(&mut roots);
roots
}
@@ -223,7 +230,7 @@ fn skill_roots_from_layer_stack_inner(
let mut roots = Vec::new();
for layer in
config_layer_stack.get_layers(ConfigLayerStackOrdering::HighestPrecedenceFirst, true)
config_layer_stack.get_layers(ConfigLayerStackOrdering::HighestPrecedenceFirst, false)
{
let Some(config_folder) = layer.config_folder() else {
continue;
@@ -277,12 +284,22 @@ fn skill_roots_from_layer_stack_inner(
roots
}
fn repo_agents_skill_roots(config_layer_stack: &ConfigLayerStack, cwd: &Path) -> Vec<SkillRoot> {
fn repo_agents_skill_roots(
config_layer_stack: &ConfigLayerStack,
cwd: &Path,
trust_disabled_project_dirs: &HashSet<PathBuf>,
) -> Vec<SkillRoot> {
let project_root_markers = project_root_markers_from_stack(config_layer_stack);
let project_root = find_project_root(cwd, &project_root_markers);
let dirs = dirs_between_project_root_and_cwd(cwd, &project_root);
let mut roots = Vec::new();
for dir in dirs {
let normalized_dir = canonicalize_path(&dir).unwrap_or_else(|_| dir.clone());
if trust_disabled_project_dirs.contains(&dir)
|| trust_disabled_project_dirs.contains(&normalized_dir)
{
continue;
}
let agents_skills = dir.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME);
if agents_skills.is_dir() {
roots.push(SkillRoot {
@@ -990,7 +1007,7 @@ mod tests {
}
#[test]
fn skill_roots_from_layer_stack_includes_disabled_project_layers() -> anyhow::Result<()> {
fn skill_roots_from_layer_stack_excludes_disabled_project_layers() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let home_folder = tmp.path().join("home");
@@ -1031,7 +1048,6 @@ mod tests {
assert_eq!(
got,
vec![
(SkillScope::Repo, dot_codex.join("skills")),
(SkillScope::User, user_folder.join("skills")),
(
SkillScope::User,
@@ -1047,6 +1063,80 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn repo_agents_skill_roots_are_gated_by_project_trust() {
let tmp = tempfile::tempdir().expect("tempdir");
let codex_home = tmp.path().join("home");
fs::create_dir_all(&codex_home).expect("create codex home");
let repo_root = tmp.path().join("repo");
let nested = repo_root.join("workspace/member");
fs::create_dir_all(&nested).expect("create nested cwd");
mark_as_git_repo(&repo_root);
let repo_agents_skill = write_skill_at(
&repo_root.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME),
"root",
"repo-root-skill",
"from repo root",
);
let nested_agents_skill = write_skill_at(
&nested.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME),
"nested",
"nested-skill",
"from nested dir",
);
fs::write(
codex_home.join(CONFIG_TOML_FILE),
toml::to_string(&ConfigToml {
projects: Some(HashMap::from([(
repo_root.to_string_lossy().to_string(),
ProjectConfig {
trust_level: Some(TrustLevel::Untrusted),
},
)])),
..Default::default()
})
.expect("serialize config"),
)
.expect("write user config");
let config = ConfigBuilder::default()
.codex_home(codex_home.clone())
.harness_overrides(ConfigOverrides {
cwd: Some(nested.clone()),
..Default::default()
})
.build()
.await
.expect("build config");
let roots = super::skill_roots(&config.config_layer_stack, &config.cwd, Vec::new());
assert!(
roots
.iter()
.all(|root| !root.path.starts_with(repo_root.join(AGENTS_DIR_NAME))),
"untrusted repo .agents/skills roots should be skipped"
);
let outcome = load_skills_from_roots(roots);
assert!(
outcome
.skills
.iter()
.all(|skill| skill.path_to_skills_md != normalized(&repo_agents_skill)),
"repo root .agents skill should not load"
);
assert!(
outcome
.skills
.iter()
.all(|skill| skill.path_to_skills_md != normalized(&nested_agents_skill)),
"nested .agents skill should not load"
);
}
#[test]
fn loads_skills_from_home_agents_dir_for_user_scope() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;

View File

@@ -18,6 +18,7 @@ use crate::config_loader::CloudRequirementsLoader;
use crate::config_loader::ConfigLayerStackOrdering;
use crate::config_loader::LoaderOverrides;
use crate::config_loader::load_config_layers_state;
use crate::config_loader::trust_disabled_project_dirs_from_layer_stack;
use crate::plugins::PluginsManager;
use crate::skills::SkillLoadOutcome;
use crate::skills::build_implicit_skill_path_indexes;
@@ -54,8 +55,11 @@ impl SkillsManager {
}
let roots = self.skill_roots_for_config(config);
let outcome =
finalize_skill_outcome(load_skills_from_roots(roots), &config.config_layer_stack);
let outcome = finalize_skill_outcome(
load_skills_from_roots(roots),
&config.config_layer_stack,
cwd,
);
let mut cache = match self.cache_by_cwd.write() {
Ok(cache) => cache,
Err(err) => err.into_inner(),
@@ -152,7 +156,7 @@ impl SkillsManager {
.skills
.retain(|skill| skill.scope != SkillScope::System);
}
let outcome = finalize_skill_outcome(outcome, &config_layer_stack);
let outcome = finalize_skill_outcome(outcome, &config_layer_stack, cwd);
let mut cache = match self.cache_by_cwd.write() {
Ok(cache) => cache,
Err(err) => err.into_inner(),
@@ -220,11 +224,41 @@ fn disabled_paths_from_stack(
disabled
}
fn trust_disabled_skill_paths(
skills: &[crate::skills::SkillMetadata],
config_layer_stack: &crate::config_loader::ConfigLayerStack,
cwd: &Path,
) -> HashSet<PathBuf> {
let trust_disabled_project_dirs =
trust_disabled_project_dirs_from_layer_stack(config_layer_stack, cwd);
if trust_disabled_project_dirs.is_empty() {
return HashSet::new();
}
skills
.iter()
.filter(|skill| skill.scope == SkillScope::Repo)
.filter(|skill| {
skill
.path_to_skills_md
.ancestors()
.any(|ancestor| trust_disabled_project_dirs.contains(ancestor))
})
.map(|skill| skill.path_to_skills_md.clone())
.collect()
}
fn finalize_skill_outcome(
mut outcome: SkillLoadOutcome,
config_layer_stack: &crate::config_loader::ConfigLayerStack,
cwd: &Path,
) -> SkillLoadOutcome {
outcome.disabled_paths = disabled_paths_from_stack(config_layer_stack);
outcome.disabled_paths.extend(trust_disabled_skill_paths(
&outcome.skills,
config_layer_stack,
cwd,
));
let (by_scripts_dir, by_doc_path) =
build_implicit_skill_path_indexes(outcome.allowed_skills_for_implicit_invocation());
outcome.implicit_skills_by_scripts_dir = Arc::new(by_scripts_dir);
@@ -251,12 +285,17 @@ mod tests {
use super::*;
use crate::config::ConfigBuilder;
use crate::config::ConfigOverrides;
use crate::config::ConfigToml;
use crate::config::ProjectConfig;
use crate::config_loader::ConfigLayerEntry;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigRequirementsToml;
use crate::plugins::PluginsManager;
use codex_protocol::config_types::TrustLevel;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use tempfile::TempDir;
@@ -267,6 +306,15 @@ mod tests {
fs::write(skill_dir.join("SKILL.md"), content).unwrap();
}
fn write_skill_at(root: &Path, dir: &str, name: &str, description: &str) -> PathBuf {
let skill_dir = root.join(dir);
fs::create_dir_all(&skill_dir).unwrap();
let content = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n");
let skill_path = skill_dir.join("SKILL.md");
fs::write(&skill_path, content).unwrap();
skill_path
}
#[tokio::test]
async fn skills_for_config_seeds_cache_by_cwd() {
let codex_home = tempfile::tempdir().expect("tempdir");
@@ -521,4 +569,95 @@ enabled = false
HashSet::from([skill_path])
);
}
#[tokio::test]
async fn finalize_skill_outcome_disables_repo_skills_under_untrusted_project_dirs() {
let tmp = tempfile::tempdir().expect("tempdir");
let codex_home = tmp.path().join("home");
fs::create_dir_all(&codex_home).expect("create codex home");
let repo_root = tmp.path().join("repo");
let nested = repo_root.join("workspace/member");
fs::create_dir_all(&nested).expect("create nested cwd");
fs::write(repo_root.join(".git"), "gitdir: fake\n").expect("mark repo");
fs::write(
codex_home.join("config.toml"),
toml::to_string(&ConfigToml {
projects: Some(HashMap::from([(
repo_root.to_string_lossy().to_string(),
ProjectConfig {
trust_level: Some(TrustLevel::Untrusted),
},
)])),
..Default::default()
})
.expect("serialize config"),
)
.expect("write user config");
let config = ConfigBuilder::default()
.codex_home(codex_home.clone())
.harness_overrides(ConfigOverrides {
cwd: Some(nested.clone()),
..Default::default()
})
.build()
.await
.expect("build config");
let repo_skill_path = write_skill_at(
&repo_root.join(".agents").join("skills"),
"repo",
"repo-skill",
"repo skill",
);
let user_skill_path = write_skill_at(
&codex_home.join("skills"),
"user",
"user-skill",
"user skill",
);
let outcome = finalize_skill_outcome(
crate::skills::SkillLoadOutcome {
skills: vec![
crate::skills::SkillMetadata {
name: "repo-skill".to_string(),
description: "repo skill".to_string(),
short_description: None,
interface: None,
dependencies: None,
policy: None,
permission_profile: None,
path_to_skills_md: repo_skill_path.clone(),
scope: SkillScope::Repo,
},
crate::skills::SkillMetadata {
name: "user-skill".to_string(),
description: "user skill".to_string(),
short_description: None,
interface: None,
dependencies: None,
policy: None,
permission_profile: None,
path_to_skills_md: user_skill_path.clone(),
scope: SkillScope::User,
},
],
..Default::default()
},
&config.config_layer_stack,
&config.cwd,
);
assert!(outcome.disabled_paths.contains(&repo_skill_path));
assert!(!outcome.disabled_paths.contains(&user_skill_path));
assert!(
outcome
.allowed_skills_for_implicit_invocation()
.iter()
.all(|skill| skill.path_to_skills_md != repo_skill_path)
);
}
}