Compare commits

...

3 Commits

Author SHA1 Message Date
Abhinav Vedmala
81e8603cca support once skill hooks 2026-05-01 10:43:53 -07:00
Abhinav Vedmala
0f5b32b78f run skill hooks from the skill directory 2026-05-01 10:34:20 -07:00
Abhinav Vedmala
0615194a36 support skill-scoped hooks 2026-04-30 16:37:05 -07:00
39 changed files with 767 additions and 32 deletions

View File

@@ -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",

View File

@@ -1902,6 +1902,7 @@
"mdm",
"sessionFlags",
"plugin",
"skill",
"cloudRequirements",
"legacyManagedConfigFile",
"legacyManagedConfigMdm",

View File

@@ -9986,6 +9986,7 @@
"mdm",
"sessionFlags",
"plugin",
"skill",
"cloudRequirements",
"legacyManagedConfigFile",
"legacyManagedConfigMdm",

View File

@@ -6595,6 +6595,7 @@
"mdm",
"sessionFlags",
"plugin",
"skill",
"cloudRequirements",
"legacyManagedConfigFile",
"legacyManagedConfigMdm",

View File

@@ -161,6 +161,7 @@
"mdm",
"sessionFlags",
"plugin",
"skill",
"cloudRequirements",
"legacyManagedConfigFile",
"legacyManagedConfigMdm",

View File

@@ -161,6 +161,7 @@
"mdm",
"sessionFlags",
"plugin",
"skill",
"cloudRequirements",
"legacyManagedConfigFile",
"legacyManagedConfigMdm",

View File

@@ -117,6 +117,7 @@
"mdm",
"sessionFlags",
"plugin",
"skill",
"cloudRequirements",
"legacyManagedConfigFile",
"legacyManagedConfigMdm",

View File

@@ -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";

View File

@@ -472,6 +472,7 @@ v2_enum_from_core!(
Mdm,
SessionFlags,
Plugin,
Skill,
CloudRequirements,
LegacyManagedConfigFile,
LegacyManagedConfigMdm,

View File

@@ -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()),
}],
}],

View File

@@ -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>,
},

View File

@@ -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,
}],
}],

View File

@@ -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| {

View File

@@ -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)
}

View File

@@ -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

View File

@@ -898,6 +898,10 @@
"command": {
"type": "string"
},
"once": {
"default": false,
"type": "boolean"
},
"statusMessage": {
"default": null,
"type": "string"

View File

@@ -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()),
}],
}],

View File

@@ -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",

View File

@@ -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;

View File

@@ -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,

View File

@@ -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))
}
}
}

View File

@@ -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)
}

View File

@@ -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(()));

View File

@@ -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())

View File

@@ -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,
}],
}],

View File

@@ -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,
}
}

View File

@@ -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
}
}

View File

@@ -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,
}],
}],

View File

@@ -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(),

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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",

View File

@@ -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()),
}],
}],