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:
parent
7cfcb20d2b
commit
ba76b0a683
143 changed files with 9465 additions and 3419 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
24
crates/codegen/xai-grok-hooks/examples/hooks/bin/stop-verify.sh
Executable file
24
crates/codegen/xai-grok-hooks/examples/hooks/bin/stop-verify.sh
Executable 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."}'
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
®istry,
|
||||
&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(
|
||||
®istry,
|
||||
&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(
|
||||
®istry,
|
||||
&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(®istry, &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(®istry, &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" }] }
|
||||
],
|
||||
|
|
|
|||
Loading…
Reference in a new issue