mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
hook trust metadata and enforcement (#20321)
# Why We want shared hook trust that both the app and the TUI can build on, but the metadata is only useful if runtime behavior agrees with it. This PR adds a single backend trust model for hooks so unmanaged hooks cannot run until the current definition has been reviewed, while managed hooks remain runnable and non-configurable. # What - persist `trusted_hash` alongside hook state in `config.toml` - expose `currentHash` and derived `trustStatus` through `hooks/list` - derive trust from normalized hook definitions so equivalent hooks from `config.toml` and `hooks.json` share the same trust identity - gate unmanaged hooks on trust before they enter the runnable handler set # Reviewer Notes - key file to review is `codex-rs/hooks/src/engine/discovery.rs` - the only **core** change is schema related
This commit is contained in:
@@ -8,23 +8,27 @@ use codex_config::ConfigLayerStack;
|
||||
use codex_config::ConfigLayerStackOrdering;
|
||||
use codex_config::HookEventsToml;
|
||||
use codex_config::HookHandlerConfig;
|
||||
use codex_config::HookStateToml;
|
||||
use codex_config::HooksFile;
|
||||
use codex_config::ManagedHooksRequirementsToml;
|
||||
use codex_config::MatcherGroup;
|
||||
use codex_config::RequirementSource;
|
||||
use codex_config::TomlValue;
|
||||
use codex_config::version_for_toml;
|
||||
use codex_plugin::PluginHookSource;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use super::ConfiguredHandler;
|
||||
use super::HookListEntry;
|
||||
use crate::config_rules::disabled_hook_keys_from_stack;
|
||||
use crate::config_rules::hook_states_from_stack;
|
||||
use crate::events::common::matcher_pattern_for_event;
|
||||
use crate::events::common::validate_matcher_pattern;
|
||||
use codex_protocol::protocol::HookHandlerType;
|
||||
use codex_protocol::protocol::HookSource;
|
||||
use codex_protocol::protocol::HookTrustStatus;
|
||||
|
||||
pub(crate) struct DiscoveryResult {
|
||||
pub handlers: Vec<ConfiguredHandler>,
|
||||
@@ -36,7 +40,8 @@ struct HookHandlerSource<'a> {
|
||||
path: &'a AbsolutePathBuf,
|
||||
key_source: String,
|
||||
source: HookSource,
|
||||
disabled_hook_keys: &'a HashSet<String>,
|
||||
is_managed: bool,
|
||||
hook_states: &'a HashMap<String, HookStateToml>,
|
||||
env: HashMap<String, String>,
|
||||
plugin_id: Option<String>,
|
||||
}
|
||||
@@ -50,7 +55,7 @@ pub(crate) fn discover_handlers(
|
||||
let mut hook_entries = Vec::new();
|
||||
let mut warnings = plugin_hook_load_warnings;
|
||||
let mut display_order = 0_i64;
|
||||
let disabled_hook_keys = disabled_hook_keys_from_stack(config_layer_stack);
|
||||
let hook_states = hook_states_from_stack(config_layer_stack);
|
||||
|
||||
if let Some(config_layer_stack) = config_layer_stack {
|
||||
append_managed_requirement_handlers(
|
||||
@@ -59,14 +64,14 @@ pub(crate) fn discover_handlers(
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
config_layer_stack,
|
||||
&disabled_hook_keys,
|
||||
&hook_states,
|
||||
);
|
||||
|
||||
for layer in config_layer_stack.get_layers(
|
||||
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||
/*include_disabled*/ false,
|
||||
) {
|
||||
let hook_source = hook_source_for_config_layer_source(&layer.name);
|
||||
let (hook_source, is_managed) = hook_metadata_for_config_layer_source(&layer.name);
|
||||
let json_hooks = load_hooks_json(layer.config_folder().as_deref(), &mut warnings);
|
||||
let toml_hooks = load_toml_hooks_from_layer(layer, &mut warnings);
|
||||
|
||||
@@ -92,7 +97,8 @@ pub(crate) fn discover_handlers(
|
||||
path: &source_path,
|
||||
key_source: source_path.display().to_string(),
|
||||
source: hook_source,
|
||||
disabled_hook_keys: &disabled_hook_keys,
|
||||
is_managed,
|
||||
hook_states: &hook_states,
|
||||
env: HashMap::new(),
|
||||
plugin_id: None,
|
||||
},
|
||||
@@ -108,7 +114,7 @@ pub(crate) fn discover_handlers(
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
plugin_hook_sources,
|
||||
&disabled_hook_keys,
|
||||
&hook_states,
|
||||
);
|
||||
|
||||
DiscoveryResult {
|
||||
@@ -124,7 +130,7 @@ fn append_managed_requirement_handlers(
|
||||
warnings: &mut Vec<String>,
|
||||
display_order: &mut i64,
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
disabled_hook_keys: &HashSet<String>,
|
||||
hook_states: &HashMap<String, HookStateToml>,
|
||||
) {
|
||||
let Some(managed_hooks) = config_layer_stack.requirements().managed_hooks.as_ref() else {
|
||||
return;
|
||||
@@ -143,7 +149,8 @@ fn append_managed_requirement_handlers(
|
||||
path: &source_path,
|
||||
key_source: source_path.display().to_string(),
|
||||
source: hook_source_for_requirement_source(managed_hooks.source.as_ref()),
|
||||
disabled_hook_keys,
|
||||
is_managed: true,
|
||||
hook_states,
|
||||
env: HashMap::new(),
|
||||
plugin_id: None,
|
||||
},
|
||||
@@ -157,9 +164,8 @@ fn append_plugin_hook_sources(
|
||||
warnings: &mut Vec<String>,
|
||||
display_order: &mut i64,
|
||||
plugin_hook_sources: Vec<PluginHookSource>,
|
||||
disabled_hook_keys: &HashSet<String>,
|
||||
hook_states: &HashMap<String, HookStateToml>,
|
||||
) {
|
||||
// TODO(abhinav): check enabled/trusted state here before plugin hooks become runnable.
|
||||
for source in plugin_hook_sources {
|
||||
let PluginHookSource {
|
||||
plugin_root,
|
||||
@@ -188,7 +194,8 @@ fn append_plugin_hook_sources(
|
||||
path: &source_path,
|
||||
key_source: format!("{plugin_id}:{source_relative_path}"),
|
||||
source: HookSource::Plugin,
|
||||
disabled_hook_keys,
|
||||
is_managed: false,
|
||||
hook_states,
|
||||
env,
|
||||
plugin_id: Some(plugin_id),
|
||||
},
|
||||
@@ -374,7 +381,7 @@ fn append_matcher_groups(
|
||||
));
|
||||
continue;
|
||||
}
|
||||
for (handler_index, handler) in group.hooks.into_iter().enumerate() {
|
||||
for (handler_index, handler) in group.hooks.iter().cloned().enumerate() {
|
||||
match handler {
|
||||
HookHandlerConfig::Command {
|
||||
command,
|
||||
@@ -396,10 +403,18 @@ fn append_matcher_groups(
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let timeout_sec = timeout_sec.unwrap_or(600).max(1);
|
||||
let normalized_handler = HookHandlerConfig::Command {
|
||||
command: command.clone(),
|
||||
timeout_sec: Some(timeout_sec),
|
||||
r#async,
|
||||
status_message: status_message.clone(),
|
||||
};
|
||||
let current_hash =
|
||||
command_hook_hash(event_name, matcher, &group, normalized_handler);
|
||||
let command = source.env.iter().fold(command, |command, (key, value)| {
|
||||
command.replace(&format!("${{{key}}}"), value)
|
||||
});
|
||||
let timeout_sec = timeout_sec.unwrap_or(600).max(1);
|
||||
// TODO(abhinav): replace this positional suffix with a durable hook id.
|
||||
let key = format!(
|
||||
"{}:{}:{}:{}",
|
||||
@@ -408,8 +423,11 @@ fn append_matcher_groups(
|
||||
group_index,
|
||||
handler_index
|
||||
);
|
||||
let enabled =
|
||||
source.source.is_managed() || !source.disabled_hook_keys.contains(&key);
|
||||
let state = source.hook_states.get(&key);
|
||||
let enabled = hook_enabled(source.is_managed, state);
|
||||
let trusted_hash = hook_trusted_hash(source.is_managed, state);
|
||||
let trust_status =
|
||||
hook_trust_status(source.is_managed, ¤t_hash, trusted_hash);
|
||||
hook_entries.push(HookListEntry {
|
||||
key,
|
||||
event_name,
|
||||
@@ -423,9 +441,16 @@ fn append_matcher_groups(
|
||||
plugin_id: source.plugin_id.clone(),
|
||||
display_order: *display_order,
|
||||
enabled,
|
||||
is_managed: source.source.is_managed(),
|
||||
is_managed: source.is_managed,
|
||||
current_hash,
|
||||
trust_status,
|
||||
});
|
||||
if enabled {
|
||||
if enabled
|
||||
&& matches!(
|
||||
trust_status,
|
||||
HookTrustStatus::Managed | HookTrustStatus::Trusted
|
||||
)
|
||||
{
|
||||
handlers.push(ConfiguredHandler {
|
||||
event_name,
|
||||
matcher: matcher.map(ToOwned::to_owned),
|
||||
@@ -453,6 +478,34 @@ fn append_matcher_groups(
|
||||
}
|
||||
}
|
||||
|
||||
/// Hash a normalized, config-derived identity instead of source text so equivalent
|
||||
/// hooks from config TOML and hooks.json converge on the same trust identity.
|
||||
#[derive(Serialize)]
|
||||
struct NormalizedHookIdentity {
|
||||
event_name: &'static str,
|
||||
#[serde(flatten)]
|
||||
group: MatcherGroup,
|
||||
}
|
||||
|
||||
fn command_hook_hash(
|
||||
event_name: codex_protocol::protocol::HookEventName,
|
||||
matcher: Option<&str>,
|
||||
group: &MatcherGroup,
|
||||
normalized_handler: HookHandlerConfig,
|
||||
) -> String {
|
||||
let mut group = group.clone();
|
||||
group.matcher = matcher.map(ToOwned::to_owned);
|
||||
group.hooks = vec![normalized_handler];
|
||||
let identity = NormalizedHookIdentity {
|
||||
event_name: hook_event_key_label(event_name),
|
||||
group,
|
||||
};
|
||||
let Ok(value) = TomlValue::try_from(identity) else {
|
||||
unreachable!("normalized hook identity should serialize to TOML");
|
||||
};
|
||||
version_for_toml(&value)
|
||||
}
|
||||
|
||||
fn hook_event_key_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str {
|
||||
match event_name {
|
||||
codex_protocol::protocol::HookEventName::PreToolUse => "pre_tool_use",
|
||||
@@ -464,17 +517,45 @@ fn hook_event_key_label(event_name: codex_protocol::protocol::HookEventName) ->
|
||||
}
|
||||
}
|
||||
|
||||
fn hook_source_for_config_layer_source(source: &ConfigLayerSource) -> HookSource {
|
||||
fn hook_trust_status(
|
||||
is_managed: bool,
|
||||
current_hash: &str,
|
||||
trusted_hash: Option<&str>,
|
||||
) -> HookTrustStatus {
|
||||
if is_managed {
|
||||
HookTrustStatus::Managed
|
||||
} else {
|
||||
match trusted_hash {
|
||||
Some(trusted_hash) if trusted_hash == current_hash => HookTrustStatus::Trusted,
|
||||
Some(_) => HookTrustStatus::Modified,
|
||||
None => HookTrustStatus::Untrusted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hook_enabled(is_managed: bool, state: Option<&HookStateToml>) -> bool {
|
||||
is_managed || state.and_then(|state| state.enabled) != Some(false)
|
||||
}
|
||||
|
||||
fn hook_trusted_hash(is_managed: bool, state: Option<&HookStateToml>) -> Option<&str> {
|
||||
(!is_managed)
|
||||
.then(|| state.and_then(|state| state.trusted_hash.as_deref()))
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn hook_metadata_for_config_layer_source(source: &ConfigLayerSource) -> (HookSource, bool) {
|
||||
match source {
|
||||
ConfigLayerSource::System { .. } => HookSource::System,
|
||||
ConfigLayerSource::User { .. } => HookSource::User,
|
||||
ConfigLayerSource::Project { .. } => HookSource::Project,
|
||||
ConfigLayerSource::Mdm { .. } => HookSource::Mdm,
|
||||
ConfigLayerSource::SessionFlags => HookSource::SessionFlags,
|
||||
ConfigLayerSource::System { .. } => (HookSource::System, true),
|
||||
ConfigLayerSource::User { .. } => (HookSource::User, false),
|
||||
ConfigLayerSource::Project { .. } => (HookSource::Project, false),
|
||||
ConfigLayerSource::Mdm { .. } => (HookSource::Mdm, true),
|
||||
ConfigLayerSource::SessionFlags => (HookSource::SessionFlags, false),
|
||||
ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => {
|
||||
HookSource::LegacyManagedConfigFile
|
||||
(HookSource::LegacyManagedConfigFile, true)
|
||||
}
|
||||
ConfigLayerSource::LegacyManagedConfigTomlFromMdm => {
|
||||
(HookSource::LegacyManagedConfigMdm, true)
|
||||
}
|
||||
ConfigLayerSource::LegacyManagedConfigTomlFromMdm => HookSource::LegacyManagedConfigMdm,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,6 +589,7 @@ mod tests {
|
||||
use super::ConfiguredHandler;
|
||||
use super::append_matcher_groups;
|
||||
use codex_config::HookHandlerConfig;
|
||||
use codex_config::HookStateToml;
|
||||
use codex_config::MatcherGroup;
|
||||
use codex_config::TomlValue;
|
||||
|
||||
@@ -516,18 +598,19 @@ mod tests {
|
||||
}
|
||||
|
||||
fn hook_source() -> HookSource {
|
||||
HookSource::User
|
||||
HookSource::System
|
||||
}
|
||||
|
||||
fn hook_handler_source<'a>(
|
||||
path: &'a AbsolutePathBuf,
|
||||
disabled_hook_keys: &'a std::collections::HashSet<String>,
|
||||
hook_states: &'a std::collections::HashMap<String, HookStateToml>,
|
||||
) -> super::HookHandlerSource<'a> {
|
||||
super::HookHandlerSource {
|
||||
path,
|
||||
key_source: path.display().to_string(),
|
||||
source: hook_source(),
|
||||
disabled_hook_keys,
|
||||
is_managed: true,
|
||||
hook_states,
|
||||
env: std::collections::HashMap::new(),
|
||||
plugin_id: None,
|
||||
}
|
||||
@@ -551,14 +634,14 @@ mod tests {
|
||||
let mut warnings = Vec::new();
|
||||
let mut display_order = 0;
|
||||
let source_path = source_path();
|
||||
let disabled_hook_keys = std::collections::HashSet::new();
|
||||
let hook_states = std::collections::HashMap::new();
|
||||
|
||||
append_matcher_groups(
|
||||
&mut handlers,
|
||||
&mut Vec::new(),
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
&hook_handler_source(&source_path, &disabled_hook_keys),
|
||||
&hook_handler_source(&source_path, &hook_states),
|
||||
HookEventName::UserPromptSubmit,
|
||||
vec![command_group(Some("["))],
|
||||
);
|
||||
@@ -586,14 +669,14 @@ mod tests {
|
||||
let mut warnings = Vec::new();
|
||||
let mut display_order = 0;
|
||||
let source_path = source_path();
|
||||
let disabled_hook_keys = std::collections::HashSet::new();
|
||||
let hook_states = std::collections::HashMap::new();
|
||||
|
||||
append_matcher_groups(
|
||||
&mut handlers,
|
||||
&mut Vec::new(),
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
&hook_handler_source(&source_path, &disabled_hook_keys),
|
||||
&hook_handler_source(&source_path, &hook_states),
|
||||
HookEventName::PreToolUse,
|
||||
vec![command_group(Some("^Bash$"))],
|
||||
);
|
||||
@@ -621,14 +704,14 @@ mod tests {
|
||||
let mut warnings = Vec::new();
|
||||
let mut display_order = 0;
|
||||
let source_path = source_path();
|
||||
let disabled_hook_keys = std::collections::HashSet::new();
|
||||
let hook_states = std::collections::HashMap::new();
|
||||
|
||||
append_matcher_groups(
|
||||
&mut handlers,
|
||||
&mut Vec::new(),
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
&hook_handler_source(&source_path, &disabled_hook_keys),
|
||||
&hook_handler_source(&source_path, &hook_states),
|
||||
HookEventName::PreToolUse,
|
||||
vec![command_group(Some("*"))],
|
||||
);
|
||||
@@ -644,14 +727,14 @@ mod tests {
|
||||
let mut warnings = Vec::new();
|
||||
let mut display_order = 0;
|
||||
let source_path = source_path();
|
||||
let disabled_hook_keys = std::collections::HashSet::new();
|
||||
let hook_states = std::collections::HashMap::new();
|
||||
|
||||
append_matcher_groups(
|
||||
&mut handlers,
|
||||
&mut Vec::new(),
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
&hook_handler_source(&source_path, &disabled_hook_keys),
|
||||
&hook_handler_source(&source_path, &hook_states),
|
||||
HookEventName::PostToolUse,
|
||||
vec![command_group(Some("Edit|Write"))],
|
||||
);
|
||||
@@ -713,50 +796,50 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_source_for_config_layer_source_discards_source_details() {
|
||||
fn hook_metadata_for_config_layer_source_discards_source_details() {
|
||||
let config_file = test_path_buf("/tmp/.codex/config.toml").abs();
|
||||
let dot_codex_folder = test_path_buf("/tmp/worktree/.codex").abs();
|
||||
|
||||
assert_eq!(
|
||||
super::hook_source_for_config_layer_source(&ConfigLayerSource::System {
|
||||
super::hook_metadata_for_config_layer_source(&ConfigLayerSource::System {
|
||||
file: config_file.clone(),
|
||||
}),
|
||||
HookSource::System,
|
||||
(HookSource::System, true),
|
||||
);
|
||||
assert_eq!(
|
||||
super::hook_source_for_config_layer_source(&ConfigLayerSource::User {
|
||||
super::hook_metadata_for_config_layer_source(&ConfigLayerSource::User {
|
||||
file: config_file.clone(),
|
||||
}),
|
||||
HookSource::User,
|
||||
(HookSource::User, false),
|
||||
);
|
||||
assert_eq!(
|
||||
super::hook_source_for_config_layer_source(&ConfigLayerSource::Project {
|
||||
super::hook_metadata_for_config_layer_source(&ConfigLayerSource::Project {
|
||||
dot_codex_folder
|
||||
}),
|
||||
HookSource::Project,
|
||||
(HookSource::Project, false),
|
||||
);
|
||||
assert_eq!(
|
||||
super::hook_source_for_config_layer_source(&ConfigLayerSource::Mdm {
|
||||
super::hook_metadata_for_config_layer_source(&ConfigLayerSource::Mdm {
|
||||
domain: "com.openai.codex".to_string(),
|
||||
key: "config".to_string(),
|
||||
}),
|
||||
HookSource::Mdm,
|
||||
(HookSource::Mdm, true),
|
||||
);
|
||||
assert_eq!(
|
||||
super::hook_source_for_config_layer_source(&ConfigLayerSource::SessionFlags),
|
||||
HookSource::SessionFlags,
|
||||
super::hook_metadata_for_config_layer_source(&ConfigLayerSource::SessionFlags),
|
||||
(HookSource::SessionFlags, false),
|
||||
);
|
||||
assert_eq!(
|
||||
super::hook_source_for_config_layer_source(
|
||||
super::hook_metadata_for_config_layer_source(
|
||||
&ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: config_file },
|
||||
),
|
||||
HookSource::LegacyManagedConfigFile,
|
||||
(HookSource::LegacyManagedConfigFile, true),
|
||||
);
|
||||
assert_eq!(
|
||||
super::hook_source_for_config_layer_source(
|
||||
super::hook_metadata_for_config_layer_source(
|
||||
&ConfigLayerSource::LegacyManagedConfigTomlFromMdm,
|
||||
),
|
||||
HookSource::LegacyManagedConfigMdm,
|
||||
(HookSource::LegacyManagedConfigMdm, true),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::HookHandlerType;
|
||||
use codex_protocol::protocol::HookRunSummary;
|
||||
use codex_protocol::protocol::HookSource;
|
||||
use codex_protocol::protocol::HookTrustStatus;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -84,6 +85,8 @@ pub struct HookListEntry {
|
||||
pub display_order: i64,
|
||||
pub enabled: bool,
|
||||
pub is_managed: bool,
|
||||
pub current_hash: String,
|
||||
pub trust_status: HookTrustStatus,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
||||
@@ -22,6 +22,7 @@ use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::HookOutputEntryKind;
|
||||
use codex_protocol::protocol::HookRunStatus;
|
||||
use codex_protocol::protocol::HookSource;
|
||||
use codex_protocol::protocol::HookTrustStatus;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
|
||||
@@ -121,7 +122,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
|
||||
|
||||
assert!(engine.warnings().is_empty());
|
||||
assert_eq!(engine.handlers.len(), 1);
|
||||
assert!(engine.handlers[0].source.is_managed());
|
||||
assert_eq!(engine.handlers[0].source, HookSource::CloudRequirements);
|
||||
let listed = crate::list_hooks(crate::HooksConfig {
|
||||
legacy_notify_argv: None,
|
||||
feature_enabled: true,
|
||||
@@ -168,6 +169,68 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
|
||||
assert!(log_contents.contains("\"hook_event_name\": \"PreToolUse\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_requirement_source_hooks_stay_managed() {
|
||||
let temp = tempdir().expect("create temp dir");
|
||||
let managed_dir =
|
||||
AbsolutePathBuf::try_from(temp.path().join("managed-hooks")).expect("absolute path");
|
||||
fs::create_dir_all(managed_dir.as_path()).expect("create managed hooks dir");
|
||||
let managed_hooks = managed_hooks_for_current_platform(
|
||||
managed_dir,
|
||||
HookEventsToml {
|
||||
pre_tool_use: vec![MatcherGroup {
|
||||
matcher: Some("^Bash$".to_string()),
|
||||
hooks: vec![HookHandlerConfig::Command {
|
||||
command: "python3 /tmp/managed.py".to_string(),
|
||||
timeout_sec: Some(10),
|
||||
r#async: false,
|
||||
status_message: Some("checking".to_string()),
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let config_layer_stack = ConfigLayerStack::new(
|
||||
Vec::new(),
|
||||
ConfigRequirements {
|
||||
managed_hooks: Some(ConstrainedWithSource::new(
|
||||
Constrained::allow_any(managed_hooks.clone()),
|
||||
Some(RequirementSource::Unknown),
|
||||
)),
|
||||
..ConfigRequirements::default()
|
||||
},
|
||||
ConfigRequirementsToml {
|
||||
hooks: Some(managed_hooks),
|
||||
..ConfigRequirementsToml::default()
|
||||
},
|
||||
)
|
||||
.expect("config layer stack");
|
||||
|
||||
let engine = ClaudeHooksEngine::new(
|
||||
/*enabled*/ true,
|
||||
Some(&config_layer_stack),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
CommandShell {
|
||||
program: String::new(),
|
||||
args: Vec::new(),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(engine.handlers.len(), 1);
|
||||
assert_eq!(engine.handlers[0].source, HookSource::Unknown);
|
||||
let discovered =
|
||||
super::discovery::discover_handlers(Some(&config_layer_stack), Vec::new(), Vec::new());
|
||||
assert_eq!(discovered.hook_entries.len(), 1);
|
||||
assert_eq!(discovered.hook_entries[0].source, HookSource::Unknown);
|
||||
assert_eq!(discovered.hook_entries[0].enabled, true);
|
||||
assert_eq!(discovered.hook_entries[0].is_managed, true);
|
||||
assert_eq!(
|
||||
discovered.hook_entries[0].trust_status,
|
||||
HookTrustStatus::Managed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() {
|
||||
let temp = tempdir().expect("create temp dir");
|
||||
@@ -228,13 +291,17 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() {
|
||||
);
|
||||
|
||||
assert_eq!(engine.handlers.len(), 1);
|
||||
assert!(engine.handlers[0].source.is_managed());
|
||||
assert_eq!(engine.handlers[0].source, HookSource::CloudRequirements);
|
||||
let discovered =
|
||||
super::discovery::discover_handlers(Some(&config_layer_stack), Vec::new(), Vec::new());
|
||||
assert_eq!(discovered.hook_entries.len(), 2);
|
||||
assert_eq!(discovered.hook_entries[0].key, managed_disabled_key);
|
||||
assert_eq!(discovered.hook_entries[0].enabled, true);
|
||||
assert!(discovered.hook_entries[0].is_managed);
|
||||
assert_eq!(
|
||||
discovered.hook_entries[0].trust_status,
|
||||
HookTrustStatus::Managed
|
||||
);
|
||||
assert_eq!(discovered.hook_entries[1].key, user_disabled_key);
|
||||
assert_eq!(discovered.hook_entries[1].enabled, false);
|
||||
assert!(!discovered.hook_entries[1].is_managed);
|
||||
@@ -281,13 +348,20 @@ fn user_disablement_does_not_filter_managed_layer_hooks() {
|
||||
);
|
||||
|
||||
assert_eq!(engine.handlers.len(), 1);
|
||||
assert!(engine.handlers[0].source.is_managed());
|
||||
assert_eq!(
|
||||
engine.handlers[0].source,
|
||||
HookSource::LegacyManagedConfigFile
|
||||
);
|
||||
let discovered =
|
||||
super::discovery::discover_handlers(Some(&config_layer_stack), Vec::new(), Vec::new());
|
||||
assert_eq!(discovered.hook_entries.len(), 1);
|
||||
assert_eq!(discovered.hook_entries[0].key, managed_key);
|
||||
assert_eq!(discovered.hook_entries[0].enabled, true);
|
||||
assert!(discovered.hook_entries[0].is_managed);
|
||||
assert_eq!(
|
||||
discovered.hook_entries[0].trust_status,
|
||||
HookTrustStatus::Managed
|
||||
);
|
||||
}
|
||||
|
||||
fn config_with_hook_state(key: &str, enabled: bool) -> TomlValue {
|
||||
@@ -339,6 +413,45 @@ fn config_with_pre_tool_use_hook(command: &str) -> TomlValue {
|
||||
.expect("config TOML should deserialize")
|
||||
}
|
||||
|
||||
fn trusted_plugin_hook_stack(
|
||||
config_path: AbsolutePathBuf,
|
||||
plugin_hook_sources: &[PluginHookSource],
|
||||
) -> ConfigLayerStack {
|
||||
let discovered = super::discovery::discover_handlers(
|
||||
/*config_layer_stack*/ None,
|
||||
plugin_hook_sources.to_vec(),
|
||||
Vec::new(),
|
||||
);
|
||||
let state = discovered
|
||||
.hook_entries
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
(
|
||||
entry.key,
|
||||
serde_json::json!({
|
||||
"trusted_hash": entry.current_hash,
|
||||
}),
|
||||
)
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
let config = serde_json::from_value(serde_json::json!({
|
||||
"hooks": {
|
||||
"state": state,
|
||||
},
|
||||
}))
|
||||
.expect("config TOML should deserialize");
|
||||
|
||||
ConfigLayerStack::new(
|
||||
vec![ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User { file: config_path },
|
||||
config,
|
||||
)],
|
||||
ConfigRequirements::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("config layer stack")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requirements_managed_hooks_warn_when_managed_dir_is_missing() {
|
||||
let temp = tempdir().expect("create temp dir");
|
||||
@@ -473,7 +586,7 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() {
|
||||
config_table.insert("hooks".to_string(), hooks_table);
|
||||
let config_layer_stack = ConfigLayerStack::new(
|
||||
vec![ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User {
|
||||
ConfigLayerSource::System {
|
||||
file: config_path.clone(),
|
||||
},
|
||||
config_toml,
|
||||
@@ -514,11 +627,13 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() {
|
||||
tool_input: serde_json::json!({ "command": "echo hello" }),
|
||||
});
|
||||
assert_eq!(preview.len(), 2);
|
||||
assert!(
|
||||
assert_eq!(
|
||||
engine
|
||||
.handlers
|
||||
.iter()
|
||||
.all(|handler| !handler.source.is_managed())
|
||||
.map(|handler| handler.source)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![HookSource::System, HookSource::System]
|
||||
);
|
||||
assert_eq!(preview[0].source_path, hooks_json_path);
|
||||
assert_eq!(preview[1].source_path, config_path);
|
||||
@@ -567,9 +682,13 @@ print(json.dumps({
|
||||
..Default::default()
|
||||
},
|
||||
}];
|
||||
let config_layer_stack = trusted_plugin_hook_stack(
|
||||
AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"),
|
||||
&plugin_hook_sources,
|
||||
);
|
||||
let engine = ClaudeHooksEngine::new(
|
||||
/*enabled*/ true,
|
||||
/*config_layer_stack*/ None,
|
||||
Some(&config_layer_stack),
|
||||
plugin_hook_sources.clone(),
|
||||
Vec::new(),
|
||||
CommandShell {
|
||||
@@ -671,9 +790,13 @@ fn plugin_hook_sources_expand_plugin_placeholders() {
|
||||
..Default::default()
|
||||
},
|
||||
}];
|
||||
let config_layer_stack = trusted_plugin_hook_stack(
|
||||
AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"),
|
||||
&plugin_hook_sources,
|
||||
);
|
||||
let engine = ClaudeHooksEngine::new(
|
||||
/*enabled*/ true,
|
||||
/*config_layer_stack*/ None,
|
||||
Some(&config_layer_stack),
|
||||
plugin_hook_sources,
|
||||
Vec::new(),
|
||||
CommandShell {
|
||||
|
||||
Reference in New Issue
Block a user