mirror of
https://github.com/openai/codex.git
synced 2026-05-22 20:14:17 +00:00
Compare commits
3 Commits
dev/adaley
...
abhinav/sk
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81e8603cca | ||
|
|
0f5b32b78f | ||
|
|
0615194a36 |
@@ -685,6 +685,7 @@ fn analytics_hook_source(source: HookSource) -> &'static str {
|
||||
HookSource::Mdm => "mdm",
|
||||
HookSource::SessionFlags => "session_flags",
|
||||
HookSource::Plugin => "plugin",
|
||||
HookSource::Skill => "skill",
|
||||
HookSource::CloudRequirements => "cloud_requirements",
|
||||
HookSource::LegacyManagedConfigFile => "legacy_managed_config_file",
|
||||
HookSource::LegacyManagedConfigMdm => "legacy_managed_config_mdm",
|
||||
|
||||
@@ -1902,6 +1902,7 @@
|
||||
"mdm",
|
||||
"sessionFlags",
|
||||
"plugin",
|
||||
"skill",
|
||||
"cloudRequirements",
|
||||
"legacyManagedConfigFile",
|
||||
"legacyManagedConfigMdm",
|
||||
|
||||
@@ -9986,6 +9986,7 @@
|
||||
"mdm",
|
||||
"sessionFlags",
|
||||
"plugin",
|
||||
"skill",
|
||||
"cloudRequirements",
|
||||
"legacyManagedConfigFile",
|
||||
"legacyManagedConfigMdm",
|
||||
|
||||
@@ -6595,6 +6595,7 @@
|
||||
"mdm",
|
||||
"sessionFlags",
|
||||
"plugin",
|
||||
"skill",
|
||||
"cloudRequirements",
|
||||
"legacyManagedConfigFile",
|
||||
"legacyManagedConfigMdm",
|
||||
|
||||
@@ -161,6 +161,7 @@
|
||||
"mdm",
|
||||
"sessionFlags",
|
||||
"plugin",
|
||||
"skill",
|
||||
"cloudRequirements",
|
||||
"legacyManagedConfigFile",
|
||||
"legacyManagedConfigMdm",
|
||||
|
||||
@@ -161,6 +161,7 @@
|
||||
"mdm",
|
||||
"sessionFlags",
|
||||
"plugin",
|
||||
"skill",
|
||||
"cloudRequirements",
|
||||
"legacyManagedConfigFile",
|
||||
"legacyManagedConfigMdm",
|
||||
|
||||
@@ -117,6 +117,7 @@
|
||||
"mdm",
|
||||
"sessionFlags",
|
||||
"plugin",
|
||||
"skill",
|
||||
"cloudRequirements",
|
||||
"legacyManagedConfigFile",
|
||||
"legacyManagedConfigMdm",
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type HookSource = "system" | "user" | "project" | "mdm" | "sessionFlags" | "plugin" | "cloudRequirements" | "legacyManagedConfigFile" | "legacyManagedConfigMdm" | "unknown";
|
||||
export type HookSource = "system" | "user" | "project" | "mdm" | "sessionFlags" | "plugin" | "skill" | "cloudRequirements" | "legacyManagedConfigFile" | "legacyManagedConfigMdm" | "unknown";
|
||||
|
||||
@@ -472,6 +472,7 @@ v2_enum_from_core!(
|
||||
Mdm,
|
||||
SessionFlags,
|
||||
Plugin,
|
||||
Skill,
|
||||
CloudRequirements,
|
||||
LegacyManagedConfigFile,
|
||||
LegacyManagedConfigMdm,
|
||||
|
||||
@@ -348,6 +348,7 @@ fn map_hook_handler_to_api(handler: CoreHookHandlerConfig) -> ConfiguredHookHand
|
||||
command,
|
||||
timeout_sec,
|
||||
r#async,
|
||||
once: _,
|
||||
status_message,
|
||||
} => ConfiguredHookHandler::Command {
|
||||
command,
|
||||
@@ -535,6 +536,7 @@ mod tests {
|
||||
command: "python3 /enterprise/hooks/pre.py".to_string(),
|
||||
timeout_sec: Some(10),
|
||||
r#async: false,
|
||||
once: false,
|
||||
status_message: Some("checking".to_string()),
|
||||
}],
|
||||
}],
|
||||
|
||||
@@ -114,6 +114,8 @@ pub enum HookHandlerConfig {
|
||||
timeout_sec: Option<u64>,
|
||||
#[serde(default)]
|
||||
r#async: bool,
|
||||
#[serde(default)]
|
||||
once: bool,
|
||||
#[serde(default, rename = "statusMessage")]
|
||||
status_message: Option<String>,
|
||||
},
|
||||
|
||||
@@ -42,6 +42,7 @@ fn hooks_file_deserializes_existing_json_shape() {
|
||||
command: "python3 /tmp/pre.py".to_string(),
|
||||
timeout_sec: Some(10),
|
||||
r#async: false,
|
||||
once: false,
|
||||
status_message: Some("checking".to_string()),
|
||||
}],
|
||||
}],
|
||||
@@ -76,6 +77,7 @@ statusMessage = "checking"
|
||||
command: "python3 /tmp/pre.py".to_string(),
|
||||
timeout_sec: Some(10),
|
||||
r#async: false,
|
||||
once: false,
|
||||
status_message: Some("checking".to_string()),
|
||||
}],
|
||||
}],
|
||||
@@ -111,6 +113,7 @@ command = "python3 /tmp/pre.py"
|
||||
command: "python3 /tmp/pre.py".to_string(),
|
||||
timeout_sec: None,
|
||||
r#async: false,
|
||||
once: false,
|
||||
status_message: None,
|
||||
}],
|
||||
}],
|
||||
@@ -154,6 +157,7 @@ command = "python3 /enterprise/place/pre.py"
|
||||
command: "python3 /enterprise/place/pre.py".to_string(),
|
||||
timeout_sec: None,
|
||||
r#async: false,
|
||||
once: false,
|
||||
status_message: None,
|
||||
}],
|
||||
}],
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::system::system_cache_root_dir;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::ConfigLayerStackOrdering;
|
||||
use codex_config::HookEventsToml;
|
||||
use codex_config::default_project_root_markers;
|
||||
use codex_config::merge_toml_values;
|
||||
use codex_config::project_root_markers_from_config;
|
||||
@@ -42,6 +43,8 @@ struct SkillFrontmatter {
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
metadata: SkillFrontmatterMetadata,
|
||||
#[serde(default)]
|
||||
hooks: HookEventsToml,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
@@ -554,8 +557,11 @@ async fn discover_skills_under_root(
|
||||
|
||||
if metadata.is_file && file_name == SKILLS_FILENAME {
|
||||
match parse_skill_file(fs, &path, scope).await {
|
||||
Ok(skill) => {
|
||||
outcome.skills.push(skill);
|
||||
Ok(parsed) => {
|
||||
outcome
|
||||
.hooks_by_skill_path
|
||||
.insert(parsed.metadata.path_to_skills_md.clone(), parsed.hooks);
|
||||
outcome.skills.push(parsed.metadata);
|
||||
}
|
||||
Err(err) => {
|
||||
if scope != SkillScope::System {
|
||||
@@ -583,7 +589,7 @@ async fn parse_skill_file(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
path: &AbsolutePathBuf,
|
||||
scope: SkillScope,
|
||||
) -> Result<SkillMetadata, SkillParseError> {
|
||||
) -> Result<ParsedSkill, SkillParseError> {
|
||||
let contents = fs
|
||||
.read_file_text(path, /*sandbox*/ None)
|
||||
.await
|
||||
@@ -630,18 +636,26 @@ async fn parse_skill_file(
|
||||
|
||||
let resolved_path = canonicalize_for_skill_identity(path);
|
||||
|
||||
Ok(SkillMetadata {
|
||||
name,
|
||||
description,
|
||||
short_description,
|
||||
interface,
|
||||
dependencies,
|
||||
policy,
|
||||
path_to_skills_md: resolved_path,
|
||||
scope,
|
||||
Ok(ParsedSkill {
|
||||
metadata: SkillMetadata {
|
||||
name,
|
||||
description,
|
||||
short_description,
|
||||
interface,
|
||||
dependencies,
|
||||
policy,
|
||||
path_to_skills_md: resolved_path,
|
||||
scope,
|
||||
},
|
||||
hooks: parsed.hooks,
|
||||
})
|
||||
}
|
||||
|
||||
struct ParsedSkill {
|
||||
metadata: SkillMetadata,
|
||||
hooks: HookEventsToml,
|
||||
}
|
||||
|
||||
fn default_skill_name(path: &AbsolutePathBuf) -> String {
|
||||
path.parent()
|
||||
.and_then(|parent| {
|
||||
|
||||
@@ -328,6 +328,57 @@ async fn loads_skills_from_home_agents_dir_for_user_scope() -> anyhow::Result<()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parses_hooks_from_skill_frontmatter() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let skill_dir = codex_home.path().join("skills").join("secure-operations");
|
||||
fs::create_dir_all(&skill_dir)?;
|
||||
fs::write(
|
||||
skill_dir.join(SKILLS_FILENAME),
|
||||
r#"---
|
||||
name: secure-operations
|
||||
description: security checks
|
||||
hooks:
|
||||
PreToolUse:
|
||||
- matcher: "Bash"
|
||||
hooks:
|
||||
- type: command
|
||||
command: "./scripts/security-check.sh"
|
||||
once: true
|
||||
---
|
||||
|
||||
# Body
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let outcome = load_skills_from_roots(vec![SkillRoot {
|
||||
path: codex_home.path().join("skills").abs(),
|
||||
scope: SkillScope::User,
|
||||
file_system: Arc::clone(&LOCAL_FS),
|
||||
}])
|
||||
.await;
|
||||
|
||||
let skill = outcome.skills.first().expect("skill should load");
|
||||
let hooks = outcome
|
||||
.hooks_for_skill(skill)
|
||||
.expect("skill hooks should be retained");
|
||||
assert_eq!(hooks.pre_tool_use.len(), 1);
|
||||
assert_eq!(hooks.pre_tool_use[0].matcher.as_deref(), Some("Bash"));
|
||||
assert_eq!(
|
||||
hooks.pre_tool_use[0].hooks,
|
||||
vec![codex_config::HookHandlerConfig::Command {
|
||||
command: "./scripts/security-check.sh".to_string(),
|
||||
timeout_sec: None,
|
||||
r#async: false,
|
||||
once: true,
|
||||
status_message: None,
|
||||
}]
|
||||
);
|
||||
assert_eq!(hooks.handler_count(), 1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_skill(codex_home: &TempDir, dir: &str, name: &str, description: &str) -> PathBuf {
|
||||
write_skill_at(&codex_home.path().join("skills"), dir, name, description)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::collections::HashSet;
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_config::HookEventsToml;
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
@@ -92,6 +93,7 @@ pub struct SkillLoadOutcome {
|
||||
pub(crate) skill_roots: Vec<AbsolutePathBuf>,
|
||||
pub(crate) skill_root_by_path: Arc<HashMap<AbsolutePathBuf, AbsolutePathBuf>>,
|
||||
pub(crate) file_systems_by_skill_path: SkillFileSystemsByPath,
|
||||
pub(crate) hooks_by_skill_path: HashMap<AbsolutePathBuf, HookEventsToml>,
|
||||
pub(crate) implicit_skills_by_scripts_dir: Arc<HashMap<AbsolutePathBuf, SkillMetadata>>,
|
||||
pub(crate) implicit_skills_by_doc_path: Arc<HashMap<AbsolutePathBuf, SkillMetadata>>,
|
||||
}
|
||||
@@ -126,6 +128,10 @@ impl SkillLoadOutcome {
|
||||
self.file_systems_by_skill_path
|
||||
.get(&skill.path_to_skills_md)
|
||||
}
|
||||
|
||||
pub fn hooks_for_skill(&self, skill: &SkillMetadata) -> Option<&HookEventsToml> {
|
||||
self.hooks_by_skill_path.get(&skill.path_to_skills_md)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
@@ -178,6 +184,9 @@ pub fn filter_skill_load_outcome_for_product(
|
||||
outcome
|
||||
.file_systems_by_skill_path
|
||||
.retain_paths(&retained_paths);
|
||||
outcome
|
||||
.hooks_by_skill_path
|
||||
.retain(|path, _| retained_paths.contains(path));
|
||||
outcome.skill_root_by_path = Arc::new(
|
||||
outcome
|
||||
.skill_root_by_path
|
||||
|
||||
@@ -898,6 +898,10 @@
|
||||
"command": {
|
||||
"type": "string"
|
||||
},
|
||||
"once": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"statusMessage": {
|
||||
"default": null,
|
||||
"type": "string"
|
||||
|
||||
@@ -1159,6 +1159,7 @@ async fn load_config_layers_includes_cloud_hook_requirements() -> anyhow::Result
|
||||
command: format!("python3 {}/pre.py", managed_dir.display()),
|
||||
timeout_sec: Some(10),
|
||||
r#async: false,
|
||||
once: false,
|
||||
status_message: Some("checking".to_string()),
|
||||
}],
|
||||
}],
|
||||
|
||||
@@ -116,7 +116,7 @@ pub(crate) async fn run_pending_session_start_hooks(
|
||||
permission_mode: hook_permission_mode(turn_context),
|
||||
source: session_start_source,
|
||||
};
|
||||
let hooks = sess.hooks();
|
||||
let hooks = turn_context.turn_skills.hooks_for_turn(sess.hooks());
|
||||
let preview_runs = hooks.preview_session_start(&request);
|
||||
run_context_injecting_hook(
|
||||
sess,
|
||||
@@ -153,7 +153,7 @@ pub(crate) async fn run_pre_tool_use_hooks(
|
||||
tool_use_id,
|
||||
tool_input: tool_input.clone(),
|
||||
};
|
||||
let hooks = sess.hooks();
|
||||
let hooks = turn_context.turn_skills.hooks_for_turn(sess.hooks());
|
||||
let preview_runs = hooks.preview_pre_tool_use(&request);
|
||||
emit_hook_started_events(sess, turn_context, preview_runs).await;
|
||||
|
||||
@@ -203,7 +203,7 @@ pub(crate) async fn run_permission_request_hooks(
|
||||
run_id_suffix: run_id_suffix.to_string(),
|
||||
tool_input: payload.tool_input,
|
||||
};
|
||||
let hooks = sess.hooks();
|
||||
let hooks = turn_context.turn_skills.hooks_for_turn(sess.hooks());
|
||||
let preview_runs = hooks.preview_permission_request(&request);
|
||||
emit_hook_started_events(sess, turn_context, preview_runs).await;
|
||||
|
||||
@@ -244,7 +244,7 @@ pub(crate) async fn run_post_tool_use_hooks(
|
||||
tool_input,
|
||||
tool_response,
|
||||
};
|
||||
let hooks = sess.hooks();
|
||||
let hooks = turn_context.turn_skills.hooks_for_turn(sess.hooks());
|
||||
let preview_runs = hooks.preview_post_tool_use(&request);
|
||||
emit_hook_started_events(sess, turn_context, preview_runs).await;
|
||||
|
||||
@@ -478,6 +478,7 @@ fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str);
|
||||
HookSource::Mdm => "mdm",
|
||||
HookSource::SessionFlags => "session_flags",
|
||||
HookSource::Plugin => "plugin",
|
||||
HookSource::Skill => "skill",
|
||||
HookSource::CloudRequirements => "cloud_requirements",
|
||||
HookSource::LegacyManagedConfigFile => "legacy_managed_config_file",
|
||||
HookSource::LegacyManagedConfigMdm => "legacy_managed_config_mdm",
|
||||
|
||||
@@ -91,6 +91,7 @@ pub(crate) use skills::SkillLoadOutcome;
|
||||
pub(crate) use skills::SkillMetadata;
|
||||
pub(crate) use skills::SkillsLoadInput;
|
||||
pub(crate) use skills::SkillsManager;
|
||||
pub(crate) use skills::active_skill_hook_sources;
|
||||
pub(crate) use skills::build_available_skills;
|
||||
pub(crate) use skills::build_skill_injections;
|
||||
pub(crate) use skills::build_skill_name_counts;
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::sync::atomic::Ordering;
|
||||
|
||||
use crate::SkillInjections;
|
||||
use crate::SkillLoadOutcome;
|
||||
use crate::active_skill_hook_sources;
|
||||
use crate::build_skill_injections;
|
||||
use crate::client::ModelClientSession;
|
||||
use crate::client_common::Prompt;
|
||||
@@ -222,6 +223,19 @@ pub(crate) async fn run_turn(
|
||||
&connector_slug_counts,
|
||||
)
|
||||
});
|
||||
if turn_context.features.enabled(Feature::CodexHooks)
|
||||
&& let Some(outcome) = skills_outcome
|
||||
{
|
||||
let (skill_hook_sources, skill_hook_warnings) =
|
||||
active_skill_hook_sources(outcome, &mentioned_skills);
|
||||
turn_context
|
||||
.turn_skills
|
||||
.set_active_hook_sources(skill_hook_sources);
|
||||
for message in skill_hook_warnings {
|
||||
sess.send_event(&turn_context, EventMsg::Warning(WarningEvent { message }))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
let config = turn_context.config.clone();
|
||||
if config
|
||||
.features
|
||||
@@ -520,7 +534,7 @@ pub(crate) async fn run_turn(
|
||||
stop_hook_active,
|
||||
last_assistant_message: last_agent_message.clone(),
|
||||
};
|
||||
let hooks = sess.hooks();
|
||||
let hooks = turn_context.turn_skills.hooks_for_turn(sess.hooks());
|
||||
for run in hooks.preview_stop(&stop_request) {
|
||||
sess.send_event(
|
||||
&turn_context,
|
||||
|
||||
@@ -18,6 +18,7 @@ pub(super) fn image_generation_tool_auth_allowed(auth_manager: Option<&AuthManag
|
||||
pub(crate) struct TurnSkillsContext {
|
||||
pub(crate) outcome: Arc<SkillLoadOutcome>,
|
||||
pub(crate) implicit_invocation_seen_skills: Arc<Mutex<HashSet<String>>>,
|
||||
active_hook_sources: Arc<std::sync::RwLock<Vec<codex_hooks::SkillHookSource>>>,
|
||||
}
|
||||
|
||||
impl TurnSkillsContext {
|
||||
@@ -25,6 +26,28 @@ impl TurnSkillsContext {
|
||||
Self {
|
||||
outcome,
|
||||
implicit_invocation_seen_skills: Arc::new(Mutex::new(HashSet::new())),
|
||||
active_hook_sources: Arc::new(std::sync::RwLock::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_active_hook_sources(&self, sources: Vec<codex_hooks::SkillHookSource>) {
|
||||
let mut active_hook_sources = self
|
||||
.active_hook_sources
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*active_hook_sources = sources;
|
||||
}
|
||||
|
||||
pub(crate) fn hooks_for_turn(&self, base_hooks: Arc<Hooks>) -> Arc<Hooks> {
|
||||
let active_hook_sources = self
|
||||
.active_hook_sources
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone();
|
||||
if active_hook_sources.is_empty() {
|
||||
base_hooks
|
||||
} else {
|
||||
Arc::new(base_hooks.with_skill_hook_sources(active_hook_sources))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::session::turn_context::TurnContext;
|
||||
use codex_analytics::InvocationType;
|
||||
use codex_analytics::SkillInvocation;
|
||||
use codex_analytics::build_track_events_context;
|
||||
use codex_hooks::SkillHookSource;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_protocol::request_user_input::RequestUserInputArgs;
|
||||
use codex_protocol::request_user_input::RequestUserInputQuestion;
|
||||
@@ -230,3 +231,40 @@ pub(crate) async fn maybe_emit_implicit_skill_invocation(
|
||||
vec![invocation],
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn active_skill_hook_sources(
|
||||
outcome: &SkillLoadOutcome,
|
||||
skills: &[SkillMetadata],
|
||||
) -> (Vec<SkillHookSource>, Vec<String>) {
|
||||
let mut sources = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
for skill in skills {
|
||||
let Some(hooks) = outcome.hooks_for_skill(skill) else {
|
||||
continue;
|
||||
};
|
||||
if hooks.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut hooks = hooks.clone();
|
||||
if !hooks.session_start.is_empty() {
|
||||
warnings.push(format!(
|
||||
"Skipping SessionStart hooks from skill {} because skills activate after thread start.",
|
||||
skill.name
|
||||
));
|
||||
hooks.session_start.clear();
|
||||
}
|
||||
if hooks.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
sources.push(SkillHookSource {
|
||||
skill_name: skill.name.clone(),
|
||||
source_path: skill.path_to_skills_md.clone(),
|
||||
hooks,
|
||||
});
|
||||
}
|
||||
|
||||
(sources, warnings)
|
||||
}
|
||||
|
||||
@@ -1975,6 +1975,325 @@ print(json.dumps({{
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_pre_tool_use_blocks_only_while_skill_is_active() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let first_call_id = "skill-pretooluse-first-shell-command";
|
||||
let second_call_id = "skill-pretooluse-second-shell-command";
|
||||
let first_marker = std::env::temp_dir().join("skill-pretooluse-first-shell-command-marker");
|
||||
let second_marker = std::env::temp_dir().join("skill-pretooluse-second-shell-command-marker");
|
||||
let first_command = format!("printf blocked > {}", first_marker.display());
|
||||
let second_command = format!("printf allowed > {}", second_marker.display());
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
core_test_support::responses::ev_function_call(
|
||||
first_call_id,
|
||||
"shell_command",
|
||||
&serde_json::to_string(&serde_json::json!({ "command": first_command }))?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "skill hook blocked it"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-3"),
|
||||
core_test_support::responses::ev_function_call(
|
||||
second_call_id,
|
||||
"shell_command",
|
||||
&serde_json::to_string(&serde_json::json!({ "command": second_command }))?,
|
||||
),
|
||||
ev_completed("resp-3"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-4"),
|
||||
ev_assistant_message("msg-2", "skill hook is inactive now"),
|
||||
ev_completed("resp-4"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let home = Arc::new(TempDir::new()?);
|
||||
let skill_dir = home.path().join("skills/secure-operations");
|
||||
fs::create_dir_all(&skill_dir).context("create skill dir")?;
|
||||
let script_path = skill_dir.join("pre_tool_use_hook.py");
|
||||
let log_path = skill_dir.join("pre_tool_use_hook_log.jsonl");
|
||||
fs::write(
|
||||
&script_path,
|
||||
format!(
|
||||
r#"import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
payload = json.load(sys.stdin)
|
||||
with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(payload) + "\n")
|
||||
|
||||
print(json.dumps({{
|
||||
"hookSpecificOutput": {{
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": "blocked by skill hook"
|
||||
}}
|
||||
}}))
|
||||
"#,
|
||||
log_path = log_path.display(),
|
||||
),
|
||||
)
|
||||
.context("write skill pre tool use hook script")?;
|
||||
fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
r#"---
|
||||
name: secure-operations
|
||||
description: Perform operations with security checks
|
||||
hooks:
|
||||
PreToolUse:
|
||||
- matcher: "^Bash$"
|
||||
hooks:
|
||||
- type: command
|
||||
command: "python3 ./pre_tool_use_hook.py"
|
||||
---
|
||||
|
||||
# Body
|
||||
"#,
|
||||
)
|
||||
.context("write skill")?;
|
||||
|
||||
let mut builder = test_codex()
|
||||
.with_home(Arc::clone(&home))
|
||||
.with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CodexHooks)
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
for marker in [&first_marker, &second_marker] {
|
||||
if marker.exists() {
|
||||
fs::remove_file(marker).context("remove leftover skill pre tool use marker")?;
|
||||
}
|
||||
}
|
||||
|
||||
test.submit_turn_with_policy(
|
||||
"use $secure-operations and run the shell command",
|
||||
codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
|
||||
)
|
||||
.await?;
|
||||
test.submit_turn_with_policy(
|
||||
"run the shell command without the skill",
|
||||
codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let requests = responses.requests();
|
||||
assert_eq!(requests.len(), 4);
|
||||
let first_output_item = requests[1].function_call_output(first_call_id);
|
||||
let first_output = first_output_item
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.expect("first shell command output string");
|
||||
assert!(
|
||||
first_output.contains("Command blocked by PreToolUse hook: blocked by skill hook"),
|
||||
"active skill hook should block the shell command",
|
||||
);
|
||||
let second_output_item = requests[3].function_call_output(second_call_id);
|
||||
let second_output = second_output_item
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.expect("second shell command output string");
|
||||
assert!(
|
||||
!second_output.contains("Command blocked by PreToolUse hook"),
|
||||
"inactive skill hook should not block later turns",
|
||||
);
|
||||
assert!(
|
||||
!first_marker.exists(),
|
||||
"skill hook should block the first command"
|
||||
);
|
||||
assert!(
|
||||
second_marker.exists(),
|
||||
"skill hook should be cleaned up before the next turn",
|
||||
);
|
||||
|
||||
let hook_inputs = read_hook_inputs_from_log(&log_path)?;
|
||||
assert_eq!(hook_inputs.len(), 1);
|
||||
assert_eq!(hook_inputs[0]["hook_event_name"], "PreToolUse");
|
||||
assert_eq!(hook_inputs[0]["tool_name"], "Bash");
|
||||
assert_eq!(hook_inputs[0]["tool_use_id"], first_call_id);
|
||||
assert_eq!(hook_inputs[0]["tool_input"]["command"], first_command);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_pre_tool_use_once_runs_only_once_across_turns() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let first_call_id = "skill-pretooluse-once-first-shell-command";
|
||||
let second_call_id = "skill-pretooluse-once-second-shell-command";
|
||||
let first_marker =
|
||||
std::env::temp_dir().join("skill-pretooluse-once-first-shell-command-marker");
|
||||
let second_marker =
|
||||
std::env::temp_dir().join("skill-pretooluse-once-second-shell-command-marker");
|
||||
let first_command = format!("printf blocked > {}", first_marker.display());
|
||||
let second_command = format!("printf allowed > {}", second_marker.display());
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
core_test_support::responses::ev_function_call(
|
||||
first_call_id,
|
||||
"shell_command",
|
||||
&serde_json::to_string(&serde_json::json!({ "command": first_command }))?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "skill hook blocked it"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-3"),
|
||||
core_test_support::responses::ev_function_call(
|
||||
second_call_id,
|
||||
"shell_command",
|
||||
&serde_json::to_string(&serde_json::json!({ "command": second_command }))?,
|
||||
),
|
||||
ev_completed("resp-3"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-4"),
|
||||
ev_assistant_message("msg-2", "once hook already fired"),
|
||||
ev_completed("resp-4"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let home = Arc::new(TempDir::new()?);
|
||||
let skill_dir = home.path().join("skills/secure-operations");
|
||||
fs::create_dir_all(&skill_dir).context("create skill dir")?;
|
||||
let script_path = skill_dir.join("pre_tool_use_hook.py");
|
||||
let log_path = skill_dir.join("pre_tool_use_hook_log.jsonl");
|
||||
fs::write(
|
||||
&script_path,
|
||||
format!(
|
||||
r#"import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
payload = json.load(sys.stdin)
|
||||
with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(payload) + "\n")
|
||||
|
||||
print(json.dumps({{
|
||||
"hookSpecificOutput": {{
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": "blocked by skill hook"
|
||||
}}
|
||||
}}))
|
||||
"#,
|
||||
log_path = log_path.display(),
|
||||
),
|
||||
)
|
||||
.context("write skill pre tool use hook script")?;
|
||||
fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
r#"---
|
||||
name: secure-operations
|
||||
description: Perform operations with security checks
|
||||
hooks:
|
||||
PreToolUse:
|
||||
- matcher: "^Bash$"
|
||||
hooks:
|
||||
- type: command
|
||||
command: "python3 ./pre_tool_use_hook.py"
|
||||
once: true
|
||||
---
|
||||
|
||||
# Body
|
||||
"#,
|
||||
)
|
||||
.context("write skill")?;
|
||||
|
||||
let mut builder = test_codex()
|
||||
.with_home(Arc::clone(&home))
|
||||
.with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CodexHooks)
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
for marker in [&first_marker, &second_marker] {
|
||||
if marker.exists() {
|
||||
fs::remove_file(marker).context("remove leftover once skill pre tool use marker")?;
|
||||
}
|
||||
}
|
||||
|
||||
test.submit_turn_with_policy(
|
||||
"use $secure-operations and run the shell command",
|
||||
codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
|
||||
)
|
||||
.await?;
|
||||
test.submit_turn_with_policy(
|
||||
"use $secure-operations and run the shell command again",
|
||||
codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let requests = responses.requests();
|
||||
assert_eq!(requests.len(), 4);
|
||||
let first_output_item = requests[1].function_call_output(first_call_id);
|
||||
let first_output = first_output_item
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.expect("first shell command output string");
|
||||
assert!(
|
||||
first_output.contains("Command blocked by PreToolUse hook: blocked by skill hook"),
|
||||
"once skill hook should block the first shell command",
|
||||
);
|
||||
let second_output_item = requests[3].function_call_output(second_call_id);
|
||||
let second_output = second_output_item
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.expect("second shell command output string");
|
||||
assert!(
|
||||
!second_output.contains("Command blocked by PreToolUse hook"),
|
||||
"once skill hook should not block later turns",
|
||||
);
|
||||
assert!(
|
||||
!first_marker.exists(),
|
||||
"once skill hook should block the first command"
|
||||
);
|
||||
assert!(
|
||||
second_marker.exists(),
|
||||
"once skill hook should allow the second command",
|
||||
);
|
||||
|
||||
let hook_inputs = read_hook_inputs_from_log(&log_path)?;
|
||||
assert_eq!(hook_inputs.len(), 1);
|
||||
assert_eq!(hook_inputs[0]["hook_event_name"], "PreToolUse");
|
||||
assert_eq!(hook_inputs[0]["tool_name"], "Bash");
|
||||
assert_eq!(hook_inputs[0]["tool_use_id"], first_call_id);
|
||||
assert_eq!(hook_inputs[0]["tool_input"]["command"], first_command);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_tool_use_blocks_shell_when_defined_in_config_toml() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -29,10 +29,11 @@ pub(crate) async fn run_command(
|
||||
) -> CommandRunResult {
|
||||
let started_at = chrono::Utc::now().timestamp();
|
||||
let started = Instant::now();
|
||||
let execution_cwd = handler.execution_cwd.as_deref().unwrap_or(cwd);
|
||||
|
||||
let mut command = build_command(shell, handler);
|
||||
command
|
||||
.current_dir(cwd)
|
||||
.current_dir(execution_cwd)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
|
||||
@@ -23,6 +23,7 @@ use super::HookListEntry;
|
||||
use crate::config_rules::disabled_hook_keys_from_stack;
|
||||
use crate::events::common::matcher_pattern_for_event;
|
||||
use crate::events::common::validate_matcher_pattern;
|
||||
use crate::registry::SkillHookSource;
|
||||
use codex_protocol::protocol::HookHandlerType;
|
||||
use codex_protocol::protocol::HookSource;
|
||||
|
||||
@@ -38,6 +39,7 @@ struct HookHandlerSource<'a> {
|
||||
source: HookSource,
|
||||
disabled_hook_keys: &'a HashSet<String>,
|
||||
env: HashMap<String, String>,
|
||||
execution_cwd: Option<AbsolutePathBuf>,
|
||||
plugin_id: Option<String>,
|
||||
}
|
||||
|
||||
@@ -94,6 +96,7 @@ pub(crate) fn discover_handlers(
|
||||
source: hook_source,
|
||||
disabled_hook_keys: &disabled_hook_keys,
|
||||
env: HashMap::new(),
|
||||
execution_cwd: None,
|
||||
plugin_id: None,
|
||||
},
|
||||
hook_events,
|
||||
@@ -145,6 +148,7 @@ fn append_managed_requirement_handlers(
|
||||
source: hook_source_for_requirement_source(managed_hooks.source.as_ref()),
|
||||
disabled_hook_keys,
|
||||
env: HashMap::new(),
|
||||
execution_cwd: None,
|
||||
plugin_id: None,
|
||||
},
|
||||
managed_hooks.get().hooks.clone(),
|
||||
@@ -190,6 +194,7 @@ fn append_plugin_hook_sources(
|
||||
source: HookSource::Plugin,
|
||||
disabled_hook_keys,
|
||||
env,
|
||||
execution_cwd: None,
|
||||
plugin_id: Some(plugin_id),
|
||||
},
|
||||
hooks,
|
||||
@@ -197,6 +202,45 @@ fn append_plugin_hook_sources(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn append_skill_hook_sources(
|
||||
handlers: &mut Vec<ConfiguredHandler>,
|
||||
warnings: &mut Vec<String>,
|
||||
mut display_order: i64,
|
||||
skill_hook_sources: Vec<SkillHookSource>,
|
||||
) {
|
||||
let disabled_hook_keys = HashSet::new();
|
||||
let mut hook_entries = Vec::new();
|
||||
for source in skill_hook_sources {
|
||||
let SkillHookSource {
|
||||
skill_name,
|
||||
source_path,
|
||||
mut hooks,
|
||||
} = source;
|
||||
if !hooks.session_start.is_empty() {
|
||||
warnings.push(format!(
|
||||
"skipping SessionStart hooks from skill {skill_name}: skills activate after thread start"
|
||||
));
|
||||
hooks.session_start.clear();
|
||||
}
|
||||
append_hook_events(
|
||||
handlers,
|
||||
&mut hook_entries,
|
||||
warnings,
|
||||
&mut display_order,
|
||||
HookHandlerSource {
|
||||
path: &source_path,
|
||||
key_source: format!("skill:{}", source_path.display()),
|
||||
source: HookSource::Skill,
|
||||
disabled_hook_keys: &disabled_hook_keys,
|
||||
env: HashMap::new(),
|
||||
execution_cwd: source_path.parent(),
|
||||
plugin_id: None,
|
||||
},
|
||||
hooks,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn managed_hooks_source_path(
|
||||
managed_hooks: &ManagedHooksRequirementsToml,
|
||||
requirement_source: Option<&RequirementSource>,
|
||||
@@ -380,6 +424,7 @@ fn append_matcher_groups(
|
||||
command,
|
||||
timeout_sec,
|
||||
r#async,
|
||||
once,
|
||||
status_message,
|
||||
} => {
|
||||
if r#async {
|
||||
@@ -410,8 +455,15 @@ fn append_matcher_groups(
|
||||
);
|
||||
let enabled =
|
||||
source.source.is_managed() || !source.disabled_hook_keys.contains(&key);
|
||||
if once && source.source != HookSource::Skill {
|
||||
warnings.push(format!(
|
||||
"ignoring once hook option in {}: once is only supported for skill hooks",
|
||||
source.path.display()
|
||||
));
|
||||
}
|
||||
let once = once && source.source == HookSource::Skill;
|
||||
hook_entries.push(HookListEntry {
|
||||
key,
|
||||
key: key.clone(),
|
||||
event_name,
|
||||
handler_type: HookHandlerType::Command,
|
||||
matcher: matcher.map(ToOwned::to_owned),
|
||||
@@ -427,6 +479,7 @@ fn append_matcher_groups(
|
||||
});
|
||||
if enabled {
|
||||
handlers.push(ConfiguredHandler {
|
||||
key,
|
||||
event_name,
|
||||
matcher: matcher.map(ToOwned::to_owned),
|
||||
command,
|
||||
@@ -436,6 +489,8 @@ fn append_matcher_groups(
|
||||
source: source.source,
|
||||
display_order: *display_order,
|
||||
env: source.env.clone(),
|
||||
execution_cwd: source.execution_cwd.clone(),
|
||||
once,
|
||||
});
|
||||
}
|
||||
*display_order += 1;
|
||||
@@ -529,6 +584,7 @@ mod tests {
|
||||
source: hook_source(),
|
||||
disabled_hook_keys,
|
||||
env: std::collections::HashMap::new(),
|
||||
execution_cwd: None,
|
||||
plugin_id: None,
|
||||
}
|
||||
}
|
||||
@@ -540,6 +596,7 @@ mod tests {
|
||||
command: "echo hello".to_string(),
|
||||
timeout_sec: None,
|
||||
r#async: false,
|
||||
once: false,
|
||||
status_message: None,
|
||||
}],
|
||||
}
|
||||
@@ -567,6 +624,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
handlers,
|
||||
vec![ConfiguredHandler {
|
||||
key: format!("{}:user_prompt_submit:0:0", source_path.display()),
|
||||
event_name: HookEventName::UserPromptSubmit,
|
||||
matcher: None,
|
||||
command: "echo hello".to_string(),
|
||||
@@ -576,6 +634,8 @@ mod tests {
|
||||
source: hook_source(),
|
||||
display_order: 0,
|
||||
env: std::collections::HashMap::new(),
|
||||
execution_cwd: None,
|
||||
once: false,
|
||||
}]
|
||||
);
|
||||
}
|
||||
@@ -602,6 +662,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
handlers,
|
||||
vec![ConfiguredHandler {
|
||||
key: format!("{}:pre_tool_use:0:0", source_path.display()),
|
||||
event_name: HookEventName::PreToolUse,
|
||||
matcher: Some("^Bash$".to_string()),
|
||||
command: "echo hello".to_string(),
|
||||
@@ -611,6 +672,8 @@ mod tests {
|
||||
source: hook_source(),
|
||||
display_order: 0,
|
||||
env: std::collections::HashMap::new(),
|
||||
execution_cwd: None,
|
||||
once: false,
|
||||
}]
|
||||
);
|
||||
}
|
||||
@@ -685,6 +748,7 @@ mod tests {
|
||||
command: "echo hello".to_string(),
|
||||
timeout_sec: None,
|
||||
r#async: false,
|
||||
once: false,
|
||||
status_message: None,
|
||||
}],
|
||||
}],
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use futures::future::join_all;
|
||||
|
||||
@@ -82,12 +84,22 @@ pub(crate) fn running_summary(handler: &ConfiguredHandler) -> HookRunSummary {
|
||||
|
||||
pub(crate) async fn execute_handlers<T>(
|
||||
shell: &CommandShell,
|
||||
fired_once_hook_keys: &Mutex<HashSet<String>>,
|
||||
handlers: Vec<ConfiguredHandler>,
|
||||
input_json: String,
|
||||
cwd: &Path,
|
||||
turn_id: Option<String>,
|
||||
parse: fn(&ConfiguredHandler, CommandRunResult, Option<String>) -> ParsedHandler<T>,
|
||||
) -> Vec<ParsedHandler<T>> {
|
||||
let handlers = {
|
||||
let mut fired_once_hook_keys = fired_once_hook_keys
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
handlers
|
||||
.into_iter()
|
||||
.filter(|handler| !handler.once || fired_once_hook_keys.insert(handler.key.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let results = join_all(
|
||||
handlers
|
||||
.iter()
|
||||
@@ -155,6 +167,7 @@ mod tests {
|
||||
display_order: i64,
|
||||
) -> ConfiguredHandler {
|
||||
ConfiguredHandler {
|
||||
key: format!("test:{display_order}"),
|
||||
event_name,
|
||||
matcher: matcher.map(str::to_owned),
|
||||
command: command.to_string(),
|
||||
@@ -164,6 +177,8 @@ mod tests {
|
||||
source: HookSource::User,
|
||||
display_order,
|
||||
env: std::collections::HashMap::new(),
|
||||
execution_cwd: None,
|
||||
once: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,11 @@ pub(crate) mod output_parser;
|
||||
pub(crate) mod schema_loader;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::registry::SkillHookSource;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_plugin::PluginHookSource;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
@@ -35,6 +39,7 @@ pub(crate) struct CommandShell {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct ConfiguredHandler {
|
||||
pub key: String,
|
||||
pub event_name: codex_protocol::protocol::HookEventName,
|
||||
pub matcher: Option<String>,
|
||||
pub command: String,
|
||||
@@ -44,6 +49,8 @@ pub(crate) struct ConfiguredHandler {
|
||||
pub source: HookSource,
|
||||
pub display_order: i64,
|
||||
pub env: HashMap<String, String>,
|
||||
pub execution_cwd: Option<AbsolutePathBuf>,
|
||||
pub once: bool,
|
||||
}
|
||||
|
||||
impl ConfiguredHandler {
|
||||
@@ -87,9 +94,11 @@ pub struct HookListEntry {
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ClaudeHooksEngine {
|
||||
enabled: bool,
|
||||
handlers: Vec<ConfiguredHandler>,
|
||||
warnings: Vec<String>,
|
||||
shell: CommandShell,
|
||||
fired_once_hook_keys: Arc<Mutex<HashSet<String>>>,
|
||||
}
|
||||
|
||||
impl ClaudeHooksEngine {
|
||||
@@ -102,9 +111,11 @@ impl ClaudeHooksEngine {
|
||||
) -> Self {
|
||||
if !enabled {
|
||||
return Self {
|
||||
enabled,
|
||||
handlers: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
shell,
|
||||
fired_once_hook_keys: Arc::new(Mutex::new(HashSet::new())),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -115,9 +126,11 @@ impl ClaudeHooksEngine {
|
||||
plugin_hook_load_warnings,
|
||||
);
|
||||
Self {
|
||||
enabled,
|
||||
handlers: discovered.handlers,
|
||||
warnings: discovered.warnings,
|
||||
shell,
|
||||
fired_once_hook_keys: Arc::new(Mutex::new(HashSet::new())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,29 +138,71 @@ impl ClaudeHooksEngine {
|
||||
&self.warnings
|
||||
}
|
||||
|
||||
fn handlers_for_preview(&self) -> Vec<ConfiguredHandler> {
|
||||
let fired_once_hook_keys = self
|
||||
.fired_once_hook_keys
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
self.handlers
|
||||
.iter()
|
||||
.filter(|handler| !handler.once || !fired_once_hook_keys.contains(&handler.key))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn with_skill_hook_sources(&self, skill_hook_sources: Vec<SkillHookSource>) -> Self {
|
||||
if skill_hook_sources.is_empty() {
|
||||
return self.clone();
|
||||
}
|
||||
if !self.enabled {
|
||||
return self.clone();
|
||||
}
|
||||
|
||||
let mut handlers = self.handlers.clone();
|
||||
let mut warnings = self.warnings.clone();
|
||||
let next_display_order = handlers
|
||||
.iter()
|
||||
.map(|handler| handler.display_order)
|
||||
.max()
|
||||
.map_or(0, |display_order| display_order + 1);
|
||||
discovery::append_skill_hook_sources(
|
||||
&mut handlers,
|
||||
&mut warnings,
|
||||
next_display_order,
|
||||
skill_hook_sources,
|
||||
);
|
||||
Self {
|
||||
enabled: self.enabled,
|
||||
handlers,
|
||||
warnings,
|
||||
shell: self.shell.clone(),
|
||||
fired_once_hook_keys: Arc::clone(&self.fired_once_hook_keys),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn preview_session_start(
|
||||
&self,
|
||||
request: &SessionStartRequest,
|
||||
) -> Vec<HookRunSummary> {
|
||||
crate::events::session_start::preview(&self.handlers, request)
|
||||
crate::events::session_start::preview(&self.handlers_for_preview(), request)
|
||||
}
|
||||
|
||||
pub(crate) fn preview_pre_tool_use(&self, request: &PreToolUseRequest) -> Vec<HookRunSummary> {
|
||||
crate::events::pre_tool_use::preview(&self.handlers, request)
|
||||
crate::events::pre_tool_use::preview(&self.handlers_for_preview(), request)
|
||||
}
|
||||
|
||||
pub(crate) fn preview_permission_request(
|
||||
&self,
|
||||
request: &PermissionRequestRequest,
|
||||
) -> Vec<HookRunSummary> {
|
||||
crate::events::permission_request::preview(&self.handlers, request)
|
||||
crate::events::permission_request::preview(&self.handlers_for_preview(), request)
|
||||
}
|
||||
|
||||
pub(crate) fn preview_post_tool_use(
|
||||
&self,
|
||||
request: &PostToolUseRequest,
|
||||
) -> Vec<HookRunSummary> {
|
||||
crate::events::post_tool_use::preview(&self.handlers, request)
|
||||
crate::events::post_tool_use::preview(&self.handlers_for_preview(), request)
|
||||
}
|
||||
|
||||
pub(crate) async fn run_session_start(
|
||||
@@ -155,47 +210,84 @@ impl ClaudeHooksEngine {
|
||||
request: SessionStartRequest,
|
||||
turn_id: Option<String>,
|
||||
) -> SessionStartOutcome {
|
||||
crate::events::session_start::run(&self.handlers, &self.shell, request, turn_id).await
|
||||
crate::events::session_start::run(
|
||||
&self.handlers,
|
||||
&self.shell,
|
||||
&self.fired_once_hook_keys,
|
||||
request,
|
||||
turn_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn run_pre_tool_use(&self, request: PreToolUseRequest) -> PreToolUseOutcome {
|
||||
crate::events::pre_tool_use::run(&self.handlers, &self.shell, request).await
|
||||
crate::events::pre_tool_use::run(
|
||||
&self.handlers,
|
||||
&self.shell,
|
||||
&self.fired_once_hook_keys,
|
||||
request,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn run_permission_request(
|
||||
&self,
|
||||
request: PermissionRequestRequest,
|
||||
) -> PermissionRequestOutcome {
|
||||
crate::events::permission_request::run(&self.handlers, &self.shell, request).await
|
||||
crate::events::permission_request::run(
|
||||
&self.handlers,
|
||||
&self.shell,
|
||||
&self.fired_once_hook_keys,
|
||||
request,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn run_post_tool_use(
|
||||
&self,
|
||||
request: PostToolUseRequest,
|
||||
) -> PostToolUseOutcome {
|
||||
crate::events::post_tool_use::run(&self.handlers, &self.shell, request).await
|
||||
crate::events::post_tool_use::run(
|
||||
&self.handlers,
|
||||
&self.shell,
|
||||
&self.fired_once_hook_keys,
|
||||
request,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn preview_user_prompt_submit(
|
||||
&self,
|
||||
request: &UserPromptSubmitRequest,
|
||||
) -> Vec<HookRunSummary> {
|
||||
crate::events::user_prompt_submit::preview(&self.handlers, request)
|
||||
crate::events::user_prompt_submit::preview(&self.handlers_for_preview(), request)
|
||||
}
|
||||
|
||||
pub(crate) async fn run_user_prompt_submit(
|
||||
&self,
|
||||
request: UserPromptSubmitRequest,
|
||||
) -> UserPromptSubmitOutcome {
|
||||
crate::events::user_prompt_submit::run(&self.handlers, &self.shell, request).await
|
||||
crate::events::user_prompt_submit::run(
|
||||
&self.handlers,
|
||||
&self.shell,
|
||||
&self.fired_once_hook_keys,
|
||||
request,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn preview_stop(&self, request: &StopRequest) -> Vec<HookRunSummary> {
|
||||
crate::events::stop::preview(&self.handlers, request)
|
||||
crate::events::stop::preview(&self.handlers_for_preview(), request)
|
||||
}
|
||||
|
||||
pub(crate) async fn run_stop(&self, request: StopRequest) -> StopOutcome {
|
||||
crate::events::stop::run(&self.handlers, &self.shell, request).await
|
||||
crate::events::stop::run(
|
||||
&self.handlers,
|
||||
&self.shell,
|
||||
&self.fired_once_hook_keys,
|
||||
request,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
|
||||
command: format!("python3 {}", script_path.display()),
|
||||
timeout_sec: Some(10),
|
||||
r#async: false,
|
||||
once: false,
|
||||
status_message: Some("checking".to_string()),
|
||||
}],
|
||||
}],
|
||||
@@ -183,6 +184,7 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() {
|
||||
command: "python3 /tmp/managed.py".to_string(),
|
||||
timeout_sec: Some(10),
|
||||
r#async: false,
|
||||
once: false,
|
||||
status_message: Some("checking".to_string()),
|
||||
}],
|
||||
}],
|
||||
@@ -352,6 +354,7 @@ fn requirements_managed_hooks_warn_when_managed_dir_is_missing() {
|
||||
command: format!("python3 {}", missing_dir.join("pre.py").display()),
|
||||
timeout_sec: Some(10),
|
||||
r#async: false,
|
||||
once: false,
|
||||
status_message: Some("checking".to_string()),
|
||||
}],
|
||||
}],
|
||||
@@ -561,6 +564,7 @@ print(json.dumps({
|
||||
command: format!("python3 {}", script_path.display()),
|
||||
timeout_sec: Some(10),
|
||||
r#async: false,
|
||||
once: false,
|
||||
status_message: None,
|
||||
}],
|
||||
}],
|
||||
@@ -665,6 +669,7 @@ fn plugin_hook_sources_expand_plugin_placeholders() {
|
||||
.to_string(),
|
||||
timeout_sec: Some(5),
|
||||
r#async: false,
|
||||
once: false,
|
||||
status_message: None,
|
||||
}],
|
||||
}],
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
//! decision.
|
||||
//! 4. Fold the decisions conservatively: any deny wins, otherwise the last
|
||||
//! allow wins, otherwise there is no hook verdict.
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use super::common;
|
||||
use crate::engine::CommandShell;
|
||||
@@ -85,6 +87,7 @@ pub(crate) fn preview(
|
||||
pub(crate) async fn run(
|
||||
handlers: &[ConfiguredHandler],
|
||||
shell: &CommandShell,
|
||||
fired_once_hook_keys: &Mutex<HashSet<String>>,
|
||||
request: PermissionRequestRequest,
|
||||
) -> PermissionRequestOutcome {
|
||||
let matcher_inputs = common::matcher_inputs(&request.tool_name, &request.matcher_aliases);
|
||||
@@ -118,6 +121,7 @@ pub(crate) async fn run(
|
||||
|
||||
let results = dispatcher::execute_handlers(
|
||||
shell,
|
||||
fired_once_hook_keys,
|
||||
matched,
|
||||
input_json,
|
||||
request.cwd.as_path(),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::HookCompletedEvent;
|
||||
@@ -70,6 +72,7 @@ pub(crate) fn preview(
|
||||
pub(crate) async fn run(
|
||||
handlers: &[ConfiguredHandler],
|
||||
shell: &CommandShell,
|
||||
fired_once_hook_keys: &Mutex<HashSet<String>>,
|
||||
request: PostToolUseRequest,
|
||||
) -> PostToolUseOutcome {
|
||||
let matcher_inputs = common::matcher_inputs(&request.tool_name, &request.matcher_aliases);
|
||||
@@ -103,6 +106,7 @@ pub(crate) async fn run(
|
||||
|
||||
let results = dispatcher::execute_handlers(
|
||||
shell,
|
||||
fired_once_hook_keys,
|
||||
matched,
|
||||
input_json,
|
||||
request.cwd.as_path(),
|
||||
@@ -542,6 +546,7 @@ mod tests {
|
||||
|
||||
fn handler() -> ConfiguredHandler {
|
||||
ConfiguredHandler {
|
||||
key: "test".to_string(),
|
||||
event_name: HookEventName::PostToolUse,
|
||||
matcher: Some("^Bash$".to_string()),
|
||||
command: "python3 post_tool_use_hook.py".to_string(),
|
||||
@@ -551,6 +556,8 @@ mod tests {
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
env: std::collections::HashMap::new(),
|
||||
execution_cwd: None,
|
||||
once: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::HookCompletedEvent;
|
||||
@@ -65,6 +67,7 @@ pub(crate) fn preview(
|
||||
pub(crate) async fn run(
|
||||
handlers: &[ConfiguredHandler],
|
||||
shell: &CommandShell,
|
||||
fired_once_hook_keys: &Mutex<HashSet<String>>,
|
||||
request: PreToolUseRequest,
|
||||
) -> PreToolUseOutcome {
|
||||
let matcher_inputs = common::matcher_inputs(&request.tool_name, &request.matcher_aliases);
|
||||
@@ -96,6 +99,7 @@ pub(crate) async fn run(
|
||||
|
||||
let results = dispatcher::execute_handlers(
|
||||
shell,
|
||||
fired_once_hook_keys,
|
||||
matched,
|
||||
input_json,
|
||||
request.cwd.as_path(),
|
||||
@@ -533,6 +537,7 @@ mod tests {
|
||||
|
||||
fn handler() -> ConfiguredHandler {
|
||||
ConfiguredHandler {
|
||||
key: "test".to_string(),
|
||||
event_name: HookEventName::PreToolUse,
|
||||
matcher: Some("^Bash$".to_string()),
|
||||
command: "echo hook".to_string(),
|
||||
@@ -542,6 +547,8 @@ mod tests {
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
env: std::collections::HashMap::new(),
|
||||
execution_cwd: None,
|
||||
once: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::HookCompletedEvent;
|
||||
@@ -76,6 +78,7 @@ pub(crate) fn preview(
|
||||
pub(crate) async fn run(
|
||||
handlers: &[ConfiguredHandler],
|
||||
shell: &CommandShell,
|
||||
fired_once_hook_keys: &Mutex<HashSet<String>>,
|
||||
request: SessionStartRequest,
|
||||
turn_id: Option<String>,
|
||||
) -> SessionStartOutcome {
|
||||
@@ -113,6 +116,7 @@ pub(crate) async fn run(
|
||||
|
||||
let results = dispatcher::execute_handlers(
|
||||
shell,
|
||||
fired_once_hook_keys,
|
||||
matched,
|
||||
input_json,
|
||||
request.cwd.as_path(),
|
||||
@@ -355,6 +359,7 @@ mod tests {
|
||||
|
||||
fn handler() -> ConfiguredHandler {
|
||||
ConfiguredHandler {
|
||||
key: "test".to_string(),
|
||||
event_name: HookEventName::SessionStart,
|
||||
matcher: None,
|
||||
command: "echo hook".to_string(),
|
||||
@@ -364,6 +369,8 @@ mod tests {
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
env: std::collections::HashMap::new(),
|
||||
execution_cwd: None,
|
||||
once: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::items::HookPromptFragment;
|
||||
@@ -63,6 +65,7 @@ pub(crate) fn preview(
|
||||
pub(crate) async fn run(
|
||||
handlers: &[ConfiguredHandler],
|
||||
shell: &CommandShell,
|
||||
fired_once_hook_keys: &Mutex<HashSet<String>>,
|
||||
request: StopRequest,
|
||||
) -> StopOutcome {
|
||||
let matched =
|
||||
@@ -101,6 +104,7 @@ pub(crate) async fn run(
|
||||
|
||||
let results = dispatcher::execute_handlers(
|
||||
shell,
|
||||
fired_once_hook_keys,
|
||||
matched,
|
||||
input_json,
|
||||
request.cwd.as_path(),
|
||||
@@ -522,6 +526,7 @@ mod tests {
|
||||
|
||||
fn handler() -> ConfiguredHandler {
|
||||
ConfiguredHandler {
|
||||
key: "test".to_string(),
|
||||
event_name: HookEventName::Stop,
|
||||
matcher: None,
|
||||
command: "echo hook".to_string(),
|
||||
@@ -531,6 +536,8 @@ mod tests {
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
env: std::collections::HashMap::new(),
|
||||
execution_cwd: None,
|
||||
once: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::HookCompletedEvent;
|
||||
@@ -61,6 +63,7 @@ pub(crate) fn preview(
|
||||
pub(crate) async fn run(
|
||||
handlers: &[ConfiguredHandler],
|
||||
shell: &CommandShell,
|
||||
fired_once_hook_keys: &Mutex<HashSet<String>>,
|
||||
request: UserPromptSubmitRequest,
|
||||
) -> UserPromptSubmitOutcome {
|
||||
let matched = dispatcher::select_handlers(
|
||||
@@ -99,6 +102,7 @@ pub(crate) async fn run(
|
||||
|
||||
let results = dispatcher::execute_handlers(
|
||||
shell,
|
||||
fired_once_hook_keys,
|
||||
matched,
|
||||
input_json,
|
||||
request.cwd.as_path(),
|
||||
@@ -413,6 +417,7 @@ mod tests {
|
||||
|
||||
fn handler() -> ConfiguredHandler {
|
||||
ConfiguredHandler {
|
||||
key: "test".to_string(),
|
||||
event_name: HookEventName::UserPromptSubmit,
|
||||
matcher: None,
|
||||
command: "echo hook".to_string(),
|
||||
@@ -422,6 +427,8 @@ mod tests {
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
env: std::collections::HashMap::new(),
|
||||
execution_cwd: None,
|
||||
once: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ pub use legacy_notify::notify_hook;
|
||||
pub use registry::HookListOutcome;
|
||||
pub use registry::Hooks;
|
||||
pub use registry::HooksConfig;
|
||||
pub use registry::SkillHookSource;
|
||||
pub use registry::command_from_argv;
|
||||
pub use registry::list_hooks;
|
||||
pub use schema::write_schema_fixtures;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::HookEventsToml;
|
||||
use codex_plugin::PluginHookSource;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::engine::ClaudeHooksEngine;
|
||||
@@ -33,6 +35,13 @@ pub struct HooksConfig {
|
||||
pub shell_args: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SkillHookSource {
|
||||
pub skill_name: String,
|
||||
pub source_path: AbsolutePathBuf,
|
||||
pub hooks: HookEventsToml,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct HookListOutcome {
|
||||
pub hooks: Vec<HookListEntry>,
|
||||
@@ -81,6 +90,14 @@ impl Hooks {
|
||||
self.engine.warnings()
|
||||
}
|
||||
|
||||
pub fn with_skill_hook_sources(&self, skill_hook_sources: Vec<SkillHookSource>) -> Self {
|
||||
Self {
|
||||
after_agent: self.after_agent.clone(),
|
||||
after_tool_use: self.after_tool_use.clone(),
|
||||
engine: self.engine.with_skill_hook_sources(skill_hook_sources),
|
||||
}
|
||||
}
|
||||
|
||||
fn hooks_for_event(&self, hook_event: &HookEvent) -> &[Hook] {
|
||||
match hook_event {
|
||||
HookEvent::AfterAgent { .. } => &self.after_agent,
|
||||
|
||||
@@ -1573,6 +1573,7 @@ pub enum HookSource {
|
||||
Mdm,
|
||||
SessionFlags,
|
||||
Plugin,
|
||||
Skill,
|
||||
CloudRequirements,
|
||||
LegacyManagedConfigFile,
|
||||
LegacyManagedConfigMdm,
|
||||
@@ -4020,6 +4021,7 @@ mod tests {
|
||||
assert_eq!(HookSource::Project.is_managed(), false);
|
||||
assert_eq!(HookSource::SessionFlags.is_managed(), false);
|
||||
assert_eq!(HookSource::Plugin.is_managed(), false);
|
||||
assert_eq!(HookSource::Skill.is_managed(), false);
|
||||
assert_eq!(HookSource::Unknown.is_managed(), false);
|
||||
}
|
||||
|
||||
|
||||
@@ -564,6 +564,7 @@ fn hook_source_summary(hook: &HookMetadata) -> String {
|
||||
.as_deref()
|
||||
.map(|plugin_id| format!("Plugin - {plugin_id}"))
|
||||
.unwrap_or_else(|| "Plugin".to_string()),
|
||||
HookSource::Skill => "Skill".to_string(),
|
||||
_ => config_source_label(hook.source).to_string(),
|
||||
}
|
||||
}
|
||||
@@ -571,6 +572,11 @@ fn hook_source_summary(hook: &HookMetadata) -> String {
|
||||
fn detail_source_value(hook: &HookMetadata) -> String {
|
||||
match hook.source {
|
||||
HookSource::Plugin => hook_source_summary(hook),
|
||||
HookSource::Skill => format!(
|
||||
"{} - {}",
|
||||
config_source_label(hook.source),
|
||||
format_directory_display(&hook.source_path, /*max_width*/ None)
|
||||
),
|
||||
HookSource::System
|
||||
| HookSource::Mdm
|
||||
| HookSource::CloudRequirements
|
||||
@@ -592,6 +598,7 @@ fn config_source_label(source: HookSource) -> &'static str {
|
||||
HookSource::Mdm => "Admin config",
|
||||
HookSource::SessionFlags => "Session flags",
|
||||
HookSource::Plugin => unreachable!("plugin hooks are handled by summary_source"),
|
||||
HookSource::Skill => "Skill",
|
||||
HookSource::CloudRequirements => "Admin config",
|
||||
HookSource::LegacyManagedConfigFile => "Admin config",
|
||||
HookSource::LegacyManagedConfigMdm => "Admin config",
|
||||
|
||||
@@ -932,6 +932,7 @@ approval_policy = "never"
|
||||
command: "python3 /enterprise/hooks/pre.py".to_string(),
|
||||
timeout_sec: Some(10),
|
||||
r#async: false,
|
||||
once: false,
|
||||
status_message: Some("checking".to_string()),
|
||||
}],
|
||||
}],
|
||||
|
||||
Reference in New Issue
Block a user