Synced from monorepo
Synced from monorepo Changes: - Report invalid MCP server config instead of failing startup - Keep completed terminal output when the gateway connection is lost - Show a duration-only detail view for single-task task output - Don't let a stale registry turn counter hide local sessions - Raise the file-descriptor soft limit on Linux and log effective limits at startup - Stop aborting when HTTP client construction fails - Make session thread and runtime spawn failures recoverable - Fix main-prompt paste parity in the question freeform input - Fire SessionEnd hooks on /exit and headless quit - Embed the deployment-config signing public key - Repaint paste-chip background on inline panel inputs - Security: prevent acceptEdits from auto-approving agent writes into the always-trusted global hook root - Fix stacked "Worked for" markers so parks render as status and turns close with exactly one marker - Parse hooks from config files - Add a remote kill-switch for managed-config signature verification - Security: fix workspace file-reference resolution bypassing workspace filesystem confinement Source-Revision: d02693a856a54f1030695b36b91d276e96b30b23
This commit is contained in:
parent
6e38642082
commit
47348d13ec
138 changed files with 7283 additions and 5796 deletions
|
|
@ -14,6 +14,7 @@ serde_json = { workspace = true }
|
|||
shellexpand = "3.1"
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["process", "rt", "macros", "time"] }
|
||||
toml = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
url = { workspace = true }
|
||||
xai-grok-config = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -7,44 +7,57 @@ use crate::error::HookError;
|
|||
use crate::event::HookEventName;
|
||||
use crate::matcher::HookMatcher;
|
||||
|
||||
/// The parsed `hooks` object from a compatible JSON settings file.
|
||||
///
|
||||
/// Parsing is lenient: unrecognized event names are skipped (not errors) so a
|
||||
/// `~/.claude/settings.json` with unsupported events still loads the rest.
|
||||
pub use xai_grok_config::HookProvenance;
|
||||
|
||||
/// Parsed `hooks` object. Unknown event names are skipped, not errors.
|
||||
#[derive(Debug)]
|
||||
pub struct HooksMap {
|
||||
pub events: HashMap<HookEventName, Vec<MatcherGroup>>,
|
||||
pub skipped_events: Vec<String>,
|
||||
}
|
||||
|
||||
impl HooksMap {
|
||||
pub fn from_value(value: serde_json::Value) -> Result<Self, String> {
|
||||
let raw_map: HashMap<String, serde_json::Value> =
|
||||
serde_json::from_value(value).map_err(|e| format!("invalid hooks structure: {e}"))?;
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum GroupErrorPolicy {
|
||||
Fail,
|
||||
SkipEvent,
|
||||
}
|
||||
|
||||
impl HooksMap {
|
||||
fn assemble<V>(
|
||||
entries: HashMap<String, V>,
|
||||
mut parse_groups: impl FnMut(V) -> Result<Vec<MatcherGroup>, String>,
|
||||
group_errors: GroupErrorPolicy,
|
||||
) -> Result<Self, String> {
|
||||
let mut events: HashMap<HookEventName, Vec<MatcherGroup>> = HashMap::new();
|
||||
let mut skipped_events = Vec::new();
|
||||
|
||||
for (key, val) in raw_map {
|
||||
let event_name: HookEventName =
|
||||
match serde_json::from_value(serde_json::Value::String(key.clone())) {
|
||||
Ok(name) => name,
|
||||
Err(_) => {
|
||||
skipped_events.push(key);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let matcher_groups: Vec<MatcherGroup> = match serde_json::from_value(val) {
|
||||
Ok(groups) => groups,
|
||||
Err(e) => {
|
||||
return Err(format!("invalid matcher groups for event '{key}': {e}"));
|
||||
for (key, val) in entries {
|
||||
let event_name = match HookEventName::parse_key(&key) {
|
||||
Some(name) => name,
|
||||
None => {
|
||||
skipped_events.push(key);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Aliases (e.g. `SubagentEnd`) can parse to one event, so merge
|
||||
// groups rather than insert, which would drop all but one.
|
||||
events.entry(event_name).or_default().extend(matcher_groups);
|
||||
match parse_groups(val) {
|
||||
Ok(groups) => events.entry(event_name).or_default().extend(groups),
|
||||
Err(detail) => match group_errors {
|
||||
GroupErrorPolicy::Fail => {
|
||||
return Err(format!(
|
||||
"invalid matcher groups for event '{key}': {detail}"
|
||||
));
|
||||
}
|
||||
GroupErrorPolicy::SkipEvent => {
|
||||
tracing::warn!(
|
||||
event = %key,
|
||||
error = %detail,
|
||||
"hooks: skipping malformed event in config layer (other events still load)"
|
||||
);
|
||||
skipped_events.push(key);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Ok(HooksMap {
|
||||
|
|
@ -52,6 +65,30 @@ impl HooksMap {
|
|||
skipped_events,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a `hooks` object from JSON. A malformed event fails the whole parse.
|
||||
pub fn from_value(value: serde_json::Value) -> Result<Self, String> {
|
||||
let entries: HashMap<String, serde_json::Value> =
|
||||
serde_json::from_value(value).map_err(|e| format!("invalid hooks structure: {e}"))?;
|
||||
Self::assemble(
|
||||
entries,
|
||||
|v| serde_json::from_value(v).map_err(|e| e.to_string()),
|
||||
GroupErrorPolicy::Fail,
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse a `hooks` table from TOML. Unlike [`Self::from_value`], a malformed
|
||||
/// event is skipped so one bad event can't drop the layer.
|
||||
pub fn from_toml_value(value: toml::Value) -> Result<Self, String> {
|
||||
let entries: HashMap<String, toml::Value> = value
|
||||
.try_into()
|
||||
.map_err(|e: toml::de::Error| format!("invalid hooks structure: {e}"))?;
|
||||
Self::assemble(
|
||||
entries,
|
||||
|v| v.try_into().map_err(|e: toml::de::Error| e.to_string()),
|
||||
GroupErrorPolicy::SkipEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -69,15 +106,13 @@ pub struct RawHandler {
|
|||
pub url: Option<String>,
|
||||
/// Seconds (converted to milliseconds internally).
|
||||
pub timeout: Option<u64>,
|
||||
/// Extra env vars for the hook process; merged into [`HookSpec::extra_env`]
|
||||
/// (see its rustdoc for precedence and reserved-key stripping).
|
||||
/// Extra env vars, merged into [`HookSpec::extra_env`].
|
||||
#[serde(default, deserialize_with = "deserialize_optional_string_map")]
|
||||
pub env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Accepts `null`, an absent field, or a string map. Serde otherwise rejects an
|
||||
/// explicit `"env": null` for a `HashMap` field even with `#[serde(default)]`;
|
||||
/// treating `null` as "no env" matches user intent.
|
||||
/// Treat `null` or an absent field as an empty map (serde otherwise rejects
|
||||
/// `null` for a `HashMap`).
|
||||
fn deserialize_optional_string_map<'de, D>(de: D) -> Result<HashMap<String, String>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
|
|
@ -104,8 +139,7 @@ fn default_timeout_ms(event: crate::event::HookEventName) -> u64 {
|
|||
}
|
||||
}
|
||||
|
||||
/// The validated handler kind. `RawHandler::handler_type` keeps the untrusted
|
||||
/// string; parsing validates it into this so consumers dispatch exhaustively.
|
||||
/// The validated handler kind.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HandlerType {
|
||||
|
|
@ -122,60 +156,99 @@ impl HandlerType {
|
|||
}
|
||||
}
|
||||
|
||||
/// A validated hook specification, ready for use by the dispatcher.
|
||||
impl std::str::FromStr for HandlerType {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"command" => Ok(Self::Command),
|
||||
"http" => Ok(Self::Http),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A validated hook specification, ready for the dispatcher.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HookSpec {
|
||||
pub name: String,
|
||||
pub event: HookEventName,
|
||||
pub handler_type: HandlerType,
|
||||
/// Raw pattern as written, kept for `/hooks-list` display (the compiled form
|
||||
/// is [`matcher`](HookSpec::matcher)).
|
||||
/// Pattern as written; the compiled form is `matcher`.
|
||||
pub configured_matcher: Option<String>,
|
||||
#[serde(skip)]
|
||||
pub matcher: Option<HookMatcher>,
|
||||
pub enabled: bool,
|
||||
/// Executable path (command handlers), post-expansion: parse-time-resolvable
|
||||
/// `$VAR` refs are substituted, unresolved/modifier forms (`${VAR:-x}`) kept
|
||||
/// for the runner's `sh -c` branch. Unlike [`url`](HookSpec::url), commands
|
||||
/// are NOT re-expanded at run time, so only `sh -c` sees mid-session env
|
||||
/// changes. Display via [`command_raw`](HookSpec::command_raw) so resolved
|
||||
/// secrets never leak.
|
||||
/// Command path, env-expanded; unresolved/modifier forms kept for the runner's
|
||||
/// `sh -c` branch. Not re-expanded at run time. Display via `command_raw`.
|
||||
pub command: Option<PathBuf>,
|
||||
/// Pre-expansion source for `command`; use it for display so resolved `env`
|
||||
/// values (possibly secrets) never leak past the runner.
|
||||
/// Pre-expansion `command` for display, so resolved secrets never leak.
|
||||
pub command_raw: Option<String>,
|
||||
/// URL endpoint (http handlers), post-expanded like [`command`](HookSpec::command).
|
||||
/// The HTTP runner re-expands it at run time before SSRF validation, so plugin
|
||||
/// URLs referencing later-injected `extra_env` keys resolve: mid-session env
|
||||
/// changes take effect for URLs but not commands (deliberate asymmetry).
|
||||
/// Display via [`url_raw`](HookSpec::url_raw).
|
||||
/// URL (http handlers), env-expanded. Unlike `command`, the HTTP runner
|
||||
/// re-expands at run time before SSRF validation (deliberate asymmetry).
|
||||
pub url: Option<String>,
|
||||
/// Pre-expansion source for `url`, for display; see [`command_raw`](HookSpec::command_raw).
|
||||
/// Pre-expansion `url` for display; see `command_raw`.
|
||||
pub url_raw: Option<String>,
|
||||
pub timeout_ms: u64,
|
||||
pub source_dir: PathBuf,
|
||||
/// Extra environment variables injected into the hook process.
|
||||
///
|
||||
/// Sources, lowest to highest precedence:
|
||||
///
|
||||
/// 1. The user-declared `env` map (populated by [`parse_hook_file`]).
|
||||
/// Runner-reserved keys (`GROK_HOOK_EVENT`, `GROK_HOOK_NAME`,
|
||||
/// `GROK_SESSION_ID`, `GROK_WORKSPACE_ROOT`, `CLAUDE_PROJECT_DIR`) are
|
||||
/// stripped at load time with a tracing warning.
|
||||
/// 2. Plugin-injected vars merged by the plugin adapter
|
||||
/// (`xai-grok-agent::plugins::hooks_adapter`): `GROK_PLUGIN_ROOT`,
|
||||
/// `CLAUDE_PLUGIN_ROOT`, `GROK_PLUGIN_DATA`, `CLAUDE_PLUGIN_DATA`, which
|
||||
/// override any user values for those keys.
|
||||
/// 3. Runner-injected vars applied at spawn time AFTER `extra_env`, so they
|
||||
/// always win even if a reserved key leaks through the layers above. This
|
||||
/// is a security property: the child must see authentic identity/event
|
||||
/// signals, never spoofed values. See the regression test
|
||||
/// `runner_injected_vars_override_extra_env_at_spawn` in
|
||||
/// `tests/integration.rs`.
|
||||
///
|
||||
/// Besides being passed to the child, this map is consulted by the load-time
|
||||
/// expansion of `command` and `url` (see [`crate::env_expand`]).
|
||||
/// Env injected into the hook process, and consulted by load-time `command`/
|
||||
/// `url` expansion. Precedence low→high: user `env` (reserved keys stripped) <
|
||||
/// plugin-injected < runner-injected at spawn (authentic identity always wins).
|
||||
pub extra_env: std::collections::HashMap<String, String>,
|
||||
/// The hook's origin and single source of truth for classification: `File`
|
||||
/// (JSON files, agent frontmatter), a config tier, or `Plugin`. `#[serde(default)]`
|
||||
/// reads pre-field wire specs as `File`.
|
||||
#[serde(default)]
|
||||
pub layer: HookProvenance,
|
||||
}
|
||||
|
||||
/// Namespace prefixes stamped on hook names, matched by [`hook_origin`]. Shared
|
||||
/// so a rename can't silently reclassify a tier.
|
||||
pub const GLOBAL_HOOK_PREFIX: &str = "global/";
|
||||
pub const PROJECT_HOOK_PREFIX: &str = "project/";
|
||||
pub const PLUGIN_HOOK_PREFIX: &str = "plugin/";
|
||||
pub const AGENT_HOOK_PREFIX: &str = "agent:";
|
||||
|
||||
/// A hook's classified origin for display and telemetry.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HookOrigin {
|
||||
SystemManaged,
|
||||
Managed,
|
||||
Requirements,
|
||||
UserConfig,
|
||||
UserFile,
|
||||
ProjectFile,
|
||||
Plugin,
|
||||
Agent,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Classify a hook's origin from [`HookProvenance`], falling back to the name
|
||||
/// prefix for `File`-tier hooks.
|
||||
pub fn hook_origin(spec: &HookSpec) -> HookOrigin {
|
||||
match spec.layer {
|
||||
HookProvenance::SystemManaged => HookOrigin::SystemManaged,
|
||||
HookProvenance::Managed => HookOrigin::Managed,
|
||||
HookProvenance::Requirements => HookOrigin::Requirements,
|
||||
HookProvenance::User => HookOrigin::UserConfig,
|
||||
HookProvenance::Plugin => HookOrigin::Plugin,
|
||||
HookProvenance::Unknown => HookOrigin::Unknown,
|
||||
HookProvenance::File => {
|
||||
let name = spec.name.as_str();
|
||||
if name.starts_with(GLOBAL_HOOK_PREFIX) {
|
||||
HookOrigin::UserFile
|
||||
} else if name.starts_with(PROJECT_HOOK_PREFIX) {
|
||||
HookOrigin::ProjectFile
|
||||
} else if name.starts_with(AGENT_HOOK_PREFIX) {
|
||||
HookOrigin::Agent
|
||||
} else if name.starts_with(PLUGIN_HOOK_PREFIX) {
|
||||
// Defensive: a plugin hook whose adapter didn't stamp `layer`.
|
||||
HookOrigin::Plugin
|
||||
} else {
|
||||
HookOrigin::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse hooks from a JSON value (e.g. from agent definition frontmatter).
|
||||
|
|
@ -189,24 +262,104 @@ pub fn parse_hooks_from_value(
|
|||
parse_hooks_from_value_with_dir(hooks, source_name, std::path::Path::new("."))
|
||||
}
|
||||
|
||||
/// Like `parse_hooks_from_value` but with an explicit `source_dir` for
|
||||
/// resolving relative command paths.
|
||||
/// [`parse_hooks_from_value`] with an explicit `source_dir`. Parses the decoded
|
||||
/// value directly (no re-parse round-trip); a malformed event is a hard error.
|
||||
pub fn parse_hooks_from_value_with_dir(
|
||||
hooks: &serde_json::Value,
|
||||
source_name: &str,
|
||||
source_dir: &Path,
|
||||
) -> (Vec<HookSpec>, Vec<HookError>) {
|
||||
let wrapper = serde_json::json!({ "hooks": hooks });
|
||||
let (mut specs, errors) =
|
||||
parse_hook_file(&wrapper.to_string(), std::path::Path::new(source_name));
|
||||
for spec in &mut specs {
|
||||
spec.source_dir = source_dir.to_path_buf();
|
||||
let error_path = Path::new(source_name);
|
||||
let hooks_map = match HooksMap::from_value(hooks.clone()) {
|
||||
Ok(map) => map,
|
||||
Err(detail) => {
|
||||
return (
|
||||
Vec::new(),
|
||||
vec![HookError::ParseFile {
|
||||
path: error_path.to_path_buf(),
|
||||
detail,
|
||||
}],
|
||||
);
|
||||
}
|
||||
};
|
||||
if !hooks_map.skipped_events.is_empty() {
|
||||
tracing::warn!(
|
||||
source = %source_name,
|
||||
skipped = ?hooks_map.skipped_events,
|
||||
"hooks: skipped unrecognized event names (check for typos)"
|
||||
);
|
||||
}
|
||||
(specs, errors)
|
||||
|
||||
let name_prefix = error_path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown");
|
||||
build_specs(
|
||||
hooks_map,
|
||||
SpecContext {
|
||||
name_prefix,
|
||||
source_dir,
|
||||
error_path,
|
||||
provenance: HookProvenance::File,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Build specs from config-layer `hooks` blocks, tagging each with its layer's
|
||||
/// `source_name`. Layers arrive highest-authority-first and specs preserve that
|
||||
/// order, so the caller's dedup keeps the higher-authority copy. Relative commands
|
||||
/// resolve against each layer's own directory; a layer that fails to parse is
|
||||
/// recorded and skipped, the rest still load.
|
||||
pub fn parse_hooks_from_config_layers(
|
||||
layers: &[xai_grok_config::HookConfigLayer],
|
||||
) -> (Vec<HookSpec>, Vec<HookError>) {
|
||||
let home = xai_grok_config::user_grok_home();
|
||||
let mut all_specs = Vec::new();
|
||||
let mut all_errors = Vec::new();
|
||||
|
||||
for layer in layers {
|
||||
let source_name = layer.source_name();
|
||||
let error_path = layer.path();
|
||||
// Resolve relative commands against the layer's own dir, not the user home.
|
||||
let source_dir = match error_path.parent() {
|
||||
Some(dir) if !dir.as_os_str().is_empty() => dir.to_path_buf(),
|
||||
_ => home.clone().unwrap_or_else(|| PathBuf::from(".")),
|
||||
};
|
||||
let hooks_map = match HooksMap::from_toml_value(layer.hooks().clone()) {
|
||||
Ok(map) => map,
|
||||
Err(detail) => {
|
||||
all_errors.push(HookError::ParseFile {
|
||||
path: error_path.to_path_buf(),
|
||||
detail,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !hooks_map.skipped_events.is_empty() {
|
||||
tracing::warn!(
|
||||
source = %source_name,
|
||||
skipped = ?hooks_map.skipped_events,
|
||||
"hooks: skipped unrecognized or malformed events in config layer"
|
||||
);
|
||||
}
|
||||
let (specs, errors) = build_specs(
|
||||
hooks_map,
|
||||
SpecContext {
|
||||
name_prefix: source_name,
|
||||
source_dir: &source_dir,
|
||||
error_path,
|
||||
provenance: layer.provenance(),
|
||||
},
|
||||
);
|
||||
all_specs.extend(specs);
|
||||
all_errors.extend(errors);
|
||||
}
|
||||
|
||||
(all_specs, all_errors)
|
||||
}
|
||||
|
||||
pub fn parse_hook_file(content: &str, file_path: &Path) -> (Vec<HookSpec>, Vec<HookError>) {
|
||||
let mut specs = Vec::new();
|
||||
let specs = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let top_level: serde_json::Value = match serde_json::from_str(content) {
|
||||
|
|
@ -250,119 +403,52 @@ pub fn parse_hook_file(content: &str, file_path: &Path) -> (Vec<HookSpec>, Vec<H
|
|||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown");
|
||||
|
||||
// HashMap event order is nondeterministic, but dispatch is per-event so it
|
||||
// doesn't matter; within an event, source order is preserved.
|
||||
for (event, matcher_groups) in hooks_map.events {
|
||||
build_specs(
|
||||
hooks_map,
|
||||
SpecContext {
|
||||
name_prefix: file_stem,
|
||||
source_dir: &source_dir,
|
||||
error_path: file_path,
|
||||
provenance: HookProvenance::File,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Build [`HookSpec`]s from a [`HooksMap`], shared by the JSON and config paths
|
||||
/// so the two never diverge.
|
||||
fn build_specs(hooks_map: HooksMap, ctx: SpecContext<'_>) -> (Vec<HookSpec>, Vec<HookError>) {
|
||||
let mut specs = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
|
||||
// Stable event order for reproducible output; source order kept within an event.
|
||||
let mut events: Vec<(HookEventName, Vec<MatcherGroup>)> =
|
||||
hooks_map.events.into_iter().collect();
|
||||
events.sort_by_key(|(event, _)| *event);
|
||||
for (event, matcher_groups) in events {
|
||||
for (group_idx, group) in matcher_groups.into_iter().enumerate() {
|
||||
let matcher_pattern = group
|
||||
.matcher
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Events with an `Ignored` matcher policy keep the configured pattern
|
||||
// for display but never compile it, so the hook always fires.
|
||||
let matcher_ignored = matcher_pattern.is_some()
|
||||
&& event.traits().matcher == crate::event::MatcherPolicy::Ignored;
|
||||
if matcher_ignored {
|
||||
tracing::warn!(
|
||||
hook = %format!("{file_stem}:{event}[{group_idx}]"),
|
||||
path = %file_path.display(),
|
||||
"hooks: matcher on a {event} group is ignored (this event always fires)"
|
||||
);
|
||||
}
|
||||
|
||||
let compiled_matcher = match matcher_pattern.as_ref().filter(|_| !matcher_ignored) {
|
||||
Some(pattern) => match HookMatcher::new(pattern) {
|
||||
Ok(m) => Some(m),
|
||||
let group_label = format!("{}:{event}[{group_idx}]", ctx.name_prefix);
|
||||
let (configured_matcher, compiled_matcher) =
|
||||
match resolve_group_matcher(group.matcher.as_deref(), event, &group_label, &ctx) {
|
||||
Ok(pair) => pair,
|
||||
Err(e) => {
|
||||
let name = format!("{file_stem}:{event}[{group_idx}]");
|
||||
errors.push(HookError::InvalidMatcher {
|
||||
name,
|
||||
path: file_path.to_path_buf(),
|
||||
source: e,
|
||||
});
|
||||
errors.push(e);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
};
|
||||
|
||||
for (hook_idx, handler) in group.hooks.into_iter().enumerate() {
|
||||
let name = format!("{file_stem}:{event}[{group_idx}].hooks[{hook_idx}]");
|
||||
|
||||
// `matcher` is deliberately NOT env-expanded: `$` is the regex
|
||||
// end-of-line anchor, so `$VAR` substitution would corrupt it.
|
||||
|
||||
let timeout_ms = handler
|
||||
.timeout
|
||||
.map(|secs| secs * 1000)
|
||||
.unwrap_or(default_timeout_ms(event));
|
||||
|
||||
let mut extra_env: HashMap<String, String> = handler.env;
|
||||
strip_reserved_env_keys(&mut extra_env, &name, file_path);
|
||||
|
||||
let handler_type = match handler.handler_type.as_str() {
|
||||
"command" => HandlerType::Command,
|
||||
"http" => HandlerType::Http,
|
||||
_ => {
|
||||
errors.push(HookError::UnsupportedHandlerType {
|
||||
name,
|
||||
path: file_path.to_path_buf(),
|
||||
handler_type: handler.handler_type,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Expand `command`/`url` now (`extra_env` first, then process
|
||||
// env). Unset refs are preserved: command hooks defer to the
|
||||
// runner, and the HTTP runner re-expands before SSRF validation
|
||||
// in case `extra_env` was populated after parsing.
|
||||
let (command, command_raw, url, url_raw) = match handler_type {
|
||||
HandlerType::Command => {
|
||||
let Some(command) = handler.command else {
|
||||
errors.push(HookError::InvalidConfig {
|
||||
name,
|
||||
path: file_path.to_path_buf(),
|
||||
detail: "command handler requires a 'command' field".into(),
|
||||
});
|
||||
continue;
|
||||
};
|
||||
let expanded =
|
||||
crate::env_expand::expand_env_vars_with_extra(&command, &extra_env);
|
||||
(Some(PathBuf::from(expanded)), Some(command), None, None)
|
||||
}
|
||||
HandlerType::Http => {
|
||||
let Some(url) = handler.url else {
|
||||
errors.push(HookError::InvalidConfig {
|
||||
name,
|
||||
path: file_path.to_path_buf(),
|
||||
detail: "http handler requires a 'url' field".into(),
|
||||
});
|
||||
continue;
|
||||
};
|
||||
let expanded =
|
||||
crate::env_expand::expand_env_vars_with_extra(&url, &extra_env);
|
||||
(None, None, Some(expanded), Some(url))
|
||||
}
|
||||
};
|
||||
|
||||
specs.push(HookSpec {
|
||||
name,
|
||||
let name = format!("{group_label}.hooks[{hook_idx}]");
|
||||
match build_one_spec(
|
||||
handler,
|
||||
event,
|
||||
handler_type,
|
||||
configured_matcher: matcher_pattern.clone(),
|
||||
matcher: compiled_matcher.clone(),
|
||||
enabled: true,
|
||||
command,
|
||||
command_raw,
|
||||
url,
|
||||
url_raw,
|
||||
timeout_ms,
|
||||
source_dir: source_dir.clone(),
|
||||
extra_env,
|
||||
});
|
||||
name,
|
||||
configured_matcher.clone(),
|
||||
compiled_matcher.clone(),
|
||||
&ctx,
|
||||
) {
|
||||
Ok(spec) => specs.push(spec),
|
||||
Err(e) => errors.push(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -370,11 +456,128 @@ pub fn parse_hook_file(content: &str, file_path: &Path) -> (Vec<HookSpec>, Vec<H
|
|||
(specs, errors)
|
||||
}
|
||||
|
||||
/// Strip user-supplied `env` entries that override runner-reserved keys.
|
||||
///
|
||||
/// Redundant with the spawn-time ordering in `runner/command.rs`, but stripping
|
||||
/// here gives a clear "ignored" signal and covers load paths that bypass
|
||||
/// `parse_hook_file`.
|
||||
/// Resolve a group's `(configured_matcher, compiled_matcher)`. The compiled
|
||||
/// matcher is `None` with no pattern, or when the event ignores matchers (pattern
|
||||
/// kept for display, hook always fires). Errors only on an invalid regex.
|
||||
fn resolve_group_matcher(
|
||||
group_matcher: Option<&str>,
|
||||
event: HookEventName,
|
||||
group_label: &str,
|
||||
ctx: &SpecContext<'_>,
|
||||
) -> Result<(Option<String>, Option<HookMatcher>), HookError> {
|
||||
let configured = group_matcher
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
if configured.is_some() && event.traits().matcher == crate::event::MatcherPolicy::Ignored {
|
||||
tracing::warn!(
|
||||
hook = %group_label,
|
||||
path = %ctx.error_path.display(),
|
||||
"hooks: matcher on a {event} group is ignored (this event always fires)"
|
||||
);
|
||||
return Ok((configured, None));
|
||||
}
|
||||
|
||||
let compiled = match configured.as_deref() {
|
||||
Some(pattern) => {
|
||||
Some(
|
||||
HookMatcher::new(pattern).map_err(|source| HookError::InvalidMatcher {
|
||||
name: group_label.to_string(),
|
||||
path: ctx.error_path.to_path_buf(),
|
||||
source,
|
||||
})?,
|
||||
)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
Ok((configured, compiled))
|
||||
}
|
||||
|
||||
/// Per-call constants shared by every group and handler in one [`build_specs`].
|
||||
struct SpecContext<'a> {
|
||||
/// Labels specs as `"{name_prefix}:{event}[..]"` (file stem or config `source_name`).
|
||||
name_prefix: &'a str,
|
||||
source_dir: &'a Path,
|
||||
error_path: &'a Path,
|
||||
provenance: HookProvenance,
|
||||
}
|
||||
|
||||
/// Build one [`HookSpec`] from a handler entry, or the [`HookError`] preventing it.
|
||||
/// `command`/`url` are env-expanded (unset refs kept for the runner); `matcher` is
|
||||
/// not, since `$` is the regex end anchor.
|
||||
fn build_one_spec(
|
||||
handler: RawHandler,
|
||||
event: HookEventName,
|
||||
name: String,
|
||||
configured_matcher: Option<String>,
|
||||
compiled_matcher: Option<HookMatcher>,
|
||||
ctx: &SpecContext<'_>,
|
||||
) -> Result<HookSpec, HookError> {
|
||||
let timeout_ms = handler
|
||||
.timeout
|
||||
// Untrusted config value: saturate rather than overflow (debug panic /
|
||||
// release wrap) on an absurd timeout.
|
||||
.map(|secs| secs.saturating_mul(1000))
|
||||
.unwrap_or(default_timeout_ms(event));
|
||||
|
||||
let mut extra_env: HashMap<String, String> = handler.env;
|
||||
strip_reserved_env_keys(&mut extra_env, &name, ctx.error_path);
|
||||
|
||||
let handler_type = match handler.handler_type.parse::<HandlerType>() {
|
||||
Ok(ht) => ht,
|
||||
Err(()) => {
|
||||
return Err(HookError::UnsupportedHandlerType {
|
||||
name,
|
||||
path: ctx.error_path.to_path_buf(),
|
||||
handler_type: handler.handler_type,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let (command, command_raw, url, url_raw) = match handler_type {
|
||||
HandlerType::Command => {
|
||||
let Some(command) = handler.command else {
|
||||
return Err(HookError::InvalidConfig {
|
||||
name,
|
||||
path: ctx.error_path.to_path_buf(),
|
||||
detail: "command handler requires a 'command' field".into(),
|
||||
});
|
||||
};
|
||||
let expanded = crate::env_expand::expand_env_vars_with_extra(&command, &extra_env);
|
||||
(Some(PathBuf::from(expanded)), Some(command), None, None)
|
||||
}
|
||||
HandlerType::Http => {
|
||||
let Some(url) = handler.url else {
|
||||
return Err(HookError::InvalidConfig {
|
||||
name,
|
||||
path: ctx.error_path.to_path_buf(),
|
||||
detail: "http handler requires a 'url' field".into(),
|
||||
});
|
||||
};
|
||||
let expanded = crate::env_expand::expand_env_vars_with_extra(&url, &extra_env);
|
||||
(None, None, Some(expanded), Some(url))
|
||||
}
|
||||
};
|
||||
|
||||
Ok(HookSpec {
|
||||
name,
|
||||
event,
|
||||
handler_type,
|
||||
configured_matcher,
|
||||
matcher: compiled_matcher,
|
||||
enabled: true,
|
||||
command,
|
||||
command_raw,
|
||||
url,
|
||||
url_raw,
|
||||
timeout_ms,
|
||||
source_dir: ctx.source_dir.to_path_buf(),
|
||||
extra_env,
|
||||
layer: ctx.provenance,
|
||||
})
|
||||
}
|
||||
|
||||
/// Strip user `env` entries that would shadow runner-reserved keys, with a warning.
|
||||
fn strip_reserved_env_keys(
|
||||
extra_env: &mut HashMap<String, String>,
|
||||
spec_name: &str,
|
||||
|
|
@ -397,6 +600,78 @@ mod tests {
|
|||
use super::*;
|
||||
use crate::test_support::with_env_var;
|
||||
|
||||
fn config_layer(source_name: &str, toml_src: &str) -> xai_grok_config::HookConfigLayer {
|
||||
let value: toml::Value = toml::from_str(toml_src).unwrap();
|
||||
let hooks = value.get("hooks").cloned().unwrap();
|
||||
xai_grok_config::HookConfigLayer::new(
|
||||
xai_grok_config::HookProvenance::Managed,
|
||||
source_name,
|
||||
hooks,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_layer_hook_parses_like_the_json_path() {
|
||||
let layer = config_layer(
|
||||
"managed",
|
||||
"[[hooks.PreToolUse]]\nmatcher = \"Bash\"\n[[hooks.PreToolUse.hooks]]\ntype = \"command\"\ncommand = \"bin/check.sh\"\ntimeout = 2\n",
|
||||
);
|
||||
let (specs, errors) = parse_hooks_from_config_layers(std::slice::from_ref(&layer));
|
||||
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
|
||||
assert_eq!(specs.len(), 1);
|
||||
let s = &specs[0];
|
||||
assert_eq!(s.event, HookEventName::PreToolUse);
|
||||
assert_eq!(s.handler_type, HandlerType::Command);
|
||||
assert_eq!(s.timeout_ms, 2000);
|
||||
assert_eq!(s.layer, HookProvenance::Managed);
|
||||
assert!(s.name.starts_with("managed:"), "got {}", s.name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_layer_keeps_valid_events_when_one_is_malformed() {
|
||||
// A config layer skips a malformed event and keeps the rest, unlike the
|
||||
// JSON path which fails the whole file.
|
||||
let layer = config_layer(
|
||||
"managed",
|
||||
"hooks.PreToolUse = \"oops\"\n[[hooks.PostToolUse]]\n[[hooks.PostToolUse.hooks]]\ntype = \"command\"\ncommand = \"ok.sh\"\n",
|
||||
);
|
||||
let (specs, _errors) = parse_hooks_from_config_layers(std::slice::from_ref(&layer));
|
||||
assert_eq!(specs.len(), 1);
|
||||
assert_eq!(specs[0].event, HookEventName::PostToolUse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_layers_additive_and_dedup_keeps_higher_authority() {
|
||||
let mk = |src: &str, prov, cmd: &str| {
|
||||
let toml_src = format!(
|
||||
"[[PreToolUse]]\n[[PreToolUse.hooks]]\ntype = \"command\"\ncommand = \"{cmd}\"\n"
|
||||
);
|
||||
xai_grok_config::HookConfigLayer::new(
|
||||
prov,
|
||||
src,
|
||||
toml::from_str::<toml::Value>(&toml_src).unwrap(),
|
||||
)
|
||||
};
|
||||
|
||||
// Distinct commands are additive; an identical command dedupes to the
|
||||
// higher-authority (first-listed) copy.
|
||||
use xai_grok_config::HookProvenance::{Managed, User};
|
||||
let (additive, _) = parse_hooks_from_config_layers(&[
|
||||
mk("managed", Managed, "m.sh"),
|
||||
mk("user", User, "u.sh"),
|
||||
]);
|
||||
assert_eq!(additive.len(), 2);
|
||||
|
||||
let (dup, _) = parse_hooks_from_config_layers(&[
|
||||
mk("managed", Managed, "same.sh"),
|
||||
mk("user", User, "same.sh"),
|
||||
]);
|
||||
let registry = crate::discovery::registry_from_specs_deduped(dup);
|
||||
let pre = registry.hooks_for(HookEventName::PreToolUse);
|
||||
assert_eq!(pre.len(), 1);
|
||||
assert!(pre[0].name.starts_with("managed:"), "got {}", pre[0].name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_claude_format_single_hook() {
|
||||
let json = r#"{
|
||||
|
|
|
|||
|
|
@ -63,34 +63,37 @@ impl HookRegistry {
|
|||
}
|
||||
}
|
||||
|
||||
/// Flatten the registry into a spec list in [`HookEventName::ALL`] order, so
|
||||
/// rebuilding from the result is stable regardless of `HashMap` iteration.
|
||||
pub fn into_specs(self) -> Vec<HookSpec> {
|
||||
let mut hooks = self.hooks;
|
||||
let mut out = Vec::new();
|
||||
for event in HookEventName::ALL {
|
||||
if let Some(specs) = hooks.remove(event) {
|
||||
out.extend(specs);
|
||||
}
|
||||
}
|
||||
// Defensive: `ALL` covers every variant, but keep leftovers in a stable order.
|
||||
if !hooks.is_empty() {
|
||||
let mut leftover: Vec<(HookEventName, Vec<HookSpec>)> = hooks.into_iter().collect();
|
||||
// Typed order, not `Display` (which collapses SubagentStop/SubagentEnd).
|
||||
leftover.sort_by_key(|(event, _)| *event);
|
||||
for (_, specs) in leftover {
|
||||
out.extend(specs);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn remove_by_prefix(&mut self, prefix: &str) {
|
||||
for specs in self.hooks.values_mut() {
|
||||
specs.retain(|s| !s.name.starts_with(prefix));
|
||||
}
|
||||
}
|
||||
|
||||
/// All event types in canonical display order.
|
||||
const ALL_EVENTS: &[HookEventName] = &[
|
||||
HookEventName::SessionStart,
|
||||
HookEventName::UserPromptSubmit,
|
||||
HookEventName::PreToolUse,
|
||||
HookEventName::PostToolUse,
|
||||
HookEventName::PostToolUseFailure,
|
||||
HookEventName::PermissionDenied,
|
||||
HookEventName::Stop,
|
||||
HookEventName::StopFailure,
|
||||
HookEventName::Notification,
|
||||
HookEventName::SubagentStart,
|
||||
HookEventName::SubagentStop,
|
||||
HookEventName::SubagentEnd,
|
||||
HookEventName::PreCompact,
|
||||
HookEventName::PostCompact,
|
||||
HookEventName::SessionEnd,
|
||||
];
|
||||
|
||||
pub fn all_hooks(&self) -> Vec<&HookSpec> {
|
||||
let mut all = Vec::new();
|
||||
for event in Self::ALL_EVENTS {
|
||||
for event in HookEventName::ALL {
|
||||
all.extend(self.hooks_for(*event));
|
||||
}
|
||||
all
|
||||
|
|
@ -125,8 +128,7 @@ impl HookRegistry {
|
|||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum HookSource<'a> {
|
||||
/// A JSON settings file (e.g. `~/.claude/settings.json`); only its `hooks`
|
||||
/// key is used.
|
||||
/// A JSON settings file; only its `hooks` key is used.
|
||||
SettingsFile(&'a Path),
|
||||
/// A directory of `*.json` hook files (e.g. `~/.grok/hooks/`).
|
||||
Directory(&'a Path),
|
||||
|
|
@ -140,6 +142,35 @@ pub fn load_hooks_from_sources(
|
|||
global_sources: &[HookSource<'_>],
|
||||
project_sources: &[HookSource<'_>],
|
||||
) -> (HookRegistry, Vec<HookError>) {
|
||||
let (specs, errors) = collect_specs_from_sources(global_sources, project_sources);
|
||||
let registry = registry_from_specs_deduped(specs);
|
||||
tracing::info!(
|
||||
total_hooks = registry.len(),
|
||||
session_start = registry.hooks_for(HookEventName::SessionStart).len(),
|
||||
pre_tool = registry.hooks_for(HookEventName::PreToolUse).len(),
|
||||
post_tool = registry.hooks_for(HookEventName::PostToolUse).len(),
|
||||
session_end = registry.hooks_for(HookEventName::SessionEnd).len(),
|
||||
stop = registry.hooks_for(HookEventName::Stop).len(),
|
||||
notification = registry.hooks_for(HookEventName::Notification).len(),
|
||||
user_prompt_submit = registry.hooks_for(HookEventName::UserPromptSubmit).len(),
|
||||
subagent_start = registry.hooks_for(HookEventName::SubagentStart).len(),
|
||||
subagent_stop = registry.hooks_for(HookEventName::SubagentStop).len()
|
||||
+ registry.hooks_for(HookEventName::SubagentEnd).len(),
|
||||
"hooks: discovery complete"
|
||||
);
|
||||
|
||||
(registry, errors)
|
||||
}
|
||||
|
||||
/// Load hook specs from global and project sources WITHOUT deduplicating, so a
|
||||
/// caller can combine them with specs from other origins (e.g. config layers) and
|
||||
/// run a single dedup pass. Global specs are prefixed `global/` and project specs
|
||||
/// `project/`; global specs precede project specs so a later first-wins dedup
|
||||
/// keeps the global copy of an identical duplicate.
|
||||
pub fn collect_specs_from_sources(
|
||||
global_sources: &[HookSource<'_>],
|
||||
project_sources: &[HookSource<'_>],
|
||||
) -> (Vec<HookSpec>, Vec<HookError>) {
|
||||
tracing::debug!(
|
||||
global_sources = global_sources.len(),
|
||||
project_sources = project_sources.len(),
|
||||
|
|
@ -152,7 +183,7 @@ pub fn load_hooks_from_sources(
|
|||
for source in global_sources {
|
||||
let (mut specs, errors) = load_from_source(source);
|
||||
for spec in &mut specs {
|
||||
spec.name = format!("global/{}", spec.name);
|
||||
spec.name = format!("{}{}", crate::config::GLOBAL_HOOK_PREFIX, spec.name);
|
||||
}
|
||||
tracing::debug!(
|
||||
source = ?source,
|
||||
|
|
@ -166,7 +197,7 @@ pub fn load_hooks_from_sources(
|
|||
for source in project_sources {
|
||||
let (mut specs, errors) = load_from_source(source);
|
||||
for spec in &mut specs {
|
||||
spec.name = format!("project/{}", spec.name);
|
||||
spec.name = format!("{}{}", crate::config::PROJECT_HOOK_PREFIX, spec.name);
|
||||
}
|
||||
tracing::debug!(
|
||||
source = ?source,
|
||||
|
|
@ -177,15 +208,18 @@ pub fn load_hooks_from_sources(
|
|||
all_errors.extend(errors);
|
||||
}
|
||||
|
||||
// Deduplicate across sources on (canonical event, command_raw, url_raw,
|
||||
// configured_matcher) so a hook defined in several sources runs once, while
|
||||
// hooks sharing a command/URL but differing by matcher all still run. The
|
||||
// canonical event collapses aliases (`SubagentStop`/`SubagentEnd`). Global
|
||||
// hooks win because they are loaded first.
|
||||
(all_specs, all_errors)
|
||||
}
|
||||
|
||||
/// Build a registry from specs, deduping on (canonical event, command_raw,
|
||||
/// url_raw, configured_matcher) so a hook from several origins runs once; earlier
|
||||
/// specs win, so callers place higher-authority first. `timeout_ms`/`extra_env`
|
||||
/// are intentionally excluded from the key.
|
||||
pub fn registry_from_specs_deduped(specs: Vec<HookSpec>) -> HookRegistry {
|
||||
let mut hooks: HashMap<HookEventName, Vec<HookSpec>> = HashMap::new();
|
||||
let mut seen_content: std::collections::HashSet<(HookEventName, String, String, String)> =
|
||||
std::collections::HashSet::new();
|
||||
for spec in all_specs {
|
||||
for spec in specs {
|
||||
let key = (
|
||||
spec.event.canonical(),
|
||||
spec.command_raw.clone().unwrap_or_default(),
|
||||
|
|
@ -203,24 +237,7 @@ pub fn load_hooks_from_sources(
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
let registry = HookRegistry { hooks };
|
||||
tracing::info!(
|
||||
total_hooks = registry.len(),
|
||||
session_start = registry.hooks_for(HookEventName::SessionStart).len(),
|
||||
pre_tool = registry.hooks_for(HookEventName::PreToolUse).len(),
|
||||
post_tool = registry.hooks_for(HookEventName::PostToolUse).len(),
|
||||
session_end = registry.hooks_for(HookEventName::SessionEnd).len(),
|
||||
stop = registry.hooks_for(HookEventName::Stop).len(),
|
||||
notification = registry.hooks_for(HookEventName::Notification).len(),
|
||||
user_prompt_submit = registry.hooks_for(HookEventName::UserPromptSubmit).len(),
|
||||
subagent_start = registry.hooks_for(HookEventName::SubagentStart).len(),
|
||||
subagent_stop = registry.hooks_for(HookEventName::SubagentStop).len()
|
||||
+ registry.hooks_for(HookEventName::SubagentEnd).len(),
|
||||
"hooks: discovery complete"
|
||||
);
|
||||
|
||||
(registry, all_errors)
|
||||
HookRegistry { hooks }
|
||||
}
|
||||
|
||||
/// Convenience wrapper: load hooks from a single global directory and optional
|
||||
|
|
@ -364,60 +381,14 @@ mod tests {
|
|||
.to_string()
|
||||
}
|
||||
|
||||
/// Drift guard for the hand-maintained `ALL_EVENTS`: a new `HookEventName`
|
||||
/// variant breaks the exhaustive match below, then fails the assertion until
|
||||
/// it is added to `ALL_EVENTS`, so no event vanishes from the flat listing.
|
||||
#[test]
|
||||
fn all_events_lists_every_variant() {
|
||||
let every_variant = [
|
||||
HookEventName::SessionStart,
|
||||
HookEventName::UserPromptSubmit,
|
||||
HookEventName::PreToolUse,
|
||||
HookEventName::PostToolUse,
|
||||
HookEventName::PostToolUseFailure,
|
||||
HookEventName::PermissionDenied,
|
||||
HookEventName::Stop,
|
||||
HookEventName::StopFailure,
|
||||
HookEventName::Notification,
|
||||
HookEventName::SubagentStart,
|
||||
HookEventName::SubagentStop,
|
||||
HookEventName::SubagentEnd,
|
||||
HookEventName::PreCompact,
|
||||
HookEventName::PostCompact,
|
||||
HookEventName::SessionEnd,
|
||||
];
|
||||
for event in every_variant {
|
||||
match event {
|
||||
HookEventName::SessionStart
|
||||
| HookEventName::UserPromptSubmit
|
||||
| HookEventName::PreToolUse
|
||||
| HookEventName::PostToolUse
|
||||
| HookEventName::PostToolUseFailure
|
||||
| HookEventName::PermissionDenied
|
||||
| HookEventName::Stop
|
||||
| HookEventName::StopFailure
|
||||
| HookEventName::Notification
|
||||
| HookEventName::SubagentStart
|
||||
| HookEventName::SubagentStop
|
||||
| HookEventName::SubagentEnd
|
||||
| HookEventName::PreCompact
|
||||
| HookEventName::PostCompact
|
||||
| HookEventName::SessionEnd => {}
|
||||
}
|
||||
assert!(
|
||||
HookRegistry::ALL_EVENTS.contains(&event),
|
||||
"{event} is missing from ALL_EVENTS"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drift guard: gate events must match the `blockingEvents` the agent
|
||||
/// advertises (extensions/hooks.rs). A new gate event fails here.
|
||||
#[test]
|
||||
fn gate_events_are_the_known_set() {
|
||||
use crate::event::GateKind;
|
||||
// Canonicalize first: `traits()` is unreachable on alias variants.
|
||||
let gates: std::collections::HashSet<_> = HookRegistry::ALL_EVENTS
|
||||
// Canonicalize first to dedup alias spellings into one set entry
|
||||
// (`traits()` itself already canonicalizes, so it's safe on aliases).
|
||||
let gates: std::collections::HashSet<_> = HookEventName::ALL
|
||||
.iter()
|
||||
.map(|e| e.canonical())
|
||||
.filter(|e| e.traits().gate != GateKind::Observe)
|
||||
|
|
@ -923,6 +894,7 @@ mod tests {
|
|||
timeout_ms: 5_000,
|
||||
source_dir: PathBuf::from("/tmp"),
|
||||
extra_env: Default::default(),
|
||||
layer: crate::config::HookProvenance::File,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -551,6 +551,7 @@ mod tests {
|
|||
timeout_ms: 5000,
|
||||
source_dir: PathBuf::from("/tmp"),
|
||||
extra_env: HashMap::new(),
|
||||
layer: crate::config::HookProvenance::File,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,112 +3,187 @@ use serde::Serialize;
|
|||
/// Maximum serialized size for `toolInput` or `toolResult` in bytes (128 KB).
|
||||
pub const MAX_PAYLOAD_SIZE: usize = 128 * 1024;
|
||||
|
||||
/// Hook event types.
|
||||
///
|
||||
/// Deserialization accepts PascalCase, snake_case, camelCase, and per-operation
|
||||
/// aliases (e.g. `beforeShellExecution` maps to `PreToolUse`); see the `Deserialize` impl.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HookEventName {
|
||||
SessionStart,
|
||||
SessionEnd,
|
||||
/// Generates [`HookEventName`] and its `Deserialize`/`parse_key`, `Display`,
|
||||
/// `traits()`, and `ALL` from one table, so adding an event is a single row.
|
||||
/// Per row: `display` is the canonical rendering (may differ from the variant's
|
||||
/// snake_case, e.g. `SubagentEnd` -> `subagent_stop`); `aliases` are the exact
|
||||
/// `Deserialize` spellings (disjoint across variants); `traits` is the
|
||||
/// `(gate, matcher, hub)` triple. `Serialize` stays derived snake_case (wire unchanged).
|
||||
macro_rules! hook_events {
|
||||
($(
|
||||
$(#[$vmeta:meta])*
|
||||
$variant:ident {
|
||||
display: $display:literal,
|
||||
aliases: [$($alias:literal),* $(,)?],
|
||||
traits: ($gate:ident, $matcher:ident, $hub:literal $(,)?),
|
||||
}
|
||||
),* $(,)?) => {
|
||||
/// Hook event types. `Ord` follows table order (stable, keeps the
|
||||
/// `SubagentStop`/`SubagentEnd` aliases distinct unlike `Display`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HookEventName {
|
||||
$($(#[$vmeta])* $variant),*
|
||||
}
|
||||
|
||||
impl HookEventName {
|
||||
/// Every variant, in canonical display order.
|
||||
pub const ALL: &'static [HookEventName] = &[$(HookEventName::$variant),*];
|
||||
|
||||
/// Source of truth for known spellings, behind `Deserialize` and `parse_key`.
|
||||
fn from_key_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
$($($alias)|* => Some(Self::$variant),)*
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The event's dispatch traits, generated exhaustively from the table.
|
||||
pub fn traits(self) -> EventTraits {
|
||||
use GateKind::*;
|
||||
use MatcherPolicy::*;
|
||||
match self {
|
||||
$(Self::$variant => EventTraits {
|
||||
gate: $gate,
|
||||
matcher: $matcher,
|
||||
hub_forward: $hub,
|
||||
},)*
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HookEventName {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self { $(Self::$variant => $display,)* })
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for HookEventName {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let s = <String as serde::Deserialize>::deserialize(deserializer)?;
|
||||
Self::from_key_str(&s).ok_or_else(|| {
|
||||
// Built from the table so it can't drift from the accepted set.
|
||||
let known = Self::ALL
|
||||
.iter()
|
||||
.map(|e| e.to_string())
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
serde::de::Error::custom(format!(
|
||||
"unknown hook event: '{s}'. Expected one of: {known} \
|
||||
(camelCase and per-operation aliases such as \
|
||||
beforeShellExecution are also accepted)"
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Table order is the canonical display order (drives `ALL` and `Ord`).
|
||||
// Per-operation aliases map to generic `PreToolUse`/`PostToolUse`.
|
||||
hook_events! {
|
||||
SessionStart {
|
||||
display: "session_start",
|
||||
aliases: ["SessionStart", "session_start", "sessionStart"],
|
||||
traits: (Observe, Tested, true),
|
||||
},
|
||||
UserPromptSubmit {
|
||||
display: "user_prompt_submit",
|
||||
aliases: ["UserPromptSubmit", "user_prompt_submit", "beforeSubmitPrompt"],
|
||||
traits: (Observe, Ignored, true),
|
||||
},
|
||||
PreToolUse {
|
||||
display: "pre_tool_use",
|
||||
aliases: [
|
||||
"PreToolUse",
|
||||
"pre_tool_use",
|
||||
"preToolUse",
|
||||
"beforeShellExecution",
|
||||
"beforeMCPExecution",
|
||||
"beforeReadFile",
|
||||
],
|
||||
traits: (Tool, Tested, false),
|
||||
},
|
||||
PostToolUse {
|
||||
display: "post_tool_use",
|
||||
aliases: [
|
||||
"PostToolUse",
|
||||
"post_tool_use",
|
||||
"postToolUse",
|
||||
"afterShellExecution",
|
||||
"afterMCPExecution",
|
||||
"afterFileEdit",
|
||||
"afterAgentResponse",
|
||||
"afterAgentThought",
|
||||
],
|
||||
traits: (Observe, Tested, true),
|
||||
},
|
||||
PostToolUseFailure {
|
||||
display: "post_tool_use_failure",
|
||||
aliases: ["PostToolUseFailure", "post_tool_use_failure", "postToolUseFailure"],
|
||||
traits: (Observe, Tested, true),
|
||||
},
|
||||
PermissionDenied {
|
||||
display: "permission_denied",
|
||||
aliases: ["PermissionDenied", "permission_denied", "permissionDenied"],
|
||||
traits: (Observe, Tested, true),
|
||||
},
|
||||
/// Fires on a genuine turn-end with stop decision control (a hook can block);
|
||||
/// not on user interrupts (API-error turns fire `StopFailure`); observe-only at session end.
|
||||
Stop,
|
||||
Stop {
|
||||
display: "stop",
|
||||
aliases: ["Stop", "stop"],
|
||||
traits: (Stop, Ignored, true),
|
||||
},
|
||||
/// Fires when the turn ends due to an API error. Output and exit code are ignored.
|
||||
StopFailure,
|
||||
|
||||
PreToolUse,
|
||||
PostToolUse,
|
||||
PostToolUseFailure,
|
||||
PermissionDenied,
|
||||
|
||||
UserPromptSubmit,
|
||||
Notification,
|
||||
|
||||
SubagentStart,
|
||||
SubagentStop,
|
||||
SubagentEnd,
|
||||
|
||||
PreCompact,
|
||||
PostCompact,
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for HookEventName {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
match s.as_str() {
|
||||
// PascalCase (native) + snake_case + camelCase (third-party compat).
|
||||
// Per-operation hook names (beforeShellExecution, afterFileEdit, etc.)
|
||||
// map to our generic PreToolUse/PostToolUse; the hook script receives the
|
||||
// tool name in JSON input and can filter, or use the `matcher` field.
|
||||
"SessionStart" | "session_start" | "sessionStart" => Ok(Self::SessionStart),
|
||||
"PreToolUse"
|
||||
| "pre_tool_use"
|
||||
| "preToolUse"
|
||||
| "beforeShellExecution"
|
||||
| "beforeMCPExecution"
|
||||
| "beforeReadFile" => Ok(Self::PreToolUse),
|
||||
"PostToolUse"
|
||||
| "post_tool_use"
|
||||
| "postToolUse"
|
||||
| "afterShellExecution"
|
||||
| "afterMCPExecution"
|
||||
| "afterFileEdit"
|
||||
| "afterAgentResponse"
|
||||
| "afterAgentThought" => Ok(Self::PostToolUse),
|
||||
"PostToolUseFailure" | "post_tool_use_failure" | "postToolUseFailure" => {
|
||||
Ok(Self::PostToolUseFailure)
|
||||
}
|
||||
"SessionEnd" | "session_end" | "sessionEnd" => Ok(Self::SessionEnd),
|
||||
"Stop" | "stop" => Ok(Self::Stop),
|
||||
"StopFailure" | "stop_failure" | "stopFailure" => Ok(Self::StopFailure),
|
||||
"Notification" | "notification" => Ok(Self::Notification),
|
||||
"UserPromptSubmit" | "user_prompt_submit" | "beforeSubmitPrompt" => {
|
||||
Ok(Self::UserPromptSubmit)
|
||||
}
|
||||
"PermissionDenied" | "permission_denied" | "permissionDenied" => {
|
||||
Ok(Self::PermissionDenied)
|
||||
}
|
||||
"SubagentStart" | "subagent_start" | "subagentStart" => Ok(Self::SubagentStart),
|
||||
"SubagentStop" | "subagent_stop" | "subagentStop" => Ok(Self::SubagentStop),
|
||||
"SubagentEnd" | "subagent_end" | "subagentEnd" => Ok(Self::SubagentEnd),
|
||||
"PreCompact" | "pre_compact" | "preCompact" => Ok(Self::PreCompact),
|
||||
"PostCompact" | "post_compact" | "postCompact" => Ok(Self::PostCompact),
|
||||
other => Err(serde::de::Error::custom(format!(
|
||||
"unknown hook event: '{other}'. Expected one of: \
|
||||
SessionStart, PreToolUse, PostToolUse, PostToolUseFailure, \
|
||||
SessionEnd, Stop, StopFailure, Notification, UserPromptSubmit, \
|
||||
PermissionDenied, SubagentStart, SubagentStop, \
|
||||
PreCompact, PostCompact (camelCase and per-operation aliases \
|
||||
such as beforeShellExecution are also accepted)"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HookEventName {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::SessionStart => write!(f, "session_start"),
|
||||
Self::PreToolUse => write!(f, "pre_tool_use"),
|
||||
Self::PostToolUse => write!(f, "post_tool_use"),
|
||||
Self::PostToolUseFailure => write!(f, "post_tool_use_failure"),
|
||||
Self::SessionEnd => write!(f, "session_end"),
|
||||
Self::Stop => write!(f, "stop"),
|
||||
Self::StopFailure => write!(f, "stop_failure"),
|
||||
Self::Notification => write!(f, "notification"),
|
||||
Self::UserPromptSubmit => write!(f, "user_prompt_submit"),
|
||||
Self::PermissionDenied => write!(f, "permission_denied"),
|
||||
Self::SubagentStart => write!(f, "subagent_start"),
|
||||
Self::SubagentStop | Self::SubagentEnd => write!(f, "subagent_stop"),
|
||||
Self::PreCompact => write!(f, "pre_compact"),
|
||||
Self::PostCompact => write!(f, "post_compact"),
|
||||
}
|
||||
}
|
||||
StopFailure {
|
||||
display: "stop_failure",
|
||||
aliases: ["StopFailure", "stop_failure", "stopFailure"],
|
||||
traits: (Observe, Tested, true),
|
||||
},
|
||||
Notification {
|
||||
display: "notification",
|
||||
aliases: ["Notification", "notification"],
|
||||
traits: (Observe, Tested, true),
|
||||
},
|
||||
SubagentStart {
|
||||
display: "subagent_start",
|
||||
aliases: ["SubagentStart", "subagent_start", "subagentStart"],
|
||||
traits: (Observe, Tested, true),
|
||||
},
|
||||
SubagentStop {
|
||||
display: "subagent_stop",
|
||||
aliases: ["SubagentStop", "subagent_stop", "subagentStop"],
|
||||
traits: (Stop, Tested, true),
|
||||
},
|
||||
/// Legacy alias of `SubagentStop`: kept as a distinct variant so a hook
|
||||
/// registered under either spelling round-trips, then collapsed via
|
||||
/// [`HookEventName::canonical`] for dispatch and dedup.
|
||||
SubagentEnd {
|
||||
display: "subagent_stop",
|
||||
aliases: ["SubagentEnd", "subagent_end", "subagentEnd"],
|
||||
traits: (Stop, Tested, true),
|
||||
},
|
||||
PreCompact {
|
||||
display: "pre_compact",
|
||||
aliases: ["PreCompact", "pre_compact", "preCompact"],
|
||||
traits: (Observe, Tested, true),
|
||||
},
|
||||
PostCompact {
|
||||
display: "post_compact",
|
||||
aliases: ["PostCompact", "post_compact", "postCompact"],
|
||||
traits: (Observe, Tested, true),
|
||||
},
|
||||
SessionEnd {
|
||||
display: "session_end",
|
||||
aliases: ["SessionEnd", "session_end", "sessionEnd"],
|
||||
traits: (Observe, Tested, true),
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
|
@ -137,9 +212,8 @@ pub struct EventTraits {
|
|||
}
|
||||
|
||||
impl HookEventName {
|
||||
/// Collapse alias variants to their canonical form so a registration and the fired
|
||||
/// event meet on one key regardless of which spelling each used (`SubagentEnd` is an
|
||||
/// alias of `SubagentStop`).
|
||||
/// Collapse aliases so a registration and the fired event meet on one key
|
||||
/// (`SubagentEnd` is an alias of `SubagentStop`).
|
||||
pub fn canonical(self) -> Self {
|
||||
match self {
|
||||
Self::SubagentEnd => Self::SubagentStop,
|
||||
|
|
@ -147,33 +221,9 @@ impl HookEventName {
|
|||
}
|
||||
}
|
||||
|
||||
/// The event's dispatch traits. Exhaustive on purpose: a new variant fails to
|
||||
/// compile until its gate, matcher, and hub forwarding are chosen here.
|
||||
pub fn traits(self) -> EventTraits {
|
||||
use GateKind::*;
|
||||
use MatcherPolicy::*;
|
||||
let t = |gate, matcher, hub_forward| EventTraits {
|
||||
gate,
|
||||
matcher,
|
||||
hub_forward,
|
||||
};
|
||||
match self.canonical() {
|
||||
Self::SessionStart => t(Observe, Tested, true),
|
||||
Self::SessionEnd => t(Observe, Tested, true),
|
||||
Self::Stop => t(Stop, Ignored, true),
|
||||
Self::StopFailure => t(Observe, Tested, true),
|
||||
Self::PreToolUse => t(Tool, Tested, false),
|
||||
Self::PostToolUse => t(Observe, Tested, true),
|
||||
Self::PostToolUseFailure => t(Observe, Tested, true),
|
||||
Self::PermissionDenied => t(Observe, Tested, true),
|
||||
Self::UserPromptSubmit => t(Observe, Ignored, true),
|
||||
Self::Notification => t(Observe, Tested, true),
|
||||
Self::SubagentStart => t(Observe, Tested, true),
|
||||
Self::SubagentStop => t(Stop, Tested, true),
|
||||
Self::SubagentEnd => unreachable!("canonicalized above"),
|
||||
Self::PreCompact => t(Observe, Tested, true),
|
||||
Self::PostCompact => t(Observe, Tested, true),
|
||||
}
|
||||
/// Validate a bare event key against the accepted spellings; `None` if unknown.
|
||||
pub fn parse_key(s: &str) -> Option<Self> {
|
||||
Self::from_key_str(s)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -834,6 +834,7 @@ mod tests {
|
|||
timeout_ms: 5000,
|
||||
source_dir: std::path::PathBuf::from(source),
|
||||
extra_env: std::collections::HashMap::new(),
|
||||
layer: crate::config::HookProvenance::File,
|
||||
};
|
||||
use crate::config::HandlerType;
|
||||
assert_eq!(
|
||||
|
|
@ -876,6 +877,7 @@ mod tests {
|
|||
timeout_ms: 5000,
|
||||
source_dir: std::env::temp_dir(),
|
||||
extra_env: std::collections::HashMap::new(),
|
||||
layer: crate::config::HookProvenance::File,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1038,6 +1040,7 @@ mod tests {
|
|||
timeout_ms: 5000,
|
||||
source_dir: tmp.path().to_path_buf(),
|
||||
extra_env,
|
||||
layer: crate::config::HookProvenance::File,
|
||||
};
|
||||
|
||||
let envelope = make_envelope();
|
||||
|
|
@ -1096,6 +1099,7 @@ mod tests {
|
|||
timeout_ms: 5000,
|
||||
source_dir: tmp.path().to_path_buf(),
|
||||
extra_env: std::collections::HashMap::new(),
|
||||
layer: crate::config::HookProvenance::File,
|
||||
};
|
||||
|
||||
let envelope = make_envelope();
|
||||
|
|
@ -1225,6 +1229,7 @@ mod tests {
|
|||
timeout_ms: 5000,
|
||||
source_dir: std::env::temp_dir(),
|
||||
extra_env,
|
||||
layer: crate::config::HookProvenance::File,
|
||||
};
|
||||
|
||||
let envelope = make_envelope();
|
||||
|
|
@ -1298,6 +1303,7 @@ mod tests {
|
|||
timeout_ms: 5000,
|
||||
source_dir: std::env::temp_dir(),
|
||||
extra_env,
|
||||
layer: crate::config::HookProvenance::File,
|
||||
};
|
||||
|
||||
let envelope = make_envelope();
|
||||
|
|
@ -1363,6 +1369,7 @@ mod tests {
|
|||
timeout_ms: 5000,
|
||||
source_dir: tmp.path().to_path_buf(),
|
||||
extra_env: std::collections::HashMap::new(),
|
||||
layer: crate::config::HookProvenance::File,
|
||||
};
|
||||
|
||||
let envelope = make_envelope();
|
||||
|
|
|
|||
|
|
@ -649,6 +649,7 @@ mod tests {
|
|||
timeout_ms: 1000,
|
||||
source_dir: std::env::temp_dir(),
|
||||
extra_env,
|
||||
layer: crate::config::HookProvenance::File,
|
||||
};
|
||||
|
||||
let envelope = HookEventEnvelope {
|
||||
|
|
@ -727,6 +728,7 @@ mod tests {
|
|||
timeout_ms: 500,
|
||||
source_dir: std::env::temp_dir(),
|
||||
extra_env,
|
||||
layer: crate::config::HookProvenance::File,
|
||||
};
|
||||
let envelope = HookEventEnvelope {
|
||||
hook_event_name: HookEventName::PreToolUse,
|
||||
|
|
|
|||
Loading…
Reference in a new issue