Synced from monorepo

Changes:
- Stop hooks for session lifecycle
- Add x.ai/session/state and x.ai/session/import ACP methods
- Deny-and-continue for auto-mode classifier blocks with denial limits
- Drop codebase-upload from dhat soak test
- scheduler_create upsert via task_id; retire one-shot tasks
- Clipboard: copy file fallback + honest toasts for SSH/Apple Terminal
- Polarity-safe syntax colors in minimal mode
- Auto mode classifies unvetted env prefixes instead of hard-prompting
- Add GROK_CLIPBOARD_NO_OSC52 kill switch to force OSC 52 off
This commit is contained in:
grokkybara[bot] 2026-07-19 18:40:33 +01:00
commit ba76b0a683
143 changed files with 9465 additions and 3419 deletions

View file

@ -73,6 +73,20 @@ cp examples/hooks/bin/tool-logger.sh ~/.grok/hooks/bin/
chmod +x ~/.grok/hooks/bin/tool-logger.sh
```
### 5. Stop Gate: verify before finishing (`stop-verify.json`)
**Type:** blocking (`Stop`)
Keeps the agent working until `cargo build` passes. A `Stop` hook runs when the agent is about to finish its turn; returning `{"decision":"block","reason":"…"}` feeds the reason back to the model and runs another round. The built-in cap ends the turn after 8 continuations. The hook sets a 300-second timeout because a timed-out Stop hook fails open and lets the agent stop.
**Install:**
```sh
mkdir -p ~/.grok/hooks/bin
cp examples/hooks/stop-verify.json ~/.grok/hooks/
cp examples/hooks/bin/stop-verify.sh ~/.grok/hooks/bin/
chmod +x ~/.grok/hooks/bin/stop-verify.sh
```
## Format
Hook files use the Claude-compatible JSON format:
@ -92,7 +106,7 @@ Hook files use the Claude-compatible JSON format:
}
```
- **Event names:** `SessionStart`, `PreToolUse`, `PostToolUse`, `SessionEnd`
- **Event names:** `SessionStart`, `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStop`, `SessionEnd` (see the [user guide](../../xai-grok-pager/docs/user-guide/10-hooks.md) for the full set)
- **Matcher:** regex on tool name. Claude names like `Bash`, `Read`, `Edit` are auto-expanded to also match Grok names (`run_terminal_cmd`, `read_file`, `search_replace`)
- **Timeout:** in seconds (default: 5)
- **Command:** path to script (relative to hook file directory) or inline shell command
@ -101,7 +115,7 @@ Hook files use the Claude-compatible JSON format:
Scripts receive the hook event envelope as JSON on **stdin** and should write a response to **stdout**:
**For blocking hooks (`PreToolUse`):**
**For tool gates (`PreToolUse`):**
```json
{"decision":"allow"}
```
@ -110,7 +124,19 @@ or
{"decision":"deny","reason":"Explanation for the user"}
```
**Exit codes:** `0` = allow, `2` = deny, other = fail-open.
**For stop gates (`Stop` / `SubagentStop`):** keep the agent working or force it to stop:
```json
{"decision":"block","reason":"Feedback fed back to the model"}
```
```json
{"hookSpecificOutput":{"hookEventName":"Stop","additionalContext":"Non-error feedback"}}
```
```json
{"continue":false,"stopReason":"Shown to the user; overrides any block"}
```
The turn ends after 8 consecutive continuations. The input carries `stopHookActive` (true once a block has already continued this turn) so a hook can give up.
**Exit codes:** `0` = allow / no decision, `2` = deny (`PreToolUse`) or block-stop with stderr as the feedback, other = fail-open. Valid decision JSON on stdout wins over the exit code.
**For passive hooks:** stdout is informational only. Exit `0` for success.

View file

@ -0,0 +1,24 @@
#!/bin/sh
# stop-verify.sh — keep the agent working until the build passes.
#
# A Stop hook runs when the agent is about to finish its turn. Emitting a
# block decision feeds the reason back to the model and runs another round;
# the built-in cap ends the turn after 8 continuations. Set a generous
# timeout on the hook (see stop-verify.json), since a timed-out hook fails
# open and lets the agent stop.
INPUT=$(cat)
# Gate only genuine turn ends, not the observe-only session-end fire.
REASON=$(echo "$INPUT" | grep -o '"reason":"[^"]*"' | sed 's/"reason":"//;s/"$//')
if [ "$REASON" != "end_turn" ]; then
exit 0
fi
if cargo build --quiet >/dev/null 2>&1; then
# Build is green: allow the stop.
exit 0
fi
# Build is red: keep the agent working, with the failure as feedback.
echo '{"decision":"block","reason":"cargo build failed; fix the errors before finishing."}'

View file

@ -0,0 +1,11 @@
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "bin/stop-verify.sh", "timeout": 300 }
]
}
]
}
}

File diff suppressed because it is too large Load diff

View file

@ -18,29 +18,51 @@ pub struct HookRegistry {
}
impl HookRegistry {
/// Returns the hooks registered for the given event type.
/// Hooks registered under the exact event key. Use
/// [`Self::hooks_for_canonical`] for dispatch.
pub fn hooks_for(&self, event: HookEventName) -> &[HookSpec] {
self.hooks.get(&event).map(|v| v.as_slice()).unwrap_or(&[])
}
/// Returns true if the registry contains no hooks at all.
/// Returns true when any enabled hook is registered for `event` or its
/// alias spelling. Allocation-free guard for hot paths.
pub fn has_enabled_hooks_for_canonical(&self, event: HookEventName) -> bool {
let enabled = |specs: &[HookSpec]| {
specs
.iter()
.any(|s| s.enabled && !crate::trust::is_hook_disabled(&s.name))
};
let canonical = event.canonical();
enabled(self.hooks_for(canonical))
|| (canonical == HookEventName::SubagentStop
&& enabled(self.hooks_for(HookEventName::SubagentEnd)))
}
/// Hooks for `event` plus any registered under an alias spelling
/// (`SubagentEnd` ≡ `SubagentStop`), so dispatch treats both identically.
pub fn hooks_for_canonical(&self, event: HookEventName) -> Vec<&HookSpec> {
let canonical = event.canonical();
let mut out: Vec<&HookSpec> = self.hooks_for(canonical).iter().collect();
if canonical == HookEventName::SubagentStop {
out.extend(self.hooks_for(HookEventName::SubagentEnd));
}
out
}
pub fn is_empty(&self) -> bool {
self.hooks.values().all(|v| v.is_empty())
}
/// Returns the total number of hooks across all event types.
pub fn len(&self) -> usize {
self.hooks.values().map(|v| v.len()).sum()
}
/// Append additional hook specs into this registry.
pub fn append_specs(&mut self, specs: Vec<HookSpec>) {
for spec in specs {
self.hooks.entry(spec.event).or_default().push(spec);
}
}
/// Remove all hook specs whose name starts with the given prefix.
pub fn remove_by_prefix(&mut self, prefix: &str) {
for specs in self.hooks.values_mut() {
specs.retain(|s| !s.name.starts_with(prefix));
@ -66,7 +88,6 @@ impl HookRegistry {
HookEventName::SessionEnd,
];
/// Returns all hooks as a flat list, ordered by event type then position.
pub fn all_hooks(&self) -> Vec<&HookSpec> {
let mut all = Vec::new();
for event in Self::ALL_EVENTS {
@ -75,21 +96,10 @@ impl HookRegistry {
all
}
/// Recompile the `matcher` field on every [`HookSpec`] from its
/// `configured_matcher` pattern string.
///
/// After deserialization the compiled [`HookMatcher`] is `None`
/// (`#[serde(skip)]`). This rebuilds it via [`HookMatcher::new`].
///
/// Specs whose `configured_matcher` is `None` (intentional match-all)
/// are left untouched. Invalid patterns cannot be rejected the way the
/// parse path does (`HookError::InvalidMatcher` + skip the hook): the
/// registry is already live, so we install [`HookMatcher::never`]
/// instead: fail closed rather than widening to match all.
///
/// Call this after any serde / wire restore (e.g. workspace proxy
/// `wire_to_hook_registry`). Until then, a configured pattern with
/// `matcher: None` behaves as match-all.
/// Rebuild the `matcher` field (serde skips it) from `configured_matcher`
/// after any wire restore; until then a configured pattern acts as match-all.
/// An invalid pattern can't be rejected here (the registry is live), so it
/// installs [`HookMatcher::never`]: fail closed rather than match all.
pub fn recompile_matchers(&mut self) {
for specs in self.hooks.values_mut() {
for spec in specs.iter_mut() {
@ -113,11 +123,10 @@ impl HookRegistry {
}
}
/// A hook source: either a single settings file or a directory of hook files.
#[derive(Debug, Clone)]
pub enum HookSource<'a> {
/// A single JSON settings file (e.g. `~/.claude/settings.json`).
/// The `hooks` key is extracted; other keys are ignored.
/// A JSON settings file (e.g. `~/.claude/settings.json`); only its `hooks`
/// key is used.
SettingsFile(&'a Path),
/// A directory of `*.json` hook files (e.g. `~/.grok/hooks/`).
Directory(&'a Path),
@ -125,12 +134,8 @@ pub enum HookSource<'a> {
/// Load hooks from global and project sources.
///
/// Sources are additive: hooks from all sources are merged into a single
/// registry. Global hooks run before project hooks. Within each scope,
/// earlier sources execute before later sources.
///
/// Returns the registry plus any non-fatal load errors.
/// A fully empty registry is valid (no-op when no hooks are configured).
/// Sources are additive; global hooks run before project. An empty registry is
/// valid.
pub fn load_hooks_from_sources(
global_sources: &[HookSource<'_>],
project_sources: &[HookSource<'_>],
@ -144,7 +149,6 @@ pub fn load_hooks_from_sources(
let mut all_specs = Vec::new();
let mut all_errors = Vec::new();
// Load global hooks first (precedence order: global, then project).
for source in global_sources {
let (mut specs, errors) = load_from_source(source);
for spec in &mut specs {
@ -159,7 +163,6 @@ pub fn load_hooks_from_sources(
all_errors.extend(errors);
}
// Load project hooks second.
for source in project_sources {
let (mut specs, errors) = load_from_source(source);
for spec in &mut specs {
@ -174,22 +177,17 @@ pub fn load_hooks_from_sources(
all_errors.extend(errors);
}
// Index by event type, deduplicating by hook content (command/url) +
// matcher across all sources. This prevents the same hook from executing
// multiple times when it's defined in multiple sources (e.g., ~/.grok/hooks/ +
// ~/.claude/settings.json + ~/.cursor/hooks.json), while still allowing
// hooks that share a command/URL but have different matchers (e.g. tool-scoped
// hooks) to all run.
//
// Deduplication key: (event, command_raw, url_raw, configured_matcher).
// Hooks with identical content + matcher are deduplicated regardless of
// source. Global hooks take precedence because they're loaded first.
// 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.
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 {
let key = (
spec.event,
spec.event.canonical(),
spec.command_raw.clone().unwrap_or_default(),
spec.url_raw.clone().unwrap_or_default(),
spec.configured_matcher.clone().unwrap_or_default(),
@ -236,7 +234,6 @@ pub fn load_hooks(
load_hooks_from_sources(&global, &project)
}
/// Load hooks from a single source (settings file or directory).
fn load_from_source(source: &HookSource<'_>) -> (Vec<HookSpec>, Vec<HookError>) {
match source {
HookSource::SettingsFile(path) => load_hooks_from_settings_file(path),
@ -244,16 +241,14 @@ fn load_from_source(source: &HookSource<'_>) -> (Vec<HookSpec>, Vec<HookError>)
}
}
/// Load hooks from a single JSON settings file.
///
/// Reads the file, extracts the `hooks` key, and parses it. If the file
/// does not exist or has no `hooks` key, returns empty results (not an error).
/// Load hooks from a single JSON settings file. A missing file or absent
/// `hooks` key returns empty results, not an error.
fn load_hooks_from_settings_file(path: &Path) -> (Vec<HookSpec>, Vec<HookError>) {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
if e.kind() == std::io::ErrorKind::NotFound {
return (Vec::new(), Vec::new()); // Missing file is fine.
return (Vec::new(), Vec::new());
}
return (
Vec::new(),
@ -272,11 +267,6 @@ fn load_hooks_from_settings_file(path: &Path) -> (Vec<HookSpec>, Vec<HookError>)
(specs, errors)
}
/// Load hooks from a single directory.
///
/// - Only loads `*.json` files.
/// - Ignores hidden/temp/editor files (dotfiles, `~`-suffixed, `.swp`).
/// - Sorts files lexicographically for deterministic ordering.
fn load_hooks_from_directory(dir: &Path) -> (Vec<HookSpec>, Vec<HookError>) {
let mut specs = Vec::new();
let mut errors = Vec::new();
@ -284,7 +274,6 @@ fn load_hooks_from_directory(dir: &Path) -> (Vec<HookSpec>, Vec<HookError>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) => {
// Missing directory is not an error — it just means no hooks.
if e.kind() == std::io::ErrorKind::NotFound {
return (specs, errors);
}
@ -296,7 +285,6 @@ fn load_hooks_from_directory(dir: &Path) -> (Vec<HookSpec>, Vec<HookError>) {
}
};
// Collect and sort file paths lexicographically.
let mut json_files: Vec<std::path::PathBuf> = Vec::new();
for entry in entries {
let entry = match entry {
@ -318,7 +306,6 @@ fn load_hooks_from_directory(dir: &Path) -> (Vec<HookSpec>, Vec<HookError>) {
}
json_files.sort();
// Parse each file.
for path in json_files {
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
@ -348,22 +335,15 @@ fn is_valid_hook_file(path: &Path) -> bool {
return false;
};
// Must have .json extension.
if path.extension().and_then(|e| e.to_str()) != Some("json") {
return false;
}
// Skip hidden files (dotfiles).
if name.starts_with('.') {
return false;
}
// Skip editor temp files.
if name.ends_with('~') || name.ends_with(".swp") || name.ends_with(".swo") {
return false;
}
// Must be a file, not a directory.
path.is_file()
}
@ -375,14 +355,11 @@ mod tests {
std::fs::write(dir.join(name), content).unwrap();
}
/// Create a simple compatible-format JSON hook file for the given event.
/// The `unique_id` parameter ensures each hook has a unique command,
/// preventing deduplication when testing multiple files.
fn simple_hook(event: &str) -> String {
simple_hook_with_id(event, "test")
}
/// Create a simple compatible-format JSON hook file with a unique command.
/// A hook file whose command is keyed by `id`, so distinct ids avoid dedup.
fn simple_hook_with_id(event: &str, id: &str) -> String {
serde_json::json!({
"hooks": {
@ -392,6 +369,74 @@ 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
.iter()
.map(|e| e.canonical())
.filter(|e| e.traits().gate != GateKind::Observe)
.collect();
let expected: std::collections::HashSet<_> = [
HookEventName::PreToolUse,
HookEventName::Stop,
HookEventName::SubagentStop,
]
.into_iter()
.collect();
assert_eq!(gates, expected, "gate events changed");
}
#[test]
fn load_empty_dirs() {
let dir = tempfile::tempdir().unwrap();
@ -401,13 +446,6 @@ mod tests {
assert_eq!(registry.len(), 0);
}
#[test]
fn load_missing_dirs() {
let (registry, errors) = load_hooks(None, None);
assert!(errors.is_empty());
assert!(registry.is_empty());
}
#[test]
fn load_nonexistent_dir() {
let (registry, errors) = load_hooks(Some(Path::new("/nonexistent/path/hooks")), None);
@ -430,7 +468,6 @@ mod tests {
#[test]
fn lexicographic_ordering_across_files() {
let dir = tempfile::tempdir().unwrap();
// Use unique IDs so hooks aren't deduplicated.
write_json(
dir.path(),
"02-second.json",
@ -450,15 +487,18 @@ mod tests {
let (registry, errors) = load_hooks(Some(dir.path()), None);
assert!(errors.is_empty());
let hooks = registry.hooks_for(HookEventName::PreToolUse);
assert_eq!(hooks.len(), 3);
// All hooks are PreToolUse, loaded in file order (01, 02, 03).
let commands: Vec<_> = hooks.iter().map(|h| h.command_raw.as_deref()).collect();
assert_eq!(
commands,
[Some("first.sh"), Some("second.sh"), Some("third.sh")],
"hooks must load in lexicographic file order (01-, 02-, 03-)"
);
}
#[test]
fn global_before_project() {
let global = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
// Use unique IDs so hooks aren't deduplicated.
write_json(
global.path(),
"global.json",
@ -527,32 +567,9 @@ mod tests {
assert_eq!(registry.len(), 2);
}
#[test]
fn hooks_indexed_by_event_type() {
let dir = tempfile::tempdir().unwrap();
// One file with all four event types.
let content = r#"{
"hooks": {
"SessionStart": [{"hooks": [{"type": "command", "command": "a.sh"}]}],
"PreToolUse": [{"hooks": [{"type": "command", "command": "b.sh"}]}],
"PostToolUse": [{"hooks": [{"type": "command", "command": "c.sh"}]}],
"SessionEnd": [{"hooks": [{"type": "command", "command": "d.sh"}]}]
}
}"#;
write_json(dir.path(), "all.json", content);
let (registry, errors) = load_hooks(Some(dir.path()), None);
assert!(errors.is_empty());
assert_eq!(registry.hooks_for(HookEventName::SessionStart).len(), 1);
assert_eq!(registry.hooks_for(HookEventName::PreToolUse).len(), 1);
assert_eq!(registry.hooks_for(HookEventName::PostToolUse).len(), 1);
assert_eq!(registry.hooks_for(HookEventName::SessionEnd).len(), 1);
}
#[test]
fn all_hooks_covers_every_event_type() {
let dir = tempfile::tempdir().unwrap();
// Create hooks for all 10 event types in one file.
let content = r#"{
"hooks": {
"SessionStart": [{"hooks": [{"type": "command", "command": "a.sh"}]}],
@ -573,27 +590,13 @@ mod tests {
assert!(errors.is_empty(), "errors: {errors:?}");
assert_eq!(registry.len(), 10);
// all_hooks() must return all 10 — not just the original 4.
let all = registry.all_hooks();
let events: std::collections::HashSet<_> = all.iter().map(|h| h.event).collect();
assert_eq!(
all.len(),
events.len(),
10,
"all_hooks() returned {} hooks, expected 10 (all event types)",
all.len()
"all_hooks() must cover 10 distinct event types"
);
// Verify each event type is represented.
let events: Vec<HookEventName> = all.iter().map(|h| h.event).collect();
assert!(events.contains(&HookEventName::SessionStart));
assert!(events.contains(&HookEventName::PreToolUse));
assert!(events.contains(&HookEventName::PostToolUse));
assert!(events.contains(&HookEventName::SessionEnd));
assert!(events.contains(&HookEventName::Stop));
assert!(events.contains(&HookEventName::Notification));
assert!(events.contains(&HookEventName::UserPromptSubmit));
assert!(events.contains(&HookEventName::SubagentStart));
assert!(events.contains(&HookEventName::SubagentStop));
assert!(events.contains(&HookEventName::SubagentEnd));
}
#[test]
@ -621,8 +624,6 @@ mod tests {
assert!(!is_valid_hook_file(&toml)); // TOML no longer accepted
}
// ── Settings file discovery tests ────────────────────────────
#[test]
fn load_from_settings_file() {
let dir = tempfile::tempdir().unwrap();
@ -667,7 +668,6 @@ mod tests {
fn mixed_sources_settings_and_directory() {
let dir = tempfile::tempdir().unwrap();
// Settings file with one hook.
let settings = dir.path().join("settings.json");
std::fs::write(
&settings,
@ -675,7 +675,6 @@ mod tests {
)
.unwrap();
// Directory with another hook.
let hooks_dir = dir.path().join("hooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
write_json(&hooks_dir, "extra.json", &simple_hook("SessionStart"));
@ -688,7 +687,6 @@ mod tests {
&[],
);
assert!(errors.is_empty(), "errors: {errors:?}");
// Both hooks should be loaded (additive merge).
assert_eq!(registry.len(), 2);
assert_eq!(registry.hooks_for(HookEventName::PreToolUse).len(), 1);
assert_eq!(registry.hooks_for(HookEventName::SessionStart).len(), 1);
@ -719,7 +717,6 @@ mod tests {
assert!(errors.is_empty());
let hooks = registry.hooks_for(HookEventName::PreToolUse);
assert_eq!(hooks.len(), 2);
// Global hook first, project hook second.
assert!(hooks[0].name.starts_with("global/"));
assert!(hooks[1].name.starts_with("project/"));
}
@ -728,8 +725,6 @@ mod tests {
fn deduplicates_hooks_with_same_content_across_sources() {
let dir = tempfile::tempdir().unwrap();
// Create three sources with the SAME hook command.
// Only the first one (global) should be kept.
let global_settings = dir.path().join("global.json");
std::fs::write(
&global_settings,
@ -760,7 +755,6 @@ mod tests {
&[],
);
assert!(errors.is_empty());
// Only one hook should be loaded (the first one, from global).
let hooks = registry.hooks_for(HookEventName::SessionStart);
assert_eq!(
hooks.len(),
@ -775,11 +769,37 @@ mod tests {
);
}
/// A hook registered under both `SubagentStop` and `SubagentEnd` dedups on
/// the canonical event, so it runs once.
#[test]
fn deduplicates_hooks_across_alias_spellings() {
let dir = tempfile::tempdir().unwrap();
let settings = dir.path().join("settings.json");
std::fs::write(
&settings,
r#"{"hooks":{
"SubagentStop":[{"hooks":[{"type":"command","command":"notify.sh"}]}],
"SubagentEnd":[{"hooks":[{"type":"command","command":"notify.sh"}]}]
}}"#,
)
.unwrap();
let (registry, errors) =
load_hooks_from_sources(&[HookSource::SettingsFile(&settings)], &[]);
assert!(errors.is_empty());
assert_eq!(
registry
.hooks_for_canonical(HookEventName::SubagentStop)
.len(),
1,
"alias spelling must not double-register the same hook"
);
}
#[test]
fn different_commands_not_deduplicated() {
let dir = tempfile::tempdir().unwrap();
// Different hook commands - should NOT be deduplicated.
let global_settings = dir.path().join("global.json");
std::fs::write(
&global_settings,
@ -802,7 +822,6 @@ mod tests {
&[],
);
assert!(errors.is_empty());
// Both hooks should be loaded since they have different commands.
let hooks = registry.hooks_for(HookEventName::SessionStart);
assert_eq!(
hooks.len(),
@ -816,7 +835,6 @@ mod tests {
fn different_event_types_not_deduplicated() {
let dir = tempfile::tempdir().unwrap();
// Same command but different event types - should NOT be deduplicated.
let settings = dir.path().join("settings.json");
std::fs::write(
&settings,
@ -832,19 +850,16 @@ mod tests {
let (registry, errors) =
load_hooks_from_sources(&[HookSource::SettingsFile(&settings)], &[]);
assert!(errors.is_empty());
// Both hooks should be loaded since they're different event types.
assert_eq!(registry.hooks_for(HookEventName::SessionStart).len(), 1);
assert_eq!(registry.hooks_for(HookEventName::SessionEnd).len(), 1);
}
/// The same command in multiple files within one directory dedups to a
/// single run, preventing accidental duplicate execution.
#[test]
fn same_command_in_same_directory_deduplicated() {
// When the same hook command is defined in multiple files within
// the same directory, they should be deduplicated (only the first
// one runs). This prevents accidental duplicate execution.
let dir = tempfile::tempdir().unwrap();
// Two files with the same hook command.
write_json(
dir.path(),
"01-first.json",
@ -858,7 +873,6 @@ mod tests {
let (registry, errors) = load_hooks(Some(dir.path()), None);
assert!(errors.is_empty());
// Only one hook should be loaded (deduplicated by content).
let hooks = registry.hooks_for(HookEventName::SessionStart);
assert_eq!(
hooks.len(),
@ -872,7 +886,6 @@ mod tests {
fn realistic_claude_settings_discovery() {
let dir = tempfile::tempdir().unwrap();
// Simulate ~/.claude/settings.json with many extra keys.
let claude_settings = dir.path().join("settings.json");
std::fs::write(
&claude_settings,
@ -904,7 +917,7 @@ mod tests {
crate::config::HookSpec {
name: name.into(),
event: HookEventName::PreToolUse,
handler_type: "command".into(),
handler_type: crate::config::HandlerType::Command,
configured_matcher: configured_matcher.map(str::to_owned),
matcher: None,
enabled: true,
@ -918,38 +931,6 @@ mod tests {
}
}
#[test]
fn recompile_matchers_fail_closed_on_invalid_pattern() {
// Serde skips `matcher`; recompile must not leave it None (match-all).
let mut registry = HookRegistry::default();
registry.append_specs(vec![recompile_test_spec("broken", Some("[invalid"))]);
registry.recompile_matchers();
let hooks = registry.hooks_for(HookEventName::PreToolUse);
assert_eq!(hooks.len(), 1);
let matcher = hooks[0]
.matcher
.as_ref()
.expect("invalid matcher must compile to never-match, not stay None");
assert!(!matcher.is_match("run_terminal_command"));
assert!(!matcher.is_match("read_file"));
assert!(!matcher.is_match("Bash"));
}
#[test]
fn recompile_matchers_restores_valid_pattern() {
let mut registry = HookRegistry::default();
registry.append_specs(vec![recompile_test_spec("ok", Some("Bash"))]);
registry.recompile_matchers();
let matcher = registry.hooks_for(HookEventName::PreToolUse)[0]
.matcher
.as_ref()
.expect("valid matcher should recompile");
assert!(matcher.is_match("run_terminal_command"));
assert!(!matcher.is_match("read_file"));
}
#[test]
fn recompile_matchers_leaves_intentional_match_all() {
let mut registry = HookRegistry::default();

File diff suppressed because it is too large Load diff

View file

@ -19,13 +19,6 @@ pub enum HookError {
source: regex::Error,
},
#[error("hook {name} in {path}: lifecycle hooks ({event}) must not specify a matcher in v0")]
LifecycleMatcherNotAllowed {
name: String,
path: PathBuf,
event: String,
},
#[error("hook {name} timed out after {elapsed_ms}ms")]
Timeout { name: String, elapsed_ms: u64 },
@ -49,7 +42,7 @@ pub enum HookError {
},
#[error(
"hook {name} in {path}: unsupported handler type '{handler_type}', only 'command' is supported in v0"
"hook {name} in {path}: unsupported handler type '{handler_type}', expected 'command' or 'http'"
)]
UnsupportedHandlerType {
name: String,

View file

@ -5,46 +5,32 @@ pub const MAX_PAYLOAD_SIZE: usize = 128 * 1024;
/// Hook event types.
///
/// Accepts both PascalCase (`"PreToolUse"`) and snake_case (`"pre_tool_use"`)
/// during deserialization for migration compatibility.
/// Serializes to snake_case for the hook envelope wire format.
/// 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 {
// ── Session lifecycle ───────────────────────────────────────
SessionStart,
SessionEnd,
/// Fires when an agent turn ends (completed, cancelled, or error).
/// 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,
/// Fires when the turn ends due to an API error. Output and exit code are ignored.
StopFailure,
// ── Tool events ─────────────────────────────────────────────
PreToolUse,
PostToolUse,
/// Fires after a tool call fails (throws an error).
PostToolUseFailure,
/// Fires when a tool call is denied by the permission system.
PermissionDenied,
// ── User / notification events ──────────────────────────────
/// Fires when the user submits a prompt.
UserPromptSubmit,
/// Fires when a notification is sent (e.g., permission prompt, idle).
Notification,
// ── Subagent events ─────────────────────────────────────────
/// Fires when a subagent is spawned.
SubagentStart,
/// Fires when a subagent completes.
SubagentStop,
/// Alias for SubagentStop (kept for backward compatibility).
SubagentEnd,
// ── Compaction events ───────────────────────────────────────
/// Fires before context compaction.
PreCompact,
/// Fires after context compaction completes.
PostCompact,
}
@ -57,7 +43,7 @@ impl<'de> serde::Deserialize<'de> for HookEventName {
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
// 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"
@ -97,7 +83,8 @@ impl<'de> serde::Deserialize<'de> for HookEventName {
SessionStart, PreToolUse, PostToolUse, PostToolUseFailure, \
SessionEnd, Stop, StopFailure, Notification, UserPromptSubmit, \
PermissionDenied, SubagentStart, SubagentStop, \
PreCompact, PostCompact"
PreCompact, PostCompact (camelCase and per-operation aliases \
such as beforeShellExecution are also accepted)"
))),
}
}
@ -124,6 +111,31 @@ impl std::fmt::Display for HookEventName {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GateKind {
/// Hook output recorded, decisions ignored.
Observe,
Tool,
/// Stop decision control (`block`, `continue: false`, `additionalContext`).
Stop,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatcherPolicy {
/// Never evaluated: kept for display with a load-time warning, the hook fires on every occurrence.
Ignored,
/// Tested against the value [`HookPayload::match_value`] extracts from the payload.
Tested,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EventTraits {
pub gate: GateKind,
pub matcher: MatcherPolicy,
/// Whether hub custom hooks receive this event (see `dispatcher::hub_hook_kind`).
pub hub_forward: bool,
}
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
@ -135,24 +147,124 @@ impl HookEventName {
}
}
/// Returns true if this event type uses blocking (deny/allow) semantics.
pub fn is_blocking(&self) -> bool {
matches!(self, Self::PreToolUse)
}
/// Events that don't support matcher patterns (fire on every occurrence).
pub fn is_lifecycle(&self) -> bool {
matches!(
self,
Self::SessionStart | Self::SessionEnd | Self::Stop | Self::UserPromptSubmit
)
/// 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),
}
}
}
/// The normalized event envelope sent to hook commands on stdin as JSON.
///
/// Contains common metadata plus an event-specific payload.
/// All field names use camelCase for the JSON wire format.
/// Max characters for free-text fields in `StopBackgroundTask`/`StopSessionCron` entries.
pub const MAX_STOP_ENTRY_TEXT_CHARS: usize = 1000;
/// Clip `text` to `max` chars (on a char boundary) with a `… [+N chars]` marker.
pub fn clip_text(text: &str, max: usize) -> String {
let char_count = text.chars().count();
if char_count <= max {
return text.to_string();
}
let clipped: String = text.chars().take(max).collect();
format!("{clipped}… [+{} chars]", char_count - max)
}
pub fn clip_stop_entry_text(text: &str) -> String {
clip_text(text, MAX_STOP_ENTRY_TEXT_CHARS)
}
/// `SubagentStop` fire phase: always `Gate` today, `Observe` reserved and not emitted.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum SubagentStopPhase {
Gate,
Observe,
}
/// One in-flight background task in a `Stop` hook input (camelCase on the wire).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StopBackgroundTask {
pub id: String,
pub r#type: BackgroundTaskType,
/// Always `running` for in-flight entries.
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_type: Option<String>,
}
/// One session-scoped scheduled wakeup (scheduler task or `/loop`) in a `Stop` hook input.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StopSessionCron {
pub id: String,
/// Human-readable interval (e.g. `every 5 minutes`): grok schedules are intervals, not cron.
pub schedule: String,
pub recurring: bool,
pub prompt: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum BackgroundTaskType {
Shell,
Monitor,
Subagent,
}
/// `StopFailure` error type. Grok emits a subset: capacity errors fold into
/// `RateLimit`, and there is no `billing_error`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum StopFailureKind {
RateLimit,
AuthenticationFailed,
InvalidRequest,
ServerError,
MaxOutputTokens,
Unknown,
}
impl StopFailureKind {
pub fn as_str(self) -> &'static str {
match self {
Self::RateLimit => "rate_limit",
Self::AuthenticationFailed => "authentication_failed",
Self::InvalidRequest => "invalid_request",
Self::ServerError => "server_error",
Self::MaxOutputTokens => "max_output_tokens",
Self::Unknown => "unknown",
}
}
}
/// The normalized event envelope sent to hook commands on stdin as JSON:
/// common metadata plus an event-specific payload.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HookEventEnvelope {
@ -167,16 +279,17 @@ pub struct HookEventEnvelope {
pub client_identifier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_id: Option<String>,
/// Session permission mode (`default`, `auto`, `plan`, `bypassPermissions`) at fire time.
#[serde(skip_serializing_if = "Option::is_none")]
pub permission_mode: Option<String>,
#[serde(flatten)]
pub payload: HookPayload,
}
/// Event-specific payload variants, flattened into the envelope JSON via
/// `#[serde(untagged)]`. Grouped to match `HookEventName`.
/// Event-specific payload, flattened into the envelope JSON.
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum HookPayload {
// ── Session lifecycle ───────────────────────────────────────
SessionStart {
source: String,
#[serde(rename = "modelId", skip_serializing_if = "Option::is_none")]
@ -193,16 +306,40 @@ pub enum HookPayload {
},
Stop {
reason: String,
/// True when this Stop fires while the agent is already continuing from a
/// previous Stop-hook block this turn; hooks check it to avoid blocking on a
/// condition that will never resolve.
#[serde(rename = "stopHookActive")]
stop_hook_active: bool,
#[serde(
rename = "lastAssistantMessage",
skip_serializing_if = "Option::is_none"
)]
last_assistant_message: Option<String>,
/// In-flight background work that could wake the session; empty when none in
/// flight, omitted (not empty) at fire sites that don't enumerate (session end).
#[serde(rename = "backgroundTasks", skip_serializing_if = "Option::is_none")]
background_tasks: Option<Vec<StopBackgroundTask>>,
#[serde(rename = "sessionCrons", skip_serializing_if = "Option::is_none")]
session_crons: Option<Vec<StopSessionCron>>,
},
StopFailure {
error: String,
error: StopFailureKind,
#[serde(rename = "errorDetails", skip_serializing_if = "Option::is_none")]
error_details: Option<String>,
/// Rendered error text shown in the conversation: unlike `Stop`, the error
/// string, not assistant output.
#[serde(
rename = "lastAssistantMessage",
skip_serializing_if = "Option::is_none"
)]
last_assistant_message: Option<String>,
},
// ── Tool events ─────────────────────────────────────────────
PreToolUse {
/// The tool the model invoked. For the meta-dispatch tools (`use_tool`
/// and the external MCP-call tool) this is the resolved underlying tool
/// (`server__tool`), not the dispatcher — matchers key on it directly.
/// (`server__tool`) rather than the dispatcher, so matchers key on it.
#[serde(rename = "toolName")]
tool_name: String,
#[serde(rename = "toolUseId")]
@ -211,8 +348,6 @@ pub enum HookPayload {
tool_input: serde_json::Value,
#[serde(rename = "toolInputTruncated")]
tool_input_truncated: bool,
#[serde(rename = "permissionMode", skip_serializing_if = "Option::is_none")]
permission_mode: Option<String>,
/// The subagent's type when this tool runs inside one (the envelope's `sessionId`
/// gives its identity); `None` for the top-level session.
#[serde(rename = "subagentType", skip_serializing_if = "Option::is_none")]
@ -265,13 +400,10 @@ pub enum HookPayload {
tool_input_truncated: bool,
},
// ── User / notification events ──────────────────────────────
/// Fires when the user submits a prompt.
UserPromptSubmit {
#[serde(skip_serializing_if = "Option::is_none")]
prompt: Option<String>,
},
/// Fires on agent notifications (permission prompts, idle, etc.).
Notification {
#[serde(rename = "notificationType")]
notification_type: String,
@ -284,8 +416,6 @@ pub enum HookPayload {
level: Option<String>,
},
// ── Subagent events ─────────────────────────────────────────
/// Fires when a subagent is spawned.
SubagentStart {
#[serde(rename = "subagentId")]
subagent_id: String,
@ -294,21 +424,22 @@ pub enum HookPayload {
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
},
/// Fires when a subagent completes.
SubagentStop {
phase: SubagentStopPhase,
#[serde(rename = "subagentId")]
subagent_id: String,
#[serde(rename = "subagentType")]
subagent_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(rename = "exitCode", skip_serializing_if = "Option::is_none")]
exit_code: Option<i32>,
#[serde(rename = "durationMs", skip_serializing_if = "Option::is_none")]
duration_ms: Option<u64>,
/// Subagent analogue of `Stop::stop_hook_active`.
#[serde(rename = "stopHookActive", skip_serializing_if = "Option::is_none")]
stop_hook_active: Option<bool>,
#[serde(
rename = "lastAssistantMessage",
skip_serializing_if = "Option::is_none"
)]
last_assistant_message: Option<String>,
},
// ── Compaction events ───────────────────────────────────────
PreCompact {
/// "manual" or "auto".
source: String,
@ -319,6 +450,33 @@ pub enum HookPayload {
},
}
impl HookPayload {
/// The value a [`MatcherPolicy::Tested`] matcher is tested against, or `None` when
/// the payload carries nothing selectable (matchers then fire-all, the fail-open default).
pub fn match_value(&self) -> Option<&str> {
let value = match self {
Self::PreToolUse { tool_name, .. }
| Self::PostToolUse { tool_name, .. }
| Self::PostToolUseFailure { tool_name, .. }
| Self::PermissionDenied { tool_name, .. } => tool_name,
Self::Notification {
notification_type, ..
} => notification_type,
Self::SubagentStart { subagent_type, .. }
| Self::SubagentStop { subagent_type, .. } => subagent_type,
Self::SessionStart { source, .. }
| Self::PreCompact { source }
| Self::PostCompact { source } => source,
Self::SessionEnd { reason, .. } => reason,
// Always a non-empty name, unlike the free-text arms above.
Self::StopFailure { error, .. } => return Some(error.as_str()),
// Ignored events listed explicitly so a new Tested event can't silently return None.
Self::Stop { .. } | Self::UserPromptSubmit { .. } => return None,
};
Some(value.as_str()).filter(|v| !v.is_empty())
}
}
/// Truncate a JSON value if its serialized size exceeds `MAX_PAYLOAD_SIZE`.
///
/// Returns `(possibly_truncated_value, was_truncated)`.
@ -417,12 +575,29 @@ mod tests {
}
#[test]
fn event_name_serde_roundtrip() {
let name = HookEventName::PreToolUse;
let json = serde_json::to_string(&name).unwrap();
assert_eq!(json, "\"pre_tool_use\"");
let parsed: HookEventName = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, name);
fn event_name_deser_camel_and_operation_aliases() {
let cases: &[(&str, HookEventName)] = &[
("sessionStart", HookEventName::SessionStart),
("preToolUse", HookEventName::PreToolUse),
("beforeShellExecution", HookEventName::PreToolUse),
("beforeMCPExecution", HookEventName::PreToolUse),
("beforeReadFile", HookEventName::PreToolUse),
("postToolUse", HookEventName::PostToolUse),
("afterShellExecution", HookEventName::PostToolUse),
("afterMCPExecution", HookEventName::PostToolUse),
("afterFileEdit", HookEventName::PostToolUse),
("afterAgentResponse", HookEventName::PostToolUse),
("afterAgentThought", HookEventName::PostToolUse),
("beforeSubmitPrompt", HookEventName::UserPromptSubmit),
("subagentStop", HookEventName::SubagentStop),
("subagentEnd", HookEventName::SubagentEnd),
("preCompact", HookEventName::PreCompact),
("stopFailure", HookEventName::StopFailure),
];
for (spelling, expected) in cases {
let parsed: HookEventName = serde_json::from_str(&format!("\"{spelling}\"")).unwrap();
assert_eq!(parsed, *expected, "alias deser failed for {spelling}");
}
}
#[test]
@ -432,57 +607,134 @@ mod tests {
}
#[test]
fn event_name_is_blocking() {
assert!(HookEventName::PreToolUse.is_blocking());
for event in [
HookEventName::SessionStart,
HookEventName::PostToolUse,
HookEventName::PostToolUseFailure,
HookEventName::SessionEnd,
HookEventName::Stop,
HookEventName::StopFailure,
HookEventName::Notification,
HookEventName::UserPromptSubmit,
HookEventName::PermissionDenied,
HookEventName::SubagentStart,
HookEventName::SubagentStop,
HookEventName::SubagentEnd,
HookEventName::PreCompact,
HookEventName::PostCompact,
] {
assert!(!event.is_blocking(), "{event:?} should not be blocking");
}
fn event_traits_report_gate_matcher_and_hub_forward() {
use super::{GateKind, MatcherPolicy};
assert_eq!(HookEventName::PreToolUse.traits().gate, GateKind::Tool);
assert_eq!(HookEventName::Stop.traits().gate, GateKind::Stop);
assert_eq!(HookEventName::SubagentStop.traits().gate, GateKind::Stop);
assert_eq!(
HookEventName::SubagentEnd.traits().gate,
GateKind::Stop,
"alias resolves through canonical()"
);
assert_eq!(HookEventName::PostToolUse.traits().gate, GateKind::Observe);
assert_eq!(HookEventName::Stop.traits().matcher, MatcherPolicy::Ignored);
assert_eq!(
HookEventName::UserPromptSubmit.traits().matcher,
MatcherPolicy::Ignored
);
assert_eq!(
HookEventName::SessionStart.traits().matcher,
MatcherPolicy::Tested
);
assert!(!HookEventName::PreToolUse.traits().hub_forward);
assert!(HookEventName::Stop.traits().hub_forward);
}
#[test]
fn event_name_is_lifecycle() {
let lifecycle = [
HookEventName::SessionStart,
HookEventName::SessionEnd,
HookEventName::Stop,
HookEventName::UserPromptSubmit,
];
for event in lifecycle {
assert!(event.is_lifecycle(), "{event:?} should be lifecycle");
}
fn clip_stop_entry_text_clips_on_char_boundary() {
assert_eq!(clip_stop_entry_text("short"), "short");
let exact = "x".repeat(MAX_STOP_ENTRY_TEXT_CHARS);
assert_eq!(clip_stop_entry_text(&exact), exact);
let matchable = [
HookEventName::PreToolUse,
HookEventName::PostToolUse,
HookEventName::PostToolUseFailure,
HookEventName::PermissionDenied,
HookEventName::StopFailure,
HookEventName::Notification,
HookEventName::SubagentStart,
HookEventName::SubagentStop,
HookEventName::SubagentEnd,
HookEventName::PreCompact,
HookEventName::PostCompact,
];
for event in matchable {
assert!(
!event.is_lifecycle(),
"{event:?} should support matchers, not be lifecycle"
let long = "x".repeat(MAX_STOP_ENTRY_TEXT_CHARS + 42);
let clipped = clip_stop_entry_text(&long);
assert!(clipped.ends_with("… [+42 chars]"));
let unicode = "".repeat(MAX_STOP_ENTRY_TEXT_CHARS + 7);
let clipped = clip_stop_entry_text(&unicode);
assert!(clipped.ends_with("… [+7 chars]"));
}
#[test]
fn stop_payload_serializes_task_and_cron_entries() {
let envelope = HookEventEnvelope {
hook_event_name: HookEventName::Stop,
session_id: "s".into(),
cwd: "/tmp".into(),
workspace_root: "/tmp".into(),
timestamp: "t".into(),
transcript_path: None,
client_identifier: None,
prompt_id: None,
permission_mode: None,
payload: HookPayload::Stop {
reason: "end_turn".into(),
stop_hook_active: true,
last_assistant_message: Some("done".into()),
background_tasks: Some(vec![
StopBackgroundTask {
id: "task-001".into(),
r#type: BackgroundTaskType::Shell,
status: "running".into(),
description: None,
command: Some("tail -f /var/log/syslog".into()),
agent_type: None,
},
StopBackgroundTask {
id: "task-002".into(),
r#type: BackgroundTaskType::Subagent,
status: "running".into(),
description: Some("explore the repo".into()),
command: None,
agent_type: Some("explore".into()),
},
]),
session_crons: Some(vec![StopSessionCron {
id: "cron-001".into(),
schedule: "every 2h".into(),
recurring: true,
prompt: "check the build".into(),
}]),
},
};
let value = serde_json::to_value(&envelope).unwrap();
assert_eq!(value["stopHookActive"], true);
assert_eq!(value["backgroundTasks"][0]["id"], "task-001");
assert_eq!(value["backgroundTasks"][0]["type"], "shell");
assert_eq!(
value["backgroundTasks"][0]["command"],
"tail -f /var/log/syslog"
);
assert_eq!(value["backgroundTasks"][1]["agentType"], "explore");
assert_eq!(value["sessionCrons"][0]["schedule"], "every 2h");
assert_eq!(value["sessionCrons"][0]["recurring"], true);
}
#[test]
fn subagent_stop_phase_serializes_lowercase() {
let payload = HookPayload::SubagentStop {
phase: SubagentStopPhase::Observe,
subagent_id: "sub-1".into(),
subagent_type: "explore".into(),
stop_hook_active: None,
last_assistant_message: None,
};
let value = serde_json::to_value(&payload).unwrap();
assert_eq!(value["phase"], "observe");
assert_eq!(
serde_json::to_value(SubagentStopPhase::Gate).unwrap(),
"gate"
);
}
#[test]
fn stop_failure_kind_as_str_matches_serialization() {
for kind in [
StopFailureKind::RateLimit,
StopFailureKind::AuthenticationFailed,
StopFailureKind::InvalidRequest,
StopFailureKind::ServerError,
StopFailureKind::MaxOutputTokens,
StopFailureKind::Unknown,
] {
assert_eq!(
serde_json::to_value(kind).unwrap(),
serde_json::Value::from(kind.as_str()),
"{kind:?} serialization drifted from as_str"
);
}
}
@ -497,23 +749,18 @@ mod tests {
#[test]
fn truncate_large_payload() {
let big_string = "x".repeat(MAX_PAYLOAD_SIZE + 1000);
let value = serde_json::Value::String(big_string);
let value = serde_json::Value::String("x".repeat(MAX_PAYLOAD_SIZE + 1000));
let (result, truncated) = truncate_payload(value);
assert!(truncated);
let s = result.as_str().unwrap();
assert!(s.ends_with("[truncated]"));
// Serialized size of the result string value should be <= MAX_PAYLOAD_SIZE + overhead
assert!(s.len() < MAX_PAYLOAD_SIZE + 100);
}
#[test]
fn truncate_large_payload_cuts_on_char_boundary() {
// '€' is 3 bytes, so the MAX_PAYLOAD_SIZE-th byte lands mid-codepoint.
let value = serde_json::Value::String("".repeat(MAX_PAYLOAD_SIZE));
let (result, truncated) = truncate_payload(value);
// '€' is 3 bytes, so the cut lands mid-codepoint and must fall back to a char boundary.
let (unicode, truncated) =
truncate_payload(serde_json::Value::String("".repeat(MAX_PAYLOAD_SIZE)));
assert!(truncated);
assert!(result.as_str().unwrap().ends_with("[truncated]"));
assert!(unicode.as_str().unwrap().ends_with("[truncated]"));
}
#[test]
@ -527,19 +774,19 @@ mod tests {
transcript_path: None,
client_identifier: None,
prompt_id: None,
permission_mode: None,
payload: HookPayload::SessionStart {
source: "new".into(),
model_id: Some("grok-3".into()),
agent_type: None,
},
};
let json = serde_json::to_string(&envelope).unwrap();
assert!(json.contains("hookEventName"));
assert!(json.contains("sessionId"));
assert!(json.contains("workspaceRoot"));
assert!(json.contains("modelId"));
// Should NOT contain snake_case versions
assert!(!json.contains("hook_event_name"));
assert!(!json.contains("session_id"));
let value = serde_json::to_value(&envelope).unwrap();
for key in ["hookEventName", "sessionId", "workspaceRoot", "modelId"] {
assert!(value.get(key).is_some(), "missing camelCase key {key}");
}
for key in ["hook_event_name", "session_id", "model_id"] {
assert!(value.get(key).is_none(), "leaked snake_case key {key}");
}
}
}

View file

@ -22,7 +22,7 @@ pub struct HookMatcher {
enum MatcherKind {
All,
/// Matches no tool names. Used when a configured matcher fails to compile
/// after deserialization fail closed rather than widen to match-all.
/// after deserialization; fail closed rather than widen to match-all.
Never,
Exact(Vec<String>),
Regex(Regex),
@ -63,6 +63,15 @@ impl HookMatcher {
}
}
/// Shared matcher-application rule: a missing matcher or missing value fires
/// (fail-open); otherwise the compiled matcher decides.
pub fn matcher_allows(matcher: Option<&HookMatcher>, value: Option<&str>) -> bool {
match (matcher, value) {
(Some(matcher), Some(value)) => matcher.is_match(value),
_ => true,
}
}
/// A pattern is "simple" (exact/`|`-list, not regex) when it contains only
/// ASCII alphanumerics, `_`, and `|`.
fn is_simple_form(pattern: &str) -> bool {
@ -173,8 +182,6 @@ mod tests {
assert!(!m.is_match("run_terminal_command"));
}
// ── External tool-name aliases ────────────────────────────────
#[test]
fn claude_bash_matches_grok_tool() {
let m = HookMatcher::new("Bash").unwrap();
@ -198,14 +205,6 @@ mod tests {
assert!(!m.is_match("my_search_replace"));
}
#[test]
fn claude_read_matches_grok_tool() {
let m = HookMatcher::new("Read").unwrap();
assert!(m.is_match("Read"));
assert!(m.is_match("read_file"));
assert!(m.is_match("hashline_read"));
}
#[test]
fn regex_against_claude_alias_matches_grok_tool() {
// A regex written against an external alias still matches the Grok tool

View file

@ -3,68 +3,70 @@ use std::time::Duration;
/// The outcome of a blocking (`pre_tool_use`) hook dispatch.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookDecision {
/// All hooks allowed (or no hooks matched).
Allow,
/// At least one hook denied with the given reason.
Deny { reason: String, hook_name: String },
}
/// HTTP-specific execution details for scrollback enrichment.
///
/// Populated only for `"http"` handler type hooks. Carries the target
/// URL, HTTP status, and a short preview of the response body so that
/// scrollback annotations can display them.
/// Parsed output of one `Stop`/`SubagentStop` gate hook. The dispatcher
/// aggregates these across hooks; `force_stop` overrides blocks.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StopHookOutcome {
pub block_reason: Option<String>,
pub additional_context: Option<String>,
pub force_stop: Option<StopOverride>,
}
/// A `continue: false` force-stop; `reason` is `stopReason`, shown to the user.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StopOverride {
pub reason: Option<String>,
}
impl StopHookOutcome {
pub fn is_empty(&self) -> bool {
self.block_reason.is_none()
&& self.additional_context.is_none()
&& self.force_stop.is_none()
}
}
/// HTTP execution details for `"http"` hooks, for scrollback enrichment.
#[derive(Debug, Clone)]
pub struct HttpInfo {
/// The URL that was POSTed to.
///
/// **Post-expansion form**: this is the actual target the runner
/// hit (or attempted to hit) and is intended for SSRF debugging.
/// User `env` map values resolved at expand time can land here, so
/// any new wire-DTO consumer that surfaces this field for **user
/// display** MUST prefer [`raw_url`] when available -- otherwise
/// secrets like API tokens embedded in the URL via `${TOKEN}`
/// substitution will leak. See `HookSpec::url_raw` in
/// `crate::config` for the parallel display-vs-execution split.
///
/// [`raw_url`]: HttpInfo::raw_url
/// Post-expansion target (for SSRF debugging). May contain secrets from
/// resolved `${VAR}` substitutions, so user-facing display MUST prefer
/// `raw_url` when present.
pub url: String,
/// Pre-expansion source URL exactly as written in the JSON file,
/// when available. Mirrors `HookSpec::url_raw` so downstream wire
/// DTOs / scrollback display layers can show the source string
/// without ever leaking resolved `${VAR}` substitutions. `None`
/// for legacy code paths that constructed the spec without the
/// raw source (the runner falls back to displaying [`url`] in
/// that case).
///
/// [`url`]: HttpInfo::url
/// Pre-expansion source URL as written in the file, safe for display.
/// `None` when the spec was built without it (fall back to `url`).
pub raw_url: Option<String>,
/// HTTP status code (e.g. 200, 500). `None` if the request never
/// completed (timeout, connection error).
pub status: Option<u16>,
/// Short preview of the response body (truncated to ~200 chars).
/// `None` if no body was read (e.g. non-blocking hooks, timeouts).
pub response_preview: Option<String>,
}
/// The outcome of a single hook execution.
#[derive(Debug)]
pub enum HookRunResult {
/// Hook executed successfully.
Success {
hook_name: String,
elapsed: Duration,
/// HTTP details, populated only for `"http"` handler type hooks.
http_info: Option<HttpInfo>,
},
/// Hook was skipped because it is disabled.
Skipped { hook_name: String },
/// Hook failed (timeout, crash, bad output, etc.) — fail-open.
Skipped {
hook_name: String,
},
/// Ran and blocked: a stop-gate decision, not a failure (distinct from `Failed`).
Blocked {
hook_name: String,
detail: String,
elapsed: Duration,
http_info: Option<HttpInfo>,
},
/// Hook failed (timeout, crash, bad output): fail-open.
Failed {
hook_name: String,
error: String,
elapsed: Duration,
/// HTTP details, populated only for `"http"` handler type hooks.
http_info: Option<HttpInfo>,
},
}

File diff suppressed because it is too large Load diff

View file

@ -6,36 +6,27 @@
use std::net::IpAddr;
use std::time::{Duration, Instant};
use serde::Deserialize;
use url::Url;
use crate::config::HookSpec;
use crate::event::HookEventEnvelope;
use crate::result::{HookDecision, HttpInfo};
use crate::result::{HookDecision, HttpInfo, StopHookOutcome};
use super::{HookRunOutput, HookRunnerResult, RunContext};
use super::{
GateKind, HookRunOutput, HookRunnerResult, RunContext, StopHookJson, stop_json_to_outcome,
};
/// Maximum characters to keep from the response body for the preview.
const RESPONSE_PREVIEW_MAX: usize = 200;
/// The JSON result structure expected from blocking HTTP hooks.
#[derive(Debug, Deserialize)]
struct HttpHookOutput {
decision: String,
#[serde(default)]
reason: Option<String>,
}
/// CWE-918: Returns `true` if an IP address is in a private, link-local,
/// or cloud metadata range that should be blocked to prevent SSRF attacks.
///
/// Loopback (`127.x` / `::1`) is allowed for local development servers.
/// CWE-918: `true` if `ip` is in a private, link-local, or cloud metadata range
/// that must be blocked to prevent SSRF. Loopback (`127.x`/`::1`) is allowed for
/// local development servers.
fn is_blocked_ip(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
let octets = v4.octets();
if octets[0] == 127 {
return false; // loopback allowed for local dev
return false; // loopback, allowed for local dev
}
if octets[0] == 10 {
return true; // RFC 1918: 10.0.0.0/8
@ -59,7 +50,7 @@ fn is_blocked_ip(ip: &IpAddr) -> bool {
}
IpAddr::V6(v6) => {
if v6.is_loopback() {
return false; // ::1 allowed for local dev
return false; // ::1, allowed for local dev
}
if v6.is_unspecified() {
return true; // ::
@ -69,25 +60,22 @@ fn is_blocked_ip(ip: &IpAddr) -> bool {
}
let segments = v6.segments();
if segments[0] & 0xffc0 == 0xfe80 {
return true; // fe80::/10 link-local
return true; // fe80::/10 link-local
}
if segments[0] & 0xfe00 == 0xfc00 {
return true; // fc00::/7 unique local (ULA)
return true; // fc00::/7 unique local (ULA)
}
false
}
}
}
/// CWE-918: Validate a hook URL to prevent SSRF.
///
/// Requirements:
/// - Only HTTPS scheme is allowed (reject HTTP / other schemes).
/// - Resolved IP addresses must not be in private/link-local/metadata ranges.
/// CWE-918: prevent SSRF. Only HTTPS is allowed and resolved IPs must not be
/// private/link-local/metadata. Known gap: the request re-resolves the host, so
/// a rebinding DNS server can still swap in a blocked IP after this check.
async fn validate_hook_url(url: &str) -> Result<(), String> {
let parsed = Url::parse(url).map_err(|e| format!("invalid URL: {e}"))?;
// Restrict to HTTPS only.
if parsed.scheme() != "https" {
return Err(format!(
"only https:// URLs are allowed for HTTP hooks, got {}://",
@ -99,7 +87,6 @@ async fn validate_hook_url(url: &str) -> Result<(), String> {
.host_str()
.ok_or_else(|| "URL has no host".to_string())?;
// If host is a literal IP, check it directly.
if let Ok(ip) = host.parse::<IpAddr>() {
if is_blocked_ip(&ip) {
return Err(format!("URL resolves to blocked private/internal IP: {ip}"));
@ -107,7 +94,6 @@ async fn validate_hook_url(url: &str) -> Result<(), String> {
return Ok(());
}
// DNS resolution check.
let port = parsed.port_or_known_default().unwrap_or(443);
let addr_str = format!("{host}:{port}");
let addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host(&addr_str)
@ -131,29 +117,25 @@ async fn validate_hook_url(url: &str) -> Result<(), String> {
Ok(())
}
/// Build the reqwest client used to send a hook request.
fn build_hook_client(timeout_ms: u64) -> reqwest::Client {
reqwest::Client::builder()
.timeout(Duration::from_millis(timeout_ms))
// `validate_hook_url` only vets the initial URL, not redirect targets.
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap_or_default()
// A default fallback would follow redirects and drop the timeout,
// reopening the SSRF path; build only fails on a TLS init fault.
.expect("hook HTTP client config is valid")
}
/// Run a single HTTP hook.
///
/// POSTs the serialized `HookEventEnvelope` as JSON to `spec.url`.
/// For blocking hooks (`PreToolUse`), parses the response JSON for
/// `{"decision": "allow"}` or `{"decision": "deny", "reason": "..."}`.
/// For non-blocking hooks, any 2xx response is success.
///
/// Respects `spec.timeout_ms` for the entire request.
/// POST the serialized `HookEventEnvelope` to `spec.url` and parse the response
/// per gate mode (blocking parses a decision JSON; observe treats any 2xx as
/// success).
pub async fn run_http_hook(
spec: &HookSpec,
envelope: &HookEventEnvelope,
_ctx: &RunContext<'_>,
is_blocking: bool,
mode: GateKind,
) -> HookRunOutput {
let start = Instant::now();
@ -165,39 +147,17 @@ pub async fn run_http_hook(
);
};
// Expand `${VAR}` / `$VAR` in the URL right before validation. We
// re-run expansion here (in addition to the load-time pass in
// `parse_hook_file`) because plugin URLs can reference plugin-injected
// vars (e.g. `${CLAUDE_PLUGIN_ROOT}/check`) that only land in
// `spec.extra_env` after the plugin adapter wires them in.
//
// For plugin hooks specifically: the load-time pass in
// `parse_hook_file` runs BEFORE the plugin adapter populates
// `extra_env` with plugin keys, so `${CLAUDE_PLUGIN_ROOT}` etc.
// survive that pass and are resolved here at runtime. For
// non-plugin hooks the load-time pass already resolved everything
// resolvable, and this pass is effectively a no-op.
//
// Unset refs are preserved verbatim, so `validate_hook_url` will
// reject them with an "invalid URL" error rather than silently
// smuggling a literal `${VAR}` past validation.
// Re-expand the URL here (in addition to the load-time pass) because plugin
// vars (e.g. `${CLAUDE_PLUGIN_ROOT}/check`) only land in `extra_env` after
// the plugin adapter runs. Unset refs are preserved so `validate_hook_url`
// rejects them rather than smuggling a literal `${VAR}` past validation.
let expanded_url = crate::env_expand::expand_env_vars_with_extra(raw_url, &spec.extra_env);
let url: &str = &expanded_url;
// For tracing/log purposes prefer the pre-expansion source so
// resolved values from the user `env` map (which may contain
// secrets like API tokens) don't land in `~/.grok/logs`. Falls
// back to the expanded form if the spec was constructed by a
// legacy path that didn't populate `url_raw`. The same `log_url`
// is also threaded into `format!("HTTP request failed for {}:
// {}", log_url, e.without_url())` below so reqwest's default
// `Display` (which appends the request URL) does not bypass the
// raw-source preference.
// Prefer the pre-expansion source for logs so resolved `env` secrets don't
// reach `~/.grok/logs`; threaded into the reqwest error format below so
// reqwest's default `Display` (which appends the URL) can't bypass it.
let log_url: &str = spec.url_raw.as_deref().unwrap_or(url);
// Helper: build an `HttpInfo` populated with both the
// post-expansion `url` (for SSRF debugging) and the raw source
// form (for any user-facing display surface). See `HttpInfo`
// rustdoc on `crate::result::HttpInfo` for the contract.
let make_info = |status: Option<u16>, preview: Option<String>| -> HttpInfo {
HttpInfo {
url: url.to_owned(),
@ -207,8 +167,20 @@ pub async fn run_http_hook(
}
};
// CWE-918: Validate URL before sending any data.
if let Err(reason) = validate_hook_url(url).await {
// CWE-918: validate before sending. Bound the DNS lookup by the hook
// timeout; the reqwest timeout only covers the request that follows.
let validation = tokio::time::timeout(
Duration::from_millis(spec.timeout_ms),
validate_hook_url(url),
)
.await
.unwrap_or_else(|_| {
Err(format!(
"URL validation timed out after {}ms",
spec.timeout_ms
))
});
if let Err(reason) = validation {
tracing::warn!(
hook_name = %spec.name,
url = %log_url,
@ -245,15 +217,10 @@ pub async fn run_http_hook(
Ok(r) => r,
Err(e) => {
let elapsed = start.elapsed();
// SECURITY: `reqwest::Error::Display` unconditionally
// appends the request URL. If the resolved URL embeds a
// secret resolved from the user `env` map (e.g.
// `?token=ghp_REAL_SECRET`), the secret would otherwise
// land in `HookRunResult::Failed.error` and surface in
// pager scrollback / wire DTOs. `e.without_url()` strips
// the URL from the formatted output so we substitute our
// own `log_url` (which prefers the raw source form) in
// its place.
// SECURITY: `reqwest::Error::Display` appends the request URL, which
// may embed an `env`-map secret and leak into `Failed.error` and
// pager scrollback. `e.without_url()` strips it so we substitute
// `log_url` (the raw source form).
let error = if e.is_timeout() {
format!("timed out after {}ms", spec.timeout_ms)
} else {
@ -279,7 +246,7 @@ pub async fn run_http_hook(
"http hook completed"
);
if !is_blocking {
if mode == GateKind::Observe {
let http_info = Some(make_info(Some(status_code), None));
if status.is_success() {
return (HookRunnerResult::Success, elapsed, http_info);
@ -291,13 +258,10 @@ pub async fn run_http_hook(
);
}
// Blocking hook: parse response JSON for decision.
let response_text = match response.text().await {
Ok(t) => t,
Err(e) => {
// SECURITY: same `without_url()` reasoning as the send
// failure above -- reqwest's body-read error also includes
// the URL by default.
// SECURITY: scrub the URL as in the send-failure branch above.
return (
HookRunnerResult::Failed(format!(
"failed to read response body for {}: {}",
@ -318,49 +282,65 @@ pub async fn run_http_hook(
let http_info = Some(make_info(Some(status_code), response_preview.clone()));
let result = parse_http_blocking_result(&response_text, status, &spec.name);
let result = match mode {
GateKind::Tool => parse_http_blocking_result(&response_text, status, &spec.name),
GateKind::Stop => parse_http_stop_result(&response_text, status, &spec.name),
GateKind::Observe => HookRunnerResult::Success,
};
(result, elapsed, http_info)
}
/// Parse an HTTP blocking hook response into a `HookRunnerResult`.
///
/// This is the HTTP analogue of `command::parse_blocking_result`.
/// Extracted as a standalone function so it can be unit-tested without
/// making real HTTP requests.
/// HTTP analogue of `command::parse_stop_result`: a 2xx JSON body is parsed for
/// the decision; a 2xx empty/non-JSON body allows the stop; a non-2xx response
/// is a failure (callers fail open).
fn parse_http_stop_result(
response_text: &str,
status: reqwest::StatusCode,
hook_name: &str,
) -> HookRunnerResult {
if !status.is_success() {
return HookRunnerResult::Failed(format!("HTTP status {status}"));
}
let trimmed = response_text.trim();
if trimmed.is_empty() {
return HookRunnerResult::Stop(StopHookOutcome::default());
}
match serde_json::from_str::<StopHookJson>(trimmed) {
Ok(json) => match stop_json_to_outcome(json, hook_name) {
Ok(outcome) => HookRunnerResult::Stop(outcome),
Err(err) => HookRunnerResult::Failed(err),
},
Err(e) => {
tracing::warn!(
hook_name = %hook_name,
error = %e,
"could not parse HTTP stop hook response JSON, treating as allow-stop"
);
HookRunnerResult::Stop(StopHookOutcome::default())
}
}
}
/// Parse an HTTP blocking hook response, the analogue of
/// `command::parse_blocking_result`.
fn parse_http_blocking_result(
response_text: &str,
status: reqwest::StatusCode,
hook_name: &str,
) -> HookRunnerResult {
if response_text.trim().is_empty() {
// No body: use HTTP status as fallback.
if status.is_success() {
return HookRunnerResult::Decision(HookDecision::Allow);
}
return HookRunnerResult::Failed(format!("HTTP status {} with empty body", status));
}
match serde_json::from_str::<HttpHookOutput>(response_text) {
Ok(output) => {
if output.decision == "deny" {
let reason = output
.reason
.unwrap_or_else(|| format!("denied by hook '{}'", hook_name));
HookRunnerResult::Decision(HookDecision::Deny {
reason,
hook_name: hook_name.to_string(),
})
} else if output.decision == "allow" {
HookRunnerResult::Decision(HookDecision::Allow)
} else {
HookRunnerResult::Failed(format!(
"unknown decision value '{}' from hook '{}'",
output.decision, hook_name
))
}
}
match serde_json::from_str::<super::GateHookJson>(response_text) {
Ok(output) => match super::gate_json_to_decision(output, hook_name) {
Ok(decision) => HookRunnerResult::Decision(decision),
Err(err) => HookRunnerResult::Failed(err),
},
Err(e) => {
// Cannot parse response: fail-open if status is success.
if status.is_success() {
tracing::warn!(
hook_name = %hook_name,
@ -378,16 +358,13 @@ fn parse_http_blocking_result(
}
}
/// Truncate a response body string for preview display.
///
/// Uses `char_indices` to find a safe UTF-8 boundary so we never panic
/// on multi-byte characters.
/// Truncate a response body for preview, cutting on a UTF-8 char boundary so
/// multi-byte characters never panic.
fn truncate_preview(s: &str) -> String {
let trimmed = s.trim();
if trimmed.len() <= RESPONSE_PREVIEW_MAX {
trimmed.to_string()
} else {
// Find the last char boundary at or before RESPONSE_PREVIEW_MAX bytes.
let boundary = trimmed
.char_indices()
.take_while(|&(i, _)| i <= RESPONSE_PREVIEW_MAX)
@ -405,8 +382,6 @@ mod tests {
use super::*;
use reqwest::StatusCode;
// ── parse_http_blocking_result tests ──────────────────────────
#[test]
fn http_allow_json() {
let result =
@ -461,22 +436,51 @@ mod tests {
}
}
/// The JSON vocabulary itself is covered by the shared `stop_json_to_outcome`
/// tests; this covers only the HTTP status/body handling.
#[test]
fn http_empty_body_success_allows() {
let result = parse_http_blocking_result("", StatusCode::OK, "test-hook");
fn http_stop_status_and_body_handling() {
match parse_http_stop_result(
r#"{"decision":"block","reason":"tests failing"}"#,
StatusCode::OK,
"s",
) {
HookRunnerResult::Stop(o) => {
assert_eq!(o.block_reason.as_deref(), Some("tests failing"));
}
other => panic!("expected Stop, got {other:?}"),
}
match parse_http_stop_result("", StatusCode::OK, "s") {
HookRunnerResult::Stop(o) => assert!(o.is_empty()),
other => panic!("expected Stop, got {other:?}"),
}
match parse_http_stop_result("not json", StatusCode::OK, "s") {
HookRunnerResult::Stop(o) => assert!(o.is_empty()),
other => panic!("expected Stop, got {other:?}"),
}
assert!(matches!(
result,
HookRunnerResult::Decision(HookDecision::Allow)
parse_http_stop_result(r#"{"decision":"deny"}"#, StatusCode::OK, "s"),
HookRunnerResult::Failed(_)
));
assert!(matches!(
parse_http_stop_result(
r#"{"decision":"block"}"#,
StatusCode::INTERNAL_SERVER_ERROR,
"s"
),
HookRunnerResult::Failed(_)
));
}
#[test]
fn http_empty_body_whitespace_success_allows() {
let result = parse_http_blocking_result(" \n ", StatusCode::OK, "test-hook");
assert!(matches!(
result,
HookRunnerResult::Decision(HookDecision::Allow)
));
fn http_empty_body_success_allows() {
for body in ["", " \n "] {
let result = parse_http_blocking_result(body, StatusCode::OK, "test-hook");
assert!(matches!(
result,
HookRunnerResult::Decision(HookDecision::Allow)
));
}
}
#[test]
@ -493,17 +497,17 @@ mod tests {
#[test]
fn http_invalid_json_success_status_fail_open() {
// Unparseable JSON with 200 OK should fail-open to allow.
let result = parse_http_blocking_result("not json at all", StatusCode::OK, "test-hook");
assert!(matches!(
result,
HookRunnerResult::Decision(HookDecision::Allow)
));
for body in ["not json at all", r#"{"decision":"deny""#] {
let result = parse_http_blocking_result(body, StatusCode::OK, "test-hook");
assert!(matches!(
result,
HookRunnerResult::Decision(HookDecision::Allow)
));
}
}
#[test]
fn http_invalid_json_error_status_fails() {
// Unparseable JSON with 500 should fail.
let result =
parse_http_blocking_result("not json", StatusCode::INTERNAL_SERVER_ERROR, "test-hook");
match result {
@ -530,143 +534,69 @@ mod tests {
}
#[test]
fn http_allow_with_non_success_status() {
let result = parse_http_blocking_result(
r#"{"decision":"allow"}"#,
StatusCode::BAD_REQUEST,
"test-hook",
);
assert!(matches!(
result,
HookRunnerResult::Decision(HookDecision::Allow)
));
}
#[test]
fn http_partial_json_success_fail_open() {
let result =
parse_http_blocking_result(r#"{"decision":"deny""#, StatusCode::OK, "test-hook");
assert!(matches!(
result,
HookRunnerResult::Decision(HookDecision::Allow)
));
}
#[test]
fn http_extra_fields_tolerated() {
let result = parse_http_blocking_result(
r#"{"decision":"deny","reason":"nope","extra":"ignored","count":42}"#,
StatusCode::OK,
"test-hook",
);
match result {
HookRunnerResult::Decision(HookDecision::Deny { reason, .. }) => {
assert_eq!(reason, "nope");
}
other => panic!("expected Deny, got {other:?}"),
fn ssrf_blocks_private_and_special_ranges() {
for ip in [
"10.0.0.1", // RFC 1918 10.0.0.0/8
"10.255.255.255",
"172.16.0.1", // RFC 1918 172.16.0.0/12
"172.31.255.255",
"192.168.0.1", // RFC 1918 192.168.0.0/16
"192.168.255.255",
"169.254.0.1", // link-local / metadata
"169.254.169.254",
"100.64.0.1", // CGNAT
"100.127.255.255",
"0.0.0.0", // unspecified
"::",
] {
assert!(is_blocked_ip(&ip.parse().unwrap()), "{ip} must be blocked");
}
// Just outside the blocked RFC 1918 / CGNAT ranges.
for ip in ["172.15.0.1", "172.32.0.1", "100.63.0.1"] {
assert!(!is_blocked_ip(&ip.parse().unwrap()), "{ip} must be allowed");
}
}
// ── SSRF protection: is_blocked_ip tests ──────────────
#[test]
fn ssrf_blocks_rfc1918_10x() {
assert!(is_blocked_ip(&"10.0.0.1".parse().unwrap()));
assert!(is_blocked_ip(&"10.255.255.255".parse().unwrap()));
fn ssrf_allows_loopback_and_public() {
for ip in ["127.0.0.1", "::1", "1.1.1.1", "8.8.8.8"] {
assert!(!is_blocked_ip(&ip.parse().unwrap()), "{ip} must be allowed");
}
}
#[test]
fn ssrf_blocks_rfc1918_172x() {
assert!(is_blocked_ip(&"172.16.0.1".parse().unwrap()));
assert!(is_blocked_ip(&"172.31.255.255".parse().unwrap()));
assert!(!is_blocked_ip(&"172.15.0.1".parse().unwrap()));
assert!(!is_blocked_ip(&"172.32.0.1".parse().unwrap()));
}
#[test]
fn ssrf_blocks_rfc1918_192168() {
assert!(is_blocked_ip(&"192.168.0.1".parse().unwrap()));
assert!(is_blocked_ip(&"192.168.255.255".parse().unwrap()));
}
#[test]
fn ssrf_blocks_link_local_metadata() {
assert!(is_blocked_ip(&"169.254.0.1".parse().unwrap()));
assert!(is_blocked_ip(&"169.254.169.254".parse().unwrap()));
}
#[test]
fn ssrf_blocks_cgnat() {
assert!(is_blocked_ip(&"100.64.0.1".parse().unwrap()));
assert!(is_blocked_ip(&"100.127.255.255".parse().unwrap()));
assert!(!is_blocked_ip(&"100.63.0.1".parse().unwrap()));
}
#[test]
fn ssrf_blocks_unspecified() {
assert!(is_blocked_ip(&"0.0.0.0".parse().unwrap()));
assert!(is_blocked_ip(&"::".parse().unwrap()));
}
#[test]
fn ssrf_allows_loopback() {
assert!(!is_blocked_ip(&"127.0.0.1".parse().unwrap()));
assert!(!is_blocked_ip(&"::1".parse().unwrap()));
}
#[test]
fn ssrf_allows_public_ips() {
assert!(!is_blocked_ip(&"1.1.1.1".parse().unwrap()));
assert!(!is_blocked_ip(&"8.8.8.8".parse().unwrap()));
}
#[test]
fn ssrf_blocks_ipv6_link_local() {
assert!(is_blocked_ip(&"fe80::1".parse().unwrap()));
}
#[test]
fn ssrf_blocks_ipv6_unique_local() {
assert!(is_blocked_ip(&"fc00::1".parse().unwrap()));
assert!(is_blocked_ip(&"fd00::1".parse().unwrap()));
}
#[test]
fn ssrf_blocks_ipv4_mapped_ipv6_private() {
assert!(is_blocked_ip(&"::ffff:10.0.0.1".parse::<IpAddr>().unwrap()));
assert!(is_blocked_ip(
&"::ffff:192.168.1.1".parse::<IpAddr>().unwrap()
));
}
// ── SSRF protection: validate_hook_url tests ──────────
#[tokio::test]
async fn ssrf_rejects_http_scheme() {
let result = validate_hook_url("http://example.com/hook").await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("https://"));
fn ssrf_blocks_ipv6_ranges() {
for ip in [
"fe80::1", // link-local
"fc00::1", // unique local (ULA)
"fd00::1",
"::ffff:10.0.0.1", // IPv4-mapped private
"::ffff:192.168.1.1",
] {
assert!(
is_blocked_ip(&ip.parse::<IpAddr>().unwrap()),
"{ip} must be blocked"
);
}
}
#[tokio::test]
async fn ssrf_rejects_ftp_scheme() {
let result = validate_hook_url("ftp://example.com/hook").await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("https://"));
async fn ssrf_rejects_non_https_schemes() {
for url in ["http://example.com/hook", "ftp://example.com/hook"] {
let err = validate_hook_url(url).await.expect_err("must reject");
assert!(err.contains("https://"));
}
}
#[tokio::test]
async fn ssrf_rejects_private_ip_literal() {
let result = validate_hook_url("https://10.0.0.1/hook").await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("blocked"));
}
#[tokio::test]
async fn ssrf_rejects_metadata_ip_literal() {
let result = validate_hook_url("https://169.254.169.254/latest/meta-data/").await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("blocked"));
async fn ssrf_rejects_blocked_ip_literals() {
for url in [
"https://10.0.0.1/hook",
"https://169.254.169.254/latest/meta-data/",
] {
let err = validate_hook_url(url).await.expect_err("must reject");
assert!(err.contains("blocked"));
}
}
#[tokio::test]
@ -682,76 +612,23 @@ mod tests {
assert!(result.unwrap_err().contains("invalid URL"));
}
// ── URL env-var expansion (extra_env precedence) ───────────
/// A host that never resolves fails validation (covers the DNS branch that
/// the literal-IP tests skip). `.invalid` is reserved to never resolve.
#[tokio::test]
async fn ssrf_rejects_unresolvable_host() {
let err = validate_hook_url("https://nonexistent.invalid/hook")
.await
.expect_err("unresolvable host must fail validation");
assert!(err.contains("DNS resolution failed"), "got: {err}");
}
use crate::config::HookSpec;
use crate::event::{HookEventEnvelope, HookEventName, HookPayload};
use crate::test_support::with_env_var;
/// Regression: an HTTP hook whose `url` references a var present only
/// in `spec.extra_env` (not the process env) must still be expanded
/// at runtime by `run_http_hook`. This is the path used by plugin
/// hooks where the plugin adapter wires `${CLAUDE_PLUGIN_ROOT}` into
/// `extra_env` after the load-time pass in `parse_hook_file` ran.
///
/// Documentation-of-intent unit test for the helper. The end-to-end
/// proof through `run_http_hook` lives in
/// [`run_http_hook_uses_post_expansion_url_for_ssrf`] below.
#[test]
fn url_extra_env_takes_precedence_in_runtime_expansion() {
// Use the same helper the runtime path uses; we don't need to
// make a real network call to verify the substitution, only that
// the helper resolves the right value from extra_env.
let mut extra = std::collections::HashMap::new();
extra.insert("PLUGIN_HOST".to_string(), "example.com".to_string());
let out =
crate::env_expand::expand_env_vars_with_extra("https://${PLUGIN_HOST}/check", &extra);
assert_eq!(out, "https://example.com/check");
}
/// If `extra_env` shadows a process-env var with the same name, the
/// `extra_env` value wins. This matches the contract documented on
/// `HookSpec::extra_env` and matches the lookup order in
/// `runner/command.rs`'s pre-flight check. Documentation-of-intent
/// unit test (the end-to-end variant via `run_http_hook` lives in
/// `tests/integration.rs`).
#[test]
fn url_extra_env_shadows_process_env() {
let key = "GROK_HOOKS_HTTP_TEST_SHADOW";
with_env_var(key, Some("from-process"), || {
let mut extra = std::collections::HashMap::new();
extra.insert(key.to_string(), "from-extra".to_string());
let out = crate::env_expand::expand_env_vars_with_extra(
&format!("https://${{{key}}}/x"),
&extra,
);
assert_eq!(out, "https://from-extra/x");
});
}
/// Regression: a URL with multiple `${VAR}` references must
/// expand all of them. Locks down behaviour against shellexpand
/// regressions that affect consecutive references.
#[test]
fn url_with_multiple_consecutive_env_refs_expands_all() {
let mut extra = std::collections::HashMap::new();
extra.insert("HOST".to_string(), "api.example.com".to_string());
extra.insert("PORT".to_string(), "8443".to_string());
extra.insert("ROUTE".to_string(), "v2/check".to_string());
let out = crate::env_expand::expand_env_vars_with_extra(
"https://${HOST}:${PORT}/${ROUTE}",
&extra,
);
assert_eq!(out, "https://api.example.com:8443/v2/check");
}
/// Regression: SSRF validation in `run_http_hook` must
/// operate on the POST-expansion URL. We construct a `HookSpec`
/// with `url: "https://${INTERNAL}/hook"` and `extra_env` mapping
/// `INTERNAL=10.0.0.1`, then call `run_http_hook` directly and
/// assert the failure carries SSRF-blocking language and that the
/// `HttpInfo.url` returned for scrollback is the post-expansion
/// form (`10.0.0.1`) rather than the literal placeholder.
/// SSRF validation in `run_http_hook` must operate on the post-expansion URL,
/// and `HttpInfo` must carry the resolved form while `raw_url` mirrors the
/// source.
#[tokio::test]
async fn run_http_hook_uses_post_expansion_url_for_ssrf() {
let mut extra_env = std::collections::HashMap::new();
@ -761,7 +638,7 @@ mod tests {
let spec = HookSpec {
name: "test-ssrf-post-expand".into(),
event: HookEventName::PreToolUse,
handler_type: "http".into(),
handler_type: crate::config::HandlerType::Http,
configured_matcher: None,
matcher: None,
enabled: true,
@ -783,12 +660,12 @@ mod tests {
transcript_path: None,
client_identifier: None,
prompt_id: None,
permission_mode: None,
payload: HookPayload::PreToolUse {
tool_name: "test".into(),
tool_use_id: "id-1".into(),
tool_input: serde_json::json!({}),
tool_input_truncated: false,
permission_mode: None,
subagent_type: None,
},
};
@ -796,7 +673,7 @@ mod tests {
session_id: "test",
workspace_root: "/tmp",
};
let (result, _, info) = run_http_hook(&spec, &envelope, &ctx, true).await;
let (result, _, info) = run_http_hook(&spec, &envelope, &ctx, GateKind::Tool).await;
match result {
crate::runner::HookRunnerResult::Failed(reason) => {
@ -813,9 +690,6 @@ mod tests {
info.url, "https://10.0.0.1/hook",
"HttpInfo.url must reflect the post-expansion URL (the actual target SSRF blocked)"
);
// HttpInfo.raw_url must mirror the source
// form so any future scrollback/wire-DTO consumer can prefer
// it for user-facing display.
assert_eq!(
info.raw_url.as_deref(),
Some("https://${INTERNAL_HOST_SSRF}/hook"),
@ -823,23 +697,14 @@ mod tests {
);
}
/// Regression: `reqwest::Error::Display`
/// unconditionally appends the request URL. If the resolved URL
/// embeds a secret resolved via `${TOKEN}` substitution from the
/// user `env` map, the secret would land in
/// `HookRunResult::Failed.error` and surface in pager scrollback
/// without the raw-fields work catching it. This test
/// builds a HookSpec that resolves to a guaranteed-dead host
/// (TEST-NET-1 192.0.2.0/24 from RFC 5737, used in docs) with a
/// secret-bearing query string, calls run_http_hook, and asserts
/// the secret does NOT appear in the returned error message.
/// `reqwest::Error::Display` appends the request URL, so a `${TOKEN}` secret
/// would leak into `Failed.error` and pager scrollback. Assert the secret
/// never appears in the error from a guaranteed-dead host (TEST-NET-1).
#[tokio::test]
async fn run_http_hook_scrubs_url_from_reqwest_error() {
// Use a TEST-NET-1 host (RFC 5737, "MUST NOT be used in
// public networks"). It is not RFC1918 so SSRF validation
// will let it through, but no real DNS or connection will
// succeed -- reqwest will surface a connection error whose
// default Display includes the URL.
// TEST-NET-1 (RFC 5737) is not RFC1918, so SSRF validation lets it
// through, but no connection succeeds: reqwest returns a connection
// error whose default Display would include the URL.
let secret = "ghp_VERY_REAL_SECRET_TOKEN_42";
let mut extra_env = std::collections::HashMap::new();
extra_env.insert("RUNTIME_HOST".to_string(), "192.0.2.1".to_string());
@ -849,7 +714,7 @@ mod tests {
let spec = HookSpec {
name: "test-scrub-reqwest-error".into(),
event: HookEventName::PreToolUse,
handler_type: "http".into(),
handler_type: crate::config::HandlerType::Http,
configured_matcher: None,
matcher: None,
enabled: true,
@ -857,9 +722,8 @@ mod tests {
command_raw: None,
url: Some(raw.to_string()),
url_raw: Some(raw.to_string()),
// Short timeout so the test doesn't hang waiting for the
// dead host. Still long enough to actually attempt the
// connection so we exercise the Err(e) branch of `send().await`.
// Short but long enough to attempt the connection, so we exercise
// the Err(e) branch of `send().await` rather than a timeout.
timeout_ms: 500,
source_dir: std::env::temp_dir(),
extra_env,
@ -873,12 +737,12 @@ mod tests {
transcript_path: None,
client_identifier: None,
prompt_id: None,
permission_mode: None,
payload: HookPayload::PreToolUse {
tool_name: "test".into(),
tool_use_id: "id-1".into(),
tool_input: serde_json::json!({}),
tool_input_truncated: false,
permission_mode: None,
subagent_type: None,
},
};
@ -887,42 +751,31 @@ mod tests {
workspace_root: "/tmp",
};
let (result, _, info) = run_http_hook(&spec, &envelope, &ctx, true).await;
let (result, _, info) = run_http_hook(&spec, &envelope, &ctx, GateKind::Tool).await;
// Either `Failed` (timeout / connection error) is fine; both
// exercise paths that previously embedded the raw URL via
// `format!("...{e}")`. Pure timeouts use a different
// formatting branch (no URL involved), so prefer the
// connection-error case but tolerate either.
// Either a timeout or a connection error is fine; both previously risked
// embedding the raw URL via `format!("...{e}")`.
let error_text = match result {
crate::runner::HookRunnerResult::Failed(reason) => reason,
other => panic!("expected Failed, got {other:?}"),
};
// The secret must NOT be in the error text. This covers BOTH
// the timeout branch (which doesn't format the URL at all,
// so trivially passes) and the connection-error branch
// (which formats `e.without_url()`, scrubbing the URL).
assert!(
!error_text.contains(secret),
"secret leaked into error text: {error_text}"
);
// The error must mention the raw URL form (so users can see
// which hook failed) -- never the resolved form, which would
// include the secret-bearing query string.
// The connection-error branch must reference the raw URL form (so users
// see which hook failed), never the resolved secret-bearing form.
if !error_text.contains("timed out") {
// Connection-error branch: error must reference the raw
// form, not the resolved one.
assert!(
error_text.contains("${RUNTIME_HOST}") || error_text.contains("${MY_TOKEN}"),
"expected error to reference the raw URL form, got: {error_text}"
);
}
// HttpInfo.url is still post-expansion (intentional, for SSRF
// debugging). The wire-DTO consumer must prefer raw_url for
// display -- documented in the HttpInfo rustdoc.
// HttpInfo.url stays post-expansion for SSRF debugging; consumers prefer
// raw_url for display (see the HttpInfo rustdoc).
let info = info.expect("HttpInfo should be present for connection failures too");
assert_eq!(
info.url,
@ -931,11 +784,8 @@ mod tests {
assert_eq!(info.raw_url.as_deref(), Some(raw));
}
/// The hook client must not follow HTTP redirects: `validate_hook_url`
/// only vets the initial URL, so a followed 3xx would reach an unvalidated
/// target. The local server answers every request with a 302 pointing at a
/// blocked address; with redirects disabled the client returns the 302
/// verbatim and never issues a second request to the target.
/// The hook client must not follow redirects: `validate_hook_url` only vets
/// the initial URL, so a followed 3xx would reach an unvalidated target.
#[tokio::test]
async fn hook_client_does_not_follow_redirects() {
use std::sync::Arc;
@ -983,18 +833,13 @@ mod tests {
);
}
/// Unresolved `${VAR}` refs are preserved verbatim by the helper,
/// which means `validate_hook_url` will reject the URL with an
/// "invalid URL" error. This is the desired behaviour: a hook
/// referencing an unset var must surface a clear failure rather than
/// silently smuggling the literal placeholder past validation.
/// An unresolved `${VAR}` ref is preserved verbatim, so `validate_hook_url`
/// rejects it rather than smuggling the literal placeholder past validation.
#[tokio::test]
async fn url_unresolved_var_fails_validation() {
let key = "GROK_HOOKS_HTTP_TEST_UNRESOLVED";
// Step 1 (sync): ensure the var is unset and run the
// expansion. `with_env_var` uses `catch_unwind` so the closure
// is synchronous; we deliberately do the async `validate_hook_url`
// call OUTSIDE the helper so we don't try to nest tokio runtimes.
// `with_env_var`'s closure is synchronous (it uses `catch_unwind`), so
// run the async `validate_hook_url` outside it to avoid nesting runtimes.
let expanded = with_env_var(key, None, || {
let extra = std::collections::HashMap::new();
crate::env_expand::expand_env_vars_with_extra(
@ -1002,10 +847,8 @@ mod tests {
&extra,
)
});
// The literal placeholder is preserved.
assert!(expanded.contains(&format!("${{{key}}}")));
// Url::parse rejects strings with literal `${` because `{`
// isn't a valid URL character.
// Url::parse rejects the literal `${` (`{` is not a valid URL character).
let result = validate_hook_url(&expanded).await;
assert!(result.is_err(), "expected invalid URL error, got Ok");
}

View file

@ -5,9 +5,15 @@ use std::time::Duration;
use crate::config::HookSpec;
use crate::event::HookEventEnvelope;
use crate::result::{HookDecision, HttpInfo};
use serde::Deserialize;
use crate::result::{HookDecision, HttpInfo, StopHookOutcome};
/// How a hook's output is interpreted, per the event's [`GateKind`]: `Observe`
/// ignores output, `Tool` parses the allow/deny vocabulary, `Stop` the stop
/// vocabulary.
pub use crate::event::GateKind;
/// Context passed to any hook runner for environment setup.
pub struct RunContext<'a> {
pub session_id: &'a str,
pub workspace_root: &'a str,
@ -16,40 +22,113 @@ pub struct RunContext<'a> {
/// Result of running a single hook (any handler type).
#[derive(Debug)]
pub enum HookRunnerResult {
/// Hook ran and produced a decision (for blocking hooks).
Decision(HookDecision),
/// Hook ran successfully (for non-blocking hooks).
Stop(StopHookOutcome),
Success,
/// Hook failed — caller should fail-open.
/// Failed: the caller fails open.
Failed(String),
}
/// Bundle returned by each runner: the result, wall-clock duration, and
/// optional HTTP metadata for enriched scrollback logging.
/// JSON from `PreToolUse` gate hooks:
/// `{"decision": "allow" | "deny", "reason": "…"}`.
#[derive(Debug, Deserialize)]
pub(crate) struct GateHookJson {
pub decision: String,
#[serde(default)]
pub reason: Option<String>,
}
/// Interpret a [`GateHookJson`] as a [`HookDecision`]. An unknown decision value
/// is an error so typos surface instead of failing open.
pub(crate) fn gate_json_to_decision(
json: GateHookJson,
hook_name: &str,
) -> Result<HookDecision, String> {
match json.decision.as_str() {
"deny" => Ok(HookDecision::Deny {
reason: json
.reason
.unwrap_or_else(|| format!("denied by hook '{hook_name}'")),
hook_name: hook_name.to_string(),
}),
"allow" => Ok(HookDecision::Allow),
other => Err(format!(
"unknown decision value '{other}' from hook '{hook_name}'"
)),
}
}
/// JSON from `Stop`/`SubagentStop` gate hooks. All fields optional; one output
/// can combine several signals.
#[derive(Debug, Default, Deserialize)]
pub(crate) struct StopHookJson {
#[serde(default)]
pub decision: Option<String>,
#[serde(default)]
pub reason: Option<String>,
#[serde(default, rename = "continue")]
pub continue_: Option<bool>,
#[serde(default, rename = "stopReason")]
pub stop_reason: Option<String>,
#[serde(default, rename = "hookSpecificOutput")]
pub hook_specific_output: Option<StopHookSpecificOutputJson>,
}
#[derive(Debug, Default, Deserialize)]
pub(crate) struct StopHookSpecificOutputJson {
#[serde(default, rename = "additionalContext")]
pub additional_context: Option<String>,
}
/// Interpret a [`StopHookJson`] as a [`StopHookOutcome`].
///
/// `decision: "block"` requires a reason (a missing one falls back to a generic
/// message). `decision: "approve"` is a no-op; any other value is an error so
/// typos surface.
pub(crate) fn stop_json_to_outcome(
json: StopHookJson,
hook_name: &str,
) -> Result<StopHookOutcome, String> {
let block_reason = match json.decision.as_deref() {
Some("block") => Some(
json.reason
.filter(|reason| !reason.trim().is_empty())
.unwrap_or_else(|| format!("Blocked by stop hook '{hook_name}'")),
),
Some("approve") | None => None,
Some(other) => {
return Err(format!(
"unknown decision value '{other}' from hook '{hook_name}'"
));
}
};
Ok(StopHookOutcome {
block_reason,
additional_context: json
.hook_specific_output
.and_then(|output| output.additional_context)
.filter(|context| !context.trim().is_empty()),
force_stop: (json.continue_ == Some(false)).then_some(crate::result::StopOverride {
reason: json.stop_reason,
}),
})
}
/// Each runner returns the result, wall-clock duration, and optional HTTP
/// metadata for enriched scrollback logging.
pub type HookRunOutput = (HookRunnerResult, Duration, Option<HttpInfo>);
/// Run a hook using the appropriate handler for its type.
///
/// Dispatches to `command::run_command_hook()` or `http::run_http_hook()`
/// based on `spec.handler_type`. Returns the result, elapsed duration, and
/// optional HTTP metadata for scrollback enrichment.
pub async fn run_hook(
spec: &HookSpec,
envelope: &HookEventEnvelope,
ctx: &RunContext<'_>,
is_blocking: bool,
mode: GateKind,
) -> HookRunOutput {
match spec.handler_type.as_str() {
"command" => {
let (result, elapsed) =
command::run_command_hook(spec, envelope, ctx, is_blocking).await;
match spec.handler_type {
crate::config::HandlerType::Command => {
let (result, elapsed) = command::run_command_hook(spec, envelope, ctx, mode).await;
(result, elapsed, None)
}
"http" => http::run_http_hook(spec, envelope, ctx, is_blocking).await,
_ => (
HookRunnerResult::Failed(format!("unsupported handler type '{}'", spec.handler_type)),
Duration::ZERO,
None,
),
crate::config::HandlerType::Http => http::run_http_hook(spec, envelope, ctx, mode).await,
}
}

View file

@ -1,11 +1,8 @@
//! Integration tests for xai-grok-hooks.
//!
//! These tests use inline shell command strings (routed via `sh -c`) to verify
//! the full dispatch pipeline: discovery → matching → execution → result.
//!
//! All hook commands are inline strings (containing spaces/pipes/semicolons)
//! rather than standalone executable script files. This avoids `noexec` tmpdir
//! issues in hermetic CI sandboxes where `chmod +x` on temp files may not work.
//! Hooks use inline shell command strings routed via `sh -c` rather than
//! standalone scripts, avoiding `noexec` tmpdir issues in hermetic CI sandboxes
//! where `chmod +x` may not work.
use std::path::Path;
@ -15,12 +12,10 @@ use xai_grok_hooks::event::*;
use xai_grok_hooks::result::HookDecision;
use xai_grok_hooks::runner::RunContext;
/// Helper: write a JSON hook file.
fn write_hook(dir: &Path, filename: &str, content: &str) {
std::fs::write(dir.join(filename), content).unwrap();
}
/// Helper: create a pre_tool_use envelope.
fn pre_tool_use_envelope(tool_name: &str) -> HookEventEnvelope {
HookEventEnvelope {
hook_event_name: HookEventName::PreToolUse,
@ -31,18 +26,17 @@ fn pre_tool_use_envelope(tool_name: &str) -> HookEventEnvelope {
transcript_path: None,
client_identifier: None,
prompt_id: None,
permission_mode: None,
payload: HookPayload::PreToolUse {
tool_name: tool_name.into(),
tool_use_id: "call-1".into(),
tool_input: serde_json::json!({"command": "echo hello"}),
tool_input_truncated: false,
permission_mode: None,
subagent_type: None,
},
}
}
/// Helper: create a session_start envelope.
fn session_start_envelope() -> HookEventEnvelope {
HookEventEnvelope {
hook_event_name: HookEventName::SessionStart,
@ -53,6 +47,7 @@ fn session_start_envelope() -> HookEventEnvelope {
transcript_path: None,
client_identifier: None,
prompt_id: None,
permission_mode: None,
payload: HookPayload::SessionStart {
source: "new".into(),
model_id: None,
@ -61,71 +56,10 @@ fn session_start_envelope() -> HookEventEnvelope {
}
}
#[tokio::test]
async fn hook_allows_via_json() {
let dir = tempfile::tempdir().unwrap();
write_hook(
dir.path(),
"safety.json",
r#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"echo '{\"decision\":\"allow\"}'","timeout":3}]}]}}"#,
);
let (registry, errors) = load_hooks(Some(dir.path()), None);
assert!(errors.is_empty(), "errors: {errors:?}");
let ctx = RunContext {
session_id: "test",
workspace_root: dir.path().to_str().unwrap(),
};
let result = dispatcher::dispatch_pre_tool_use(
&registry,
&pre_tool_use_envelope("run_terminal_cmd"),
&ctx,
)
.await;
assert_eq!(result.decision, HookDecision::Allow);
}
#[tokio::test]
async fn hook_denies_via_json() {
let dir = tempfile::tempdir().unwrap();
// Inline command: echo deny JSON and exit 2.
write_hook(
dir.path(),
"safety.json",
r#"{"hooks":{"PreToolUse":[{"matcher":"run_terminal_cmd","hooks":[{"type":"command","command":"echo '{\"decision\":\"deny\",\"reason\":\"dangerous\"}'; exit 2","timeout":3}]}]}}"#,
);
let (registry, errors) = load_hooks(Some(dir.path()), None);
assert!(errors.is_empty());
let ctx = RunContext {
session_id: "test",
workspace_root: dir.path().to_str().unwrap(),
};
let result = dispatcher::dispatch_pre_tool_use(
&registry,
&pre_tool_use_envelope("run_terminal_cmd"),
&ctx,
)
.await;
match result.decision {
HookDecision::Deny { reason, .. } => {
assert_eq!(reason, "dangerous");
}
other => panic!("expected Deny, got {other:?}"),
}
}
#[tokio::test]
async fn hook_deny_via_exit_code_only() {
let dir = tempfile::tempdir().unwrap();
// Inline command: no JSON output, just exit code 2.
write_hook(
dir.path(),
"safety.json",
@ -155,9 +89,6 @@ async fn hook_deny_via_exit_code_only() {
async fn hook_fail_open_on_crash() {
let dir = tempfile::tempdir().unwrap();
// Inline command: exit code 1 = hook failure. Under the fail-open
// policy the tool call must NOT be blocked; the failure is recorded
// for the UI but only an explicit `deny` decision blocks.
write_hook(
dir.path(),
"safety.json",
@ -191,8 +122,6 @@ async fn hook_fail_open_on_crash() {
async fn hook_fail_open_on_timeout() {
let dir = tempfile::tempdir().unwrap();
// Inline command: sleep longer than the timeout. Under fail-open
// the timeout must not block the tool call.
write_hook(
dir.path(),
"safety.json",
@ -235,7 +164,6 @@ async fn matcher_filters_tool_name() {
workspace_root: dir.path().to_str().unwrap(),
};
// Matching tool name → denied.
let pre_result = dispatcher::dispatch_pre_tool_use(
&registry,
&pre_tool_use_envelope("run_terminal_cmd"),
@ -244,7 +172,6 @@ async fn matcher_filters_tool_name() {
.await;
assert!(matches!(pre_result.decision, HookDecision::Deny { .. }));
// Non-matching tool name → allowed (hook doesn't run).
let pre_result =
dispatcher::dispatch_pre_tool_use(&registry, &pre_tool_use_envelope("read_file"), &ctx)
.await;
@ -288,7 +215,6 @@ async fn non_blocking_dispatch() {
async fn first_deny_stops_chain() {
let dir = tempfile::tempdir().unwrap();
// Two files: first denies, second allows. Second should never run.
write_hook(
dir.path(),
"01-deny.json",
@ -326,7 +252,6 @@ async fn first_deny_stops_chain() {
async fn hook_receives_stdin_envelope() {
let dir = tempfile::tempdir().unwrap();
// Inline command: read stdin, check it contains expected fields, allow if valid.
write_hook(
dir.path(),
"check.json",
@ -347,50 +272,10 @@ async fn hook_receives_stdin_envelope() {
assert_eq!(pre_result.decision, HookDecision::Allow);
}
#[tokio::test]
async fn hook_receives_env_vars() {
let dir = tempfile::tempdir().unwrap();
// Inline command: check env vars and write results to a file.
let output_file = dir.path().join("env_output.txt");
let cmd = format!(
r#"echo "EVENT=$GROK_HOOK_EVENT" > {f}; echo "NAME=$GROK_HOOK_NAME" >> {f}; echo "SESSION=$GROK_SESSION_ID" >> {f}; echo '{{"decision":"allow"}}'"#,
f = output_file.display(),
);
let hook_json = serde_json::json!({
"hooks": {
"PreToolUse": [
{ "hooks": [{ "type": "command", "command": cmd }] }
]
}
});
write_hook(dir.path(), "env.json", &hook_json.to_string());
let (registry, errors) = load_hooks(Some(dir.path()), None);
assert!(errors.is_empty());
let ctx = RunContext {
session_id: "sess-456",
workspace_root: dir.path().to_str().unwrap(),
};
let pre_result =
dispatcher::dispatch_pre_tool_use(&registry, &pre_tool_use_envelope("read_file"), &ctx)
.await;
assert_eq!(pre_result.decision, HookDecision::Allow);
// Verify env vars were received.
let output = std::fs::read_to_string(&output_file).unwrap();
assert!(output.contains("EVENT=pre_tool_use"), "output: {output}");
assert!(output.contains("NAME="), "output: {output}"); // auto-generated name
assert!(output.contains("SESSION=sess-456"), "output: {output}");
}
#[tokio::test]
async fn shell_pipe_command_works() {
let dir = tempfile::tempdir().unwrap();
// Shell command with a pipe -- must go through sh -c.
write_hook(
dir.path(),
"pipe.json",
@ -421,6 +306,7 @@ fn make_envelope(event: HookEventName, payload: HookPayload) -> HookEventEnvelop
transcript_path: None,
client_identifier: None,
prompt_id: None,
permission_mode: None,
payload,
}
}
@ -494,11 +380,15 @@ async fn new_event_types_fire_and_receive_correct_envelope() {
event_name: HookEventName::StopFailure,
json_key: "StopFailure",
payload: HookPayload::StopFailure {
error: "rate_limit".into(),
error: xai_grok_hooks::event::StopFailureKind::RateLimit,
error_details: Some("429 Too Many Requests".into()),
last_assistant_message: Some("Turn failed: rate limited".into()),
},
assertions: vec![
("hookEventName", "stop_failure".into()),
("error", "rate_limit".into()),
("errorDetails", "429 Too Many Requests".into()),
("lastAssistantMessage", "Turn failed: rate limited".into()),
],
},
];
@ -579,7 +469,6 @@ async fn runner_injected_vars_override_extra_env_at_spawn() {
let dir = tempfile::tempdir().unwrap();
let output_file = dir.path().join("envcap.txt");
// The hook writes the values it sees for each reserved key.
let cmd = format!(
r#"echo "EVENT=$GROK_HOOK_EVENT" > {f}; echo "NAME=$GROK_HOOK_NAME" >> {f}; echo "SESSION=$GROK_SESSION_ID" >> {f}; echo "ROOT=$GROK_WORKSPACE_ROOT" >> {f}; echo "PROJ=$CLAUDE_PROJECT_DIR" >> {f}; echo "USER_KEY=$USER_KEY" >> {f}; echo '{{"decision":"allow"}}'"#,
f = output_file.display(),
@ -627,7 +516,6 @@ async fn runner_injected_vars_override_extra_env_at_spawn() {
assert_eq!(result.decision, HookDecision::Allow);
let captured = std::fs::read_to_string(&output_file).unwrap();
// Reserved keys: runner values must win (NOT the spoofed values).
assert!(
captured.contains("EVENT=pre_tool_use"),
"GROK_HOOK_EVENT must reflect the real event, got:\n{captured}"
@ -660,7 +548,6 @@ async fn runner_injected_vars_override_extra_env_at_spawn() {
!captured.contains("PROJ=/spoofed/project"),
"spoofed CLAUDE_PROJECT_DIR must NOT leak through"
);
// Non-reserved key: user value passes through.
assert!(
captured.contains("USER_KEY=user_value_kept"),
"non-reserved user-declared env keys must pass through, got:\n{captured}"
@ -682,7 +569,6 @@ async fn direct_exec_command_with_env_var_resolves_at_load_time() {
// need to mutate global state.
let tmpdir_str = dir.path().to_string_lossy().into_owned();
// Create the script in tempdir with executable bit.
let script = dir.path().join("check.sh");
std::fs::write(
&script,
@ -706,9 +592,9 @@ async fn direct_exec_command_with_env_var_resolves_at_load_time() {
"type": "command",
// No shell metachars apart from `${...}`. The
// load-time pass resolves `${ROOT}` to the
// tmpdir path, leaving "/tmp.../check.sh"
// with NO `$` -- the runner picks the
// direct-exec branch.
// tmpdir path, leaving "/tmp.../check.sh" with
// no `$`, so the runner picks the direct-exec
// branch.
"command": "${ROOT}/check.sh",
"env": { "ROOT": tmpdir_str }
}
@ -722,7 +608,6 @@ async fn direct_exec_command_with_env_var_resolves_at_load_time() {
let (registry, errors) = load_hooks(Some(dir.path()), None);
assert!(errors.is_empty(), "errors: {errors:?}");
// Sanity: the loaded spec already has the resolved path with no `$`.
let specs: Vec<_> = registry
.hooks_for(HookEventName::PreToolUse)
.iter()
@ -868,7 +753,7 @@ async fn lenient_parsing_with_mixed_claude_events() {
"PreCompact": [
{ "hooks": [{ "type": "command", "command": "echo compact" }] }
],
// Unknown external-only events must not break the above.
// Unknown external-only events; must not break the above.
"PermissionRequest": [
{ "hooks": [{ "type": "command", "command": "echo perm-req" }] }
],