fix: trust-gate project hooks and exec policies

This commit is contained in:
viyatb-oai
2026-03-26 18:06:27 -07:00
parent 81fa04783a
commit e0aaebaf16
9 changed files with 428 additions and 52 deletions
+149
View File
@@ -17,6 +17,7 @@ use crate::tools::format_exec_output_str;
use codex_features::Features;
use codex_protocol::ThreadId;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::permissions::FileSystemAccessMode;
@@ -277,6 +278,75 @@ fn developer_input_texts(items: &[ResponseItem]) -> Vec<&str> {
.collect()
}
fn write_project_hooks(dot_codex: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dot_codex)?;
std::fs::write(
dot_codex.join("hooks.json"),
r#"{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "echo hello from hook"
}
]
}
]
}
}"#,
)
}
async fn write_project_trust_config(
codex_home: &Path,
trusted_projects: &[(&Path, TrustLevel)],
) -> std::io::Result<()> {
tokio::fs::write(
codex_home.join(codex_config::CONFIG_TOML_FILE),
toml::to_string(&crate::config::ConfigToml {
projects: Some(
trusted_projects
.iter()
.map(|(project, trust_level)| {
(
project.to_string_lossy().to_string(),
crate::config::ProjectConfig {
trust_level: Some(*trust_level),
},
)
})
.collect::<std::collections::HashMap<_, _>>(),
),
..Default::default()
})
.expect("serialize config"),
)
.await
}
async fn preview_session_start_hooks(
config: &crate::config::Config,
) -> std::io::Result<Vec<codex_protocol::protocol::HookRunSummary>> {
let hooks = Hooks::new(HooksConfig {
feature_enabled: true,
config_layer_stack: Some(config.config_layer_stack.clone()),
..HooksConfig::default()
});
Ok(
hooks.preview_session_start(&codex_hooks::SessionStartRequest {
session_id: ThreadId::new(),
cwd: config.cwd.clone(),
transcript_path: None,
model: "gpt-5".to_string(),
permission_mode: "default".to_string(),
source: codex_hooks::SessionStartSource::Startup,
}),
)
}
fn test_tool_runtime(session: Arc<Session>, turn_context: Arc<TurnContext>) -> ToolCallRuntime {
let router = Arc::new(ToolRouter::from_config(
&turn_context.tools_config,
@@ -5238,3 +5308,82 @@ async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request()
pretty_assertions::assert_eq!(output, expected);
}
#[tokio::test]
async fn session_start_hooks_only_load_from_trusted_project_layers() -> std::io::Result<()> {
let temp = tempfile::tempdir()?;
let codex_home = temp.path().join("home");
let project_root = temp.path().join("project");
let nested = project_root.join("nested");
let root_dot_codex = project_root.join(".codex");
let nested_dot_codex = nested.join(".codex");
std::fs::create_dir_all(&codex_home)?;
std::fs::create_dir_all(&nested_dot_codex)?;
std::fs::write(project_root.join(".git"), "gitdir: here")?;
write_project_hooks(&root_dot_codex)?;
write_project_hooks(&nested_dot_codex)?;
write_project_trust_config(&codex_home, &[(&nested, TrustLevel::Trusted)]).await?;
let config = ConfigBuilder::default()
.codex_home(codex_home)
.fallback_cwd(Some(nested))
.build()
.await?;
let preview = preview_session_start_hooks(&config).await?;
assert_eq!(
preview
.iter()
.map(|run| &run.source_path)
.collect::<Vec<_>>(),
vec![&nested_dot_codex.join("hooks.json")],
);
Ok(())
}
#[tokio::test]
async fn session_start_hooks_require_project_trust_without_config_toml() -> std::io::Result<()> {
let temp = tempfile::tempdir()?;
let project_root = temp.path().join("project");
let nested = project_root.join("nested");
let dot_codex = project_root.join(".codex");
std::fs::create_dir_all(&nested)?;
std::fs::write(project_root.join(".git"), "gitdir: here")?;
write_project_hooks(&dot_codex)?;
let cases = [
("unknown", Vec::<(&Path, TrustLevel)>::new(), 0_usize),
(
"untrusted",
vec![(&project_root as &Path, TrustLevel::Untrusted)],
0_usize,
),
(
"trusted",
vec![(&project_root as &Path, TrustLevel::Trusted)],
1_usize,
),
];
for (name, trust_entries, expected_hooks) in cases {
let codex_home = temp.path().join(format!("home_{name}"));
std::fs::create_dir_all(&codex_home)?;
write_project_trust_config(&codex_home, &trust_entries).await?;
let config = ConfigBuilder::default()
.codex_home(codex_home)
.fallback_cwd(Some(nested.clone()))
.build()
.await?;
assert_eq!(
preview_session_start_hooks(&config).await?.len(),
expected_hooks,
"unexpected hook count for {name}",
);
}
Ok(())
}
+10 -22
View File
@@ -588,37 +588,35 @@ impl ProjectTrustContext {
}
}
fn disabled_reason_for_dir(&self, dir: &AbsolutePathBuf) -> Option<String> {
let decision = self.decision_for_dir(dir);
fn disabled_reason_for_decision(&self, decision: &ProjectTrustDecision) -> Option<String> {
if decision.is_trusted() {
return None;
}
let gated_features = "project-local config, hooks, and exec policies";
let trust_key = decision.trust_key.as_str();
let user_config_file = self.user_config_file.as_path().display();
match decision.trust_level {
Some(TrustLevel::Untrusted) => Some(format!(
"{trust_key} is marked as untrusted in {user_config_file}. To load config.toml, mark it trusted."
"{trust_key} is marked as untrusted in {user_config_file}. To load {gated_features}, mark it trusted."
)),
_ => Some(format!(
"To load config.toml, add {trust_key} as a trusted project in {user_config_file}."
"To load {gated_features}, add {trust_key} as a trusted project in {user_config_file}."
)),
}
}
}
fn project_layer_entry(
trust_context: &ProjectTrustContext,
dot_codex_folder: &AbsolutePathBuf,
layer_dir: &AbsolutePathBuf,
config: TomlValue,
config_toml_exists: bool,
disabled_reason: Option<String>,
) -> ConfigLayerEntry {
let source = ConfigLayerSource::Project {
dot_codex_folder: dot_codex_folder.clone(),
};
if config_toml_exists && let Some(reason) = trust_context.disabled_reason_for_dir(layer_dir) {
if let Some(reason) = disabled_reason {
ConfigLayerEntry::new_disabled(source, config, reason)
} else {
ConfigLayerEntry::new(source, config)
@@ -785,6 +783,7 @@ async fn load_project_layers(
let layer_dir = AbsolutePathBuf::from_absolute_path(dir)?;
let decision = trust_context.decision_for_dir(&layer_dir);
let disabled_reason = trust_context.disabled_reason_for_decision(&decision);
let dot_codex_abs = AbsolutePathBuf::from_absolute_path(&dot_codex)?;
let dot_codex_normalized =
normalize_path(dot_codex_abs.as_path()).unwrap_or_else(|_| dot_codex_abs.to_path_buf());
@@ -807,24 +806,16 @@ async fn load_project_layers(
));
}
layers.push(project_layer_entry(
trust_context,
&dot_codex_abs,
&layer_dir,
TomlValue::Table(toml::map::Map::new()),
/*config_toml_exists*/ true,
disabled_reason.clone(),
));
continue;
}
};
let config =
resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?;
let entry = project_layer_entry(
trust_context,
&dot_codex_abs,
&layer_dir,
config,
/*config_toml_exists*/ true,
);
let entry = project_layer_entry(&dot_codex_abs, config, disabled_reason.clone());
layers.push(entry);
}
Err(err) => {
@@ -833,11 +824,9 @@ async fn load_project_layers(
// for this project layer, as this may still have subfolders
// that are significant in the overall ConfigLayerStack.
layers.push(project_layer_entry(
trust_context,
&dot_codex_abs,
&layer_dir,
TomlValue::Table(toml::map::Map::new()),
/*config_toml_exists*/ false,
disabled_reason,
));
} else {
let config_file_display = config_file.as_path().display();
@@ -852,7 +841,6 @@ async fn load_project_layers(
Ok(layers)
}
/// The legacy mechanism for specifying admin-enforced configuration is to read
/// from a file like `/etc/codex/managed_config.toml` that has the same
/// structure as `config.toml` where fields like `approval_policy` can specify
+58
View File
@@ -1348,6 +1348,64 @@ async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io::
Ok(())
}
#[tokio::test]
async fn project_layer_without_config_toml_is_disabled_when_untrusted_or_unknown()
-> std::io::Result<()> {
let tmp = tempdir()?;
let project_root = tmp.path().join("project");
let nested = project_root.join("child");
tokio::fs::create_dir_all(nested.join(".codex")).await?;
tokio::fs::write(project_root.join(".git"), "gitdir: here").await?;
let cwd = AbsolutePathBuf::from_absolute_path(&nested)?;
let cases = [
("untrusted", Some(TrustLevel::Untrusted), true),
("unknown", None, true),
("trusted", Some(TrustLevel::Trusted), false),
];
for (name, trust_level, expect_disabled) in cases {
let codex_home = tmp.path().join(format!("home_no_config_{name}"));
tokio::fs::create_dir_all(&codex_home).await?;
if let Some(trust_level) = trust_level {
make_config_for_test(&codex_home, &project_root, trust_level, None).await?;
}
let layers = load_config_layers_state(
&codex_home,
Some(cwd.clone()),
&[] as &[(String, TomlValue)],
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
)
.await?;
let project_layers: Vec<_> = layers
.get_layers(
super::ConfigLayerStackOrdering::HighestPrecedenceFirst,
true,
)
.into_iter()
.filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. }))
.collect();
assert_eq!(
project_layers.len(),
1,
"expected one project layer for {name}"
);
assert_eq!(
project_layers[0].disabled_reason.is_some(),
expect_disabled,
"unexpected disabled state for {name}",
);
assert_eq!(
project_layers[0].config,
TomlValue::Table(toml::map::Map::new())
);
}
Ok(())
}
#[tokio::test]
async fn cli_overrides_with_relative_paths_do_not_break_trust_check() -> std::io::Result<()> {
let tmp = tempdir()?;
+2
View File
@@ -485,6 +485,8 @@ async fn load_exec_policy_with_warning(
}
pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result<Policy, ExecPolicyError> {
// Disabled project layers already represent the trust decision, so hooks
// and exec-policy loading can reuse the normal trusted-layer view.
// Iterate the layers in increasing order of precedence, adding the *.rules
// from each layer, so that higher-precedence layers can override
// rules defined in lower-precedence ones.
+184
View File
@@ -11,6 +11,7 @@ use crate::config_loader::RequirementSource;
use crate::config_loader::Sourced;
use codex_app_server_protocol::ConfigLayerSource;
use codex_config::RequirementsExecPolicy;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
@@ -68,6 +69,33 @@ fn starlark_string(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
async fn write_project_trust_config(
codex_home: &Path,
trusted_projects: &[(&Path, TrustLevel)],
) -> std::io::Result<()> {
tokio::fs::write(
codex_home.join(codex_config::CONFIG_TOML_FILE),
toml::to_string(&crate::config::ConfigToml {
projects: Some(
trusted_projects
.iter()
.map(|(project, trust_level)| {
(
project.to_string_lossy().to_string(),
crate::config::ProjectConfig {
trust_level: Some(*trust_level),
},
)
})
.collect::<std::collections::HashMap<_, _>>(),
),
..Default::default()
})
.expect("serialize config"),
)
.await
}
fn read_only_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
@@ -1694,3 +1722,159 @@ async fn assert_exec_approval_requirement_for_command(
assert_eq!(requirement, expected_requirement);
}
#[tokio::test]
async fn exec_policies_only_load_from_trusted_project_layers() -> std::io::Result<()> {
let temp = tempfile::tempdir()?;
let codex_home = temp.path().join("home_execpolicy_nested");
let project_root = temp.path().join("project_execpolicy_nested");
let nested = project_root.join("nested");
let root_rules = project_root.join(".codex").join(RULES_DIR_NAME);
let nested_rules = nested.join(".codex").join(RULES_DIR_NAME);
fs::create_dir_all(&codex_home)?;
fs::create_dir_all(&nested_rules)?;
fs::write(project_root.join(".git"), "gitdir: here")?;
fs::create_dir_all(&root_rules)?;
fs::write(
root_rules.join("deny-rm.rules"),
r#"prefix_rule(pattern=["rm"], decision="forbidden")"#,
)?;
fs::write(
nested_rules.join("deny-mv.rules"),
r#"prefix_rule(pattern=["mv"], decision="forbidden")"#,
)?;
write_project_trust_config(&codex_home, &[(&nested, TrustLevel::Trusted)]).await?;
let config = ConfigBuilder::default()
.codex_home(codex_home)
.fallback_cwd(Some(nested))
.build()
.await?;
let policy = load_exec_policy(&config.config_layer_stack)
.await
.map_err(std::io::Error::other)?;
assert_eq!(
policy
.check_multiple([vec!["rm".to_string()]].iter(), &|_| Decision::Allow)
.decision,
Decision::Allow,
);
assert_eq!(
policy
.check_multiple([vec!["mv".to_string()]].iter(), &|_| Decision::Allow)
.decision,
Decision::Forbidden,
);
Ok(())
}
#[tokio::test]
async fn exec_policies_require_project_trust_without_config_toml() -> std::io::Result<()> {
let temp = tempfile::tempdir()?;
let project_root = temp.path().join("project_execpolicy");
let nested = project_root.join("nested");
let rules_dir = project_root.join(".codex").join(RULES_DIR_NAME);
fs::create_dir_all(&nested)?;
fs::write(project_root.join(".git"), "gitdir: here")?;
fs::create_dir_all(&rules_dir)?;
fs::write(
rules_dir.join("deny-rm.rules"),
r#"prefix_rule(pattern=["rm"], decision="forbidden")"#,
)?;
let cases = [
(
"unknown",
Vec::<(&Path, TrustLevel)>::new(),
Decision::Allow,
),
(
"untrusted",
vec![(&project_root as &Path, TrustLevel::Untrusted)],
Decision::Allow,
),
(
"trusted",
vec![(&project_root as &Path, TrustLevel::Trusted)],
Decision::Forbidden,
),
];
for (name, trust_entries, expected_decision) in cases {
let codex_home = temp.path().join(format!("home_execpolicy_{name}"));
fs::create_dir_all(&codex_home)?;
write_project_trust_config(&codex_home, &trust_entries).await?;
let config = ConfigBuilder::default()
.codex_home(codex_home)
.fallback_cwd(Some(nested.clone()))
.build()
.await?;
let policy = load_exec_policy(&config.config_layer_stack)
.await
.map_err(std::io::Error::other)?;
assert_eq!(
policy
.check_multiple([vec!["rm".to_string()]].iter(), &|_| Decision::Allow)
.decision,
expected_decision,
"unexpected execpolicy decision for {name}",
);
}
Ok(())
}
#[tokio::test]
async fn exec_policy_warnings_ignore_untrusted_project_rules_without_config_toml()
-> std::io::Result<()> {
let temp = tempfile::tempdir()?;
let project_root = temp.path().join("project_execpolicy_warning");
let nested = project_root.join("nested");
let rules_dir = project_root.join(".codex").join(RULES_DIR_NAME);
fs::create_dir_all(&nested)?;
fs::write(project_root.join(".git"), "gitdir: here")?;
fs::create_dir_all(&rules_dir)?;
fs::write(rules_dir.join("broken.rules"), "prefix_rule(")?;
let cases = [
("unknown", Vec::<(&Path, TrustLevel)>::new(), false),
(
"untrusted",
vec![(&project_root as &Path, TrustLevel::Untrusted)],
false,
),
(
"trusted",
vec![(&project_root as &Path, TrustLevel::Trusted)],
true,
),
];
for (name, trust_entries, expect_warning) in cases {
let codex_home = temp.path().join(format!("home_execpolicy_warning_{name}"));
fs::create_dir_all(&codex_home)?;
write_project_trust_config(&codex_home, &trust_entries).await?;
let config = ConfigBuilder::default()
.codex_home(codex_home)
.fallback_cwd(Some(nested.clone()))
.build()
.await?;
let warning = check_execpolicy_for_warnings(&config.config_layer_stack)
.await
.map_err(std::io::Error::other)?;
assert_eq!(
matches!(warning, Some(ExecPolicyError::ParsePolicy { .. })),
expect_warning,
"unexpected execpolicy warning state for {name}",
);
}
Ok(())
}