Skip to main content

everruns_core/
hook_dispatch.rs

1// User-defined hooks: production bash dispatcher.
2//
3// `BashkitShellHookDispatcher` is the production `BashHookDispatcher` impl.
4// It runs the user-authored hook command through the same bashkit
5// interpreter the `bashkit_shell` capability uses, against a session VFS
6// adapter so the hook script sees the same `/workspace` the agent sees.
7//
8// The hook payload is delivered via:
9//   - `$EVERRUNS_HOOK_PAYLOAD_JSON` env var (always set, full JSON payload)
10//   - `$EVERRUNS_HOOK_PAYLOAD_PATH` env var pointing at a file written into
11//     the session VFS, containing the same JSON payload. The file is cleaned
12//     up after the hook returns (best-effort).
13//   - Convenience scalars (`$EVERRUNS_HOOK_EVENT`, `$EVERRUNS_HOOK_ID`,
14//     `$EVERRUNS_HOOK_SESSION_ID`, `$EVERRUNS_HOOK_TURN_ID`,
15//     `$EVERRUNS_HOOK_TOOL_NAME`, `$EVERRUNS_HOOK_TOOL_CALL_ID`).
16//
17// bashkit does not expose a process-level stdin to user scripts (it
18// interprets the script directly rather than spawning a shell), so env-var
19// delivery is the closest sandbox-preserving equivalent to the stdin
20// contract you see in Claude Code hooks.
21
22use std::sync::Arc;
23
24use async_trait::async_trait;
25use bashkit::{Bash, ExecutionLimits, TraceMode};
26
27use crate::capabilities::SessionFileSystemAdapter;
28use crate::hook_executor::{
29    BashExecOutput, BashHookDispatcher, ExecutorOpts, HOOK_PAYLOAD_DIR, HOOK_PAYLOAD_WORKSPACE_DIR,
30    HookPayload, payload_filename, standard_hook_env,
31};
32use crate::traits::SessionFileSystem;
33
34/// Trimmed-down ExecutionLimits for hook commands. Hooks are short,
35/// side-effect-y scripts, not agent-authored programs: keep the
36/// max_input_bytes bound but cap commands/iterations/depth lower than
37/// `bashkit_shell` does so a runaway hook can't hold up tool execution.
38fn hook_execution_limits() -> ExecutionLimits {
39    ExecutionLimits::new()
40        .max_commands(200)
41        .max_loop_iterations(2_000)
42        .max_function_depth(32)
43        .max_input_bytes(64 * 1024) // user-authored command is bounded too
44        .max_ast_depth(64)
45        .parser_timeout(std::time::Duration::from_secs(2))
46}
47
48/// Bashkit-backed dispatcher. Constructed per session (or shared across
49/// sessions if `store` is shared) so that the produced
50/// `SessionFileSystemAdapter` operates on the right session VFS.
51pub struct BashkitShellHookDispatcher {
52    store: Arc<dyn SessionFileSystem>,
53}
54
55impl BashkitShellHookDispatcher {
56    pub fn new(store: Arc<dyn SessionFileSystem>) -> Self {
57        // Run the hook's bash over the same MountFs resolver the agent's shell
58        // uses (EVE-660), so the `/workspace/...` path exposed to the script and
59        // the storage path we write the payload to resolve to one backend file.
60        // (Before the resolver this relied on the bashkit adapter stripping
61        // `/workspace`; that stripping now lives in MountFs.)
62        Self {
63            store: crate::mount_fs::MountFs::wrap(store),
64        }
65    }
66
67    /// Best-effort cleanup of the on-disk payload file. Failures are
68    /// logged but never raise — the hook outcome must not depend on
69    /// cleanup success.
70    async fn cleanup_payload_file(&self, session_id: crate::typed_id::SessionId, path: &str) {
71        if let Err(e) = self.store.delete_file(session_id, path, false).await {
72            tracing::debug!(
73                error = %e,
74                path = %path,
75                "BashkitShellHookDispatcher: payload file cleanup failed (non-fatal)"
76            );
77        }
78    }
79}
80
81#[async_trait]
82impl BashHookDispatcher for BashkitShellHookDispatcher {
83    async fn dispatch(
84        &self,
85        payload: &HookPayload,
86        command: &str,
87        extra_env: &std::collections::BTreeMap<String, String>,
88        opts: &ExecutorOpts,
89    ) -> Result<BashExecOutput, String> {
90        let session_id = payload.session_id;
91
92        // MountFs resolves both the `/workspace/...` path we expose to the
93        // script ($EVERRUNS_HOOK_PAYLOAD_PATH) and the backend-native storage
94        // path we write/delete ourselves to the same file, so the script reads
95        // exactly the payload we wrote.
96        let filename = payload_filename(payload);
97        let script_path = format!("{HOOK_PAYLOAD_WORKSPACE_DIR}/{filename}");
98        let storage_path = format!("{HOOK_PAYLOAD_DIR}/{filename}");
99        let standard_env = standard_hook_env(payload, &script_path)?;
100
101        // Write the payload to the session VFS so jq-style scripts can
102        // `cat $EVERRUNS_HOOK_PAYLOAD_PATH | jq …`. If we cannot write,
103        // proceed anyway — the env-var copy is always present and the
104        // path env will resolve to a missing file (the script's choice).
105        let payload_json = standard_env
106            .iter()
107            .find(|(k, _)| k == "EVERRUNS_HOOK_PAYLOAD_JSON")
108            .map(|(_, v)| v.clone())
109            .unwrap_or_default();
110        let wrote_file = match self
111            .store
112            .write_file(session_id, &storage_path, &payload_json, "utf-8")
113            .await
114        {
115            Ok(_) => true,
116            Err(e) => {
117                tracing::warn!(
118                    error = %e,
119                    path = %storage_path,
120                    "BashkitShellHookDispatcher: VFS payload write failed; env-var fallback still set"
121                );
122                false
123            }
124        };
125
126        // Build the bash interpreter against the session VFS.
127        let session_fs = Arc::new(SessionFileSystemAdapter::new(
128            session_id,
129            self.store.clone(),
130        ));
131        let mut builder = Bash::builder()
132            .fs(session_fs)
133            .cwd("/workspace")
134            .username("everruns")
135            .hostname("everruns-hook")
136            .env("HOME", "/home/agent")
137            .env("SHELL", "/bin/bash")
138            .env("PATH", "/usr/local/bin:/usr/bin:/bin")
139            .env("WORKSPACE", "/workspace")
140            .limits(hook_execution_limits())
141            .max_memory(10 * 1024 * 1024)
142            .trace_mode(TraceMode::Redacted);
143        for (k, v) in &standard_env {
144            builder = builder.env(k, v);
145        }
146        for (k, v) in extra_env {
147            // Operator-supplied env can override standard env if the operator
148            // explicitly named the same key; this is intentional (escape
149            // hatch) but applies only to per-spec env, never to other
150            // capabilities' contributions because each spec gets its own
151            // executor.
152            builder = builder.env(k, v);
153        }
154        let mut bash = builder.build();
155        let cancel_token = bash.cancellation_token();
156
157        let timeout = std::time::Duration::from_millis(opts.timeout_ms.max(1) as u64);
158        let exec = tokio::time::timeout(timeout, bash.exec(command)).await;
159
160        // Best-effort cleanup happens regardless of outcome.
161        if wrote_file {
162            self.cleanup_payload_file(session_id, &storage_path).await;
163        }
164
165        match exec {
166            Ok(Ok(output)) => {
167                let mut stdout = output.stdout;
168                let mut stderr = output.stderr;
169                // Apply the executor-level output cap. Bashkit's own
170                // `max_input_bytes` bounds the *script* size; for output we
171                // truncate here so the parse step sees a bounded buffer.
172                let cap = opts.max_output_bytes;
173                if stdout.len() + stderr.len() > cap {
174                    let stdout_budget = cap.min(stdout.len());
175                    truncate_at_char_boundary(&mut stdout, stdout_budget);
176                    let stderr_budget = cap.saturating_sub(stdout.len());
177                    truncate_at_char_boundary(&mut stderr, stderr_budget);
178                    return Err(format!(
179                        "hook output exceeded {} bytes (stdout={}, stderr={})",
180                        cap,
181                        stdout.len(),
182                        stderr.len(),
183                    ));
184                }
185                Ok(BashExecOutput {
186                    exit_code: output.exit_code,
187                    stdout,
188                    stderr,
189                })
190            }
191            Ok(Err(e)) => Err(format!("hook execution error: {e}")),
192            Err(_) => {
193                cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
194                Err(format!("hook timed out after {} ms", opts.timeout_ms))
195            }
196        }
197    }
198}
199
200fn truncate_at_char_boundary(s: &mut String, mut end: usize) {
201    if end >= s.len() {
202        return;
203    }
204    while end > 0 && !s.is_char_boundary(end) {
205        end -= 1;
206    }
207    s.truncate(end);
208}
209
210// ============================================================================
211// Tests (integration with real bashkit)
212// ============================================================================
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use crate::error::Result;
218    use crate::hook_executor::{BashHookExecutor, HOOK_PAYLOAD_DIR, HookExecutor};
219    use crate::session_file::{FileInfo, FileStat, GrepMatch, SessionFile};
220    use crate::traits::SessionFileSystem;
221    use crate::typed_id::SessionId;
222    use crate::user_hook_types::{HookEvent, HookId, HookOutcome};
223    use chrono::Utc;
224    use serde_json::json;
225    use std::collections::HashMap;
226    use std::sync::Mutex;
227    use uuid::Uuid;
228
229    #[derive(Default)]
230    struct MockFileStore {
231        files: Mutex<HashMap<String, String>>,
232    }
233
234    impl MockFileStore {
235        fn read(&self, path: &str) -> Option<String> {
236            self.files.lock().unwrap().get(path).cloned()
237        }
238    }
239
240    #[async_trait]
241    impl SessionFileSystem for MockFileStore {
242        fn is_mount_resolver(&self) -> bool {
243            false
244        }
245
246        async fn read_file(
247            &self,
248            _session_id: SessionId,
249            path: &str,
250        ) -> Result<Option<SessionFile>> {
251            let entry = self.files.lock().unwrap().get(path).cloned();
252            Ok(entry.map(|content| {
253                let size = content.len() as i64;
254                SessionFile {
255                    id: Uuid::new_v4(),
256                    session_id: Uuid::nil(),
257                    path: path.to_string(),
258                    name: path.rsplit('/').next().unwrap_or("").to_string(),
259                    content: Some(content),
260                    encoding: "utf-8".to_string(),
261                    is_directory: false,
262                    is_readonly: false,
263                    size_bytes: size,
264                    created_at: Utc::now(),
265                    updated_at: Utc::now(),
266                }
267            }))
268        }
269
270        async fn write_file(
271            &self,
272            _session_id: SessionId,
273            path: &str,
274            content: &str,
275            _encoding: &str,
276        ) -> Result<SessionFile> {
277            self.files
278                .lock()
279                .unwrap()
280                .insert(path.to_string(), content.to_string());
281            Ok(SessionFile {
282                id: Uuid::new_v4(),
283                session_id: Uuid::nil(),
284                path: path.to_string(),
285                name: path.rsplit('/').next().unwrap_or("").to_string(),
286                content: Some(content.to_string()),
287                encoding: "utf-8".to_string(),
288                is_directory: false,
289                is_readonly: false,
290                size_bytes: content.len() as i64,
291                created_at: Utc::now(),
292                updated_at: Utc::now(),
293            })
294        }
295
296        async fn delete_file(
297            &self,
298            _session_id: SessionId,
299            path: &str,
300            _recursive: bool,
301        ) -> Result<bool> {
302            Ok(self.files.lock().unwrap().remove(path).is_some())
303        }
304
305        async fn list_directory(
306            &self,
307            _session_id: SessionId,
308            _path: &str,
309        ) -> Result<Vec<FileInfo>> {
310            Ok(vec![])
311        }
312
313        async fn stat_file(&self, _session_id: SessionId, _path: &str) -> Result<Option<FileStat>> {
314            Ok(None)
315        }
316
317        async fn grep_files(
318            &self,
319            _session_id: SessionId,
320            _pattern: &str,
321            _path_pattern: Option<&str>,
322        ) -> Result<Vec<GrepMatch>> {
323            Ok(vec![])
324        }
325
326        async fn create_directory(&self, _session_id: SessionId, _path: &str) -> Result<FileInfo> {
327            Err(anyhow::anyhow!("not implemented").into())
328        }
329    }
330
331    fn payload(event: HookEvent, data: serde_json::Value) -> HookPayload {
332        HookPayload {
333            event,
334            hook_id: HookId::for_user("t"),
335            session_id: SessionId::from(Uuid::nil()),
336            turn_id: Some("trn_test".into()),
337            org_id: None,
338            agent_id: Some("agt_test".into()),
339            ts: "2026-05-28T00:00:00Z".into(),
340            data,
341        }
342    }
343
344    fn opts() -> ExecutorOpts {
345        ExecutorOpts {
346            timeout_ms: 5_000,
347            max_output_bytes: 64 * 1024,
348        }
349    }
350
351    async fn run(
352        store: Arc<dyn SessionFileSystem>,
353        command: &str,
354        env: std::collections::BTreeMap<String, String>,
355        payload: HookPayload,
356    ) -> HookOutcome {
357        let dispatcher = Arc::new(BashkitShellHookDispatcher::new(store));
358        let exec = BashHookExecutor::with_dispatcher(command.to_string(), env, dispatcher);
359        exec.run(payload, &opts()).await
360    }
361
362    #[tokio::test]
363    async fn allow_by_default_with_zero_exit() {
364        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::default());
365        let outcome = run(
366            store,
367            "echo -n",
368            Default::default(),
369            payload(HookEvent::PreToolUse, json!({})),
370        )
371        .await;
372        assert!(matches!(outcome, HookOutcome::Allow), "{:?}", outcome);
373    }
374
375    #[tokio::test]
376    async fn block_via_json_decision() {
377        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::default());
378        let cmd = r#"printf '%s' '{"decision":"block","reason":"nope","user_message":"blocked"}'"#;
379        let outcome = run(
380            store,
381            cmd,
382            Default::default(),
383            payload(HookEvent::PreToolUse, json!({})),
384        )
385        .await;
386        match outcome {
387            HookOutcome::Block {
388                reason,
389                user_message,
390            } => {
391                assert_eq!(reason, "nope");
392                assert_eq!(user_message.as_deref(), Some("blocked"));
393            }
394            other => panic!("expected Block, got {other:?}"),
395        }
396    }
397
398    #[tokio::test]
399    async fn block_via_exit_code_fallback() {
400        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::default());
401        // Empty stdout + non-zero exit + stderr reason.
402        let cmd = "echo blocked-reason >&2; exit 1";
403        let outcome = run(
404            store,
405            cmd,
406            Default::default(),
407            payload(HookEvent::PreToolUse, json!({})),
408        )
409        .await;
410        match outcome {
411            HookOutcome::Block { reason, .. } => {
412                assert!(reason.contains("blocked-reason"), "reason = {reason:?}");
413            }
414            other => panic!("expected Block, got {other:?}"),
415        }
416    }
417
418    #[tokio::test]
419    async fn mutate_with_patch() {
420        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::default());
421        let cmd = r#"printf '%s' '{"decision":"mutate","patch":{"arguments":{"command":"ls"}}}'"#;
422        let outcome = run(
423            store,
424            cmd,
425            Default::default(),
426            payload(
427                HookEvent::PreToolUse,
428                json!({"tool_name": "bash", "tool_call_id": "call_1", "arguments": {}}),
429            ),
430        )
431        .await;
432        match outcome {
433            HookOutcome::Mutate { patch, .. } => {
434                assert_eq!(patch["arguments"]["command"], "ls");
435            }
436            other => panic!("expected Mutate, got {other:?}"),
437        }
438    }
439
440    #[tokio::test]
441    async fn non_json_stdout_is_error() {
442        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::default());
443        let outcome = run(
444            store,
445            "echo not-json",
446            Default::default(),
447            payload(HookEvent::PostToolUse, json!({})),
448        )
449        .await;
450        assert!(
451            matches!(outcome, HookOutcome::Error { .. }),
452            "{:?}",
453            outcome
454        );
455    }
456
457    #[tokio::test]
458    async fn payload_env_var_visible_to_script() {
459        // The script echoes the EVERRUNS_HOOK_EVENT into a sentinel file on
460        // the session VFS so we can assert delivery without depending on
461        // bashkit's stdout for non-decision text.
462        let cmd = "echo $EVERRUNS_HOOK_EVENT > /workspace/.seen-event";
463        let mock = Arc::new(MockFileStore::default());
464        let store: Arc<dyn SessionFileSystem> = mock.clone();
465        let outcome = run(
466            store,
467            cmd,
468            Default::default(),
469            payload(HookEvent::PreToolUse, json!({})),
470        )
471        .await;
472        assert!(matches!(outcome, HookOutcome::Allow), "{:?}", outcome);
473        assert_eq!(
474            mock.read("/.seen-event")
475                .or_else(|| mock.read("/workspace/.seen-event"))
476                .map(|s| s.trim().to_string()),
477            Some("pre_tool_use".to_string())
478        );
479        let _ = store;
480    }
481
482    #[tokio::test]
483    async fn tool_name_env_var_set_for_tool_events() {
484        let mock = Arc::new(MockFileStore::default());
485        let store: Arc<dyn SessionFileSystem> = mock.clone();
486        let cmd = "echo $EVERRUNS_HOOK_TOOL_NAME > /workspace/.tool-name";
487        let _ = run(
488            store,
489            cmd,
490            Default::default(),
491            payload(
492                HookEvent::PreToolUse,
493                json!({"tool_name": "edit_file", "tool_call_id": "c"}),
494            ),
495        )
496        .await;
497        assert_eq!(
498            mock.read("/.tool-name")
499                .or_else(|| mock.read("/workspace/.tool-name"))
500                .map(|s| s.trim().to_string()),
501            Some("edit_file".to_string())
502        );
503    }
504
505    #[tokio::test]
506    async fn payload_file_written_then_cleaned_up() {
507        let mock = Arc::new(MockFileStore::default());
508        let store: Arc<dyn SessionFileSystem> = mock.clone();
509        // Copy the payload file contents into a workspace file BEFORE the
510        // dispatcher cleans up the source.
511        let cmd = r#"cat "$EVERRUNS_HOOK_PAYLOAD_PATH" > /workspace/.snapshot.json"#;
512        let outcome = run(
513            store,
514            cmd,
515            Default::default(),
516            payload(HookEvent::PostToolUse, json!({"tool_name":"x"})),
517        )
518        .await;
519        assert!(matches!(outcome, HookOutcome::Allow), "{:?}", outcome);
520
521        let files = mock.files.lock().unwrap();
522        // The snapshot file we copied through should exist with the
523        // payload JSON inside.
524        let snapshot = files
525            .get("/.snapshot.json")
526            .or_else(|| files.get("/workspace/.snapshot.json"))
527            .expect("snapshot file written");
528        assert!(snapshot.contains("\"event\":\"post_tool_use\""));
529        // The original payload file in /.hooks/ should have been removed.
530        let lingering: Vec<_> = files
531            .keys()
532            .filter(|k| k.starts_with(HOOK_PAYLOAD_DIR))
533            .collect();
534        assert!(
535            lingering.is_empty(),
536            "payload file not cleaned up: {lingering:?}"
537        );
538    }
539
540    #[tokio::test]
541    async fn extra_env_overrides_standard_env() {
542        let mock = Arc::new(MockFileStore::default());
543        let store: Arc<dyn SessionFileSystem> = mock.clone();
544        let mut env = std::collections::BTreeMap::new();
545        env.insert("EVERRUNS_HOOK_EVENT".to_string(), "overridden".to_string());
546        let _ = run(
547            store,
548            "echo $EVERRUNS_HOOK_EVENT > /workspace/.event",
549            env,
550            payload(HookEvent::PreToolUse, json!({})),
551        )
552        .await;
553        assert_eq!(
554            mock.read("/.event")
555                .or_else(|| mock.read("/workspace/.event"))
556                .map(|s| s.trim().to_string()),
557            Some("overridden".to_string())
558        );
559    }
560
561    #[tokio::test]
562    async fn timeout_returns_error_outcome() {
563        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::default());
564        let dispatcher = Arc::new(BashkitShellHookDispatcher::new(store));
565        let exec = BashHookExecutor::with_dispatcher(
566            // Busy loop that bashkit's loop limit + our wall-clock timeout
567            // should jointly bound. We deliberately set a very short
568            // timeout to exercise the timeout path.
569            "while true; do :; done".to_string(),
570            Default::default(),
571            dispatcher,
572        );
573        let opts = ExecutorOpts {
574            timeout_ms: 50,
575            max_output_bytes: 64 * 1024,
576        };
577        let outcome = exec
578            .run(payload(HookEvent::PreToolUse, json!({})), &opts)
579            .await;
580        // Either the wall-clock timeout or bashkit's own loop cap may fire
581        // first depending on platform timing. Both must yield Error (which
582        // the adapter then converts per on_error).
583        match outcome {
584            HookOutcome::Error { message } => {
585                assert!(
586                    message.contains("timed out") || message.contains("execution error"),
587                    "unexpected error message: {message}"
588                );
589            }
590            other => panic!("expected Error, got {other:?}"),
591        }
592    }
593
594    // ------------------------------------------------------------------
595    // End-to-end: a real post_tool_use hook spec runs through the whole
596    // chain (UserHookSpec -> PostToolUseHookAdapter -> BashHookExecutor ->
597    // BashkitShellHookDispatcher -> bashkit -> session VFS) and writes an
598    // audit log line for the tool call. Mirrors the
599    // examples/hook-bundles/audit-every-tool.json bundle.
600    // ------------------------------------------------------------------
601    #[tokio::test]
602    async fn end_to_end_audit_log_hook_writes_workspace_file() {
603        use crate::atoms::PostToolExecHook;
604        use crate::hook_adapter::PostToolUseHookAdapter;
605        use crate::tool_types::{BuiltinTool, DeferrablePolicy, ToolHints, ToolPolicy};
606        use crate::user_hook_types::{ExecutorSpec, HookEvent, HookSource, OnError, UserHookSpec};
607
608        let mock = Arc::new(MockFileStore::default());
609        let store: Arc<dyn SessionFileSystem> = mock.clone();
610
611        let spec = UserHookSpec {
612            id: Some("audit_tool_calls".into()),
613            event: HookEvent::PostToolUse,
614            matcher: Default::default(),
615            executor: ExecutorSpec::Bash {
616                command:
617                    "printf '%s\\n' \"$EVERRUNS_HOOK_TOOL_NAME:$EVERRUNS_HOOK_TOOL_CALL_ID\" >> /workspace/.audit.log; echo '{}'"
618                        .into(),
619                env: Default::default(),
620            },
621            timeout_ms: 5000,
622            on_error: OnError::Warn,
623            description: None,
624            source: HookSource::UserConfig,
625        };
626
627        let dispatcher: Arc<dyn BashHookDispatcher> =
628            Arc::new(BashkitShellHookDispatcher::new(store.clone()));
629        let executor: Arc<dyn HookExecutor> = Arc::new(BashHookExecutor::with_dispatcher(
630            match &spec.executor {
631                ExecutorSpec::Bash { command, .. } => command.clone(),
632            },
633            match &spec.executor {
634                ExecutorSpec::Bash { env, .. } => env.clone(),
635            },
636            dispatcher,
637        ));
638        let adapter = PostToolUseHookAdapter::new(spec, executor);
639
640        let tool_call = crate::tool_types::ToolCall {
641            id: "call_first".into(),
642            name: "edit_file".into(),
643            arguments: serde_json::json!({"path": "/workspace/foo.rs"}),
644        };
645        let tool_def = crate::tool_types::ToolDefinition::Builtin(BuiltinTool {
646            name: "edit_file".into(),
647            display_name: None,
648            description: "x".into(),
649            parameters: serde_json::json!({}),
650            policy: ToolPolicy::Auto,
651            category: None,
652            deferrable: DeferrablePolicy::Never,
653            hints: ToolHints::default(),
654            full_parameters: None,
655        });
656        let mut result = crate::tool_types::ToolResult {
657            tool_call_id: "call_first".into(),
658            result: Some(serde_json::json!({"changed": true})),
659            images: None,
660            error: None,
661            connection_required: None,
662            raw_output: None,
663        };
664        let ctx = crate::traits::ToolContext::new(SessionId::from(Uuid::nil()));
665
666        adapter
667            .after_exec(&tool_call, &tool_def, &mut result, &ctx)
668            .await;
669
670        // First call: fresh audit log file.
671        let log = mock
672            .read("/.audit.log")
673            .or_else(|| mock.read("/workspace/.audit.log"))
674            .expect("audit log written");
675        assert!(log.contains("edit_file:call_first"), "log = {log:?}");
676
677        // Second call appends.
678        let tool_call_2 = crate::tool_types::ToolCall {
679            id: "call_second".into(),
680            name: "bash".into(),
681            arguments: serde_json::json!({"command": "ls"}),
682        };
683        let tool_def_2 = crate::tool_types::ToolDefinition::Builtin(BuiltinTool {
684            name: "bash".into(),
685            display_name: None,
686            description: "x".into(),
687            parameters: serde_json::json!({}),
688            policy: ToolPolicy::Auto,
689            category: None,
690            deferrable: DeferrablePolicy::Never,
691            hints: ToolHints::default(),
692            full_parameters: None,
693        });
694        let mut result_2 = crate::tool_types::ToolResult {
695            tool_call_id: "call_second".into(),
696            result: Some(serde_json::json!({"ok": true})),
697            images: None,
698            error: None,
699            connection_required: None,
700            raw_output: None,
701        };
702        adapter
703            .after_exec(&tool_call_2, &tool_def_2, &mut result_2, &ctx)
704            .await;
705
706        let log2 = mock
707            .read("/.audit.log")
708            .or_else(|| mock.read("/workspace/.audit.log"))
709            .expect("audit log present after second call");
710        assert!(log2.contains("edit_file:call_first"), "log2 = {log2:?}");
711        assert!(log2.contains("bash:call_second"), "log2 = {log2:?}");
712
713        // The dispatcher cleans up its payload file each call — the audit
714        // log should be the only artifact under /.
715        let files = mock.files.lock().unwrap();
716        let payload_files: Vec<_> = files
717            .keys()
718            .filter(|k| k.starts_with(HOOK_PAYLOAD_DIR))
719            .collect();
720        assert!(payload_files.is_empty(), "leftover: {payload_files:?}");
721    }
722
723    // ------------------------------------------------------------------
724    // End-to-end: a real pre_tool_use hook spec runs through the whole
725    // chain (UserHookSpec -> PreToolUseHookAdapter -> BashHookExecutor ->
726    // BashkitShellHookDispatcher -> bashkit) and blocks execution by
727    // emitting a JSON `{"decision":"block",...}` decision when the agent
728    // tries to run `rm -rf /`. Mirrors examples/hook-bundles/block-rm-rf.json
729    // (with a simplified deny check the bashkit interpreter can parse).
730    // ------------------------------------------------------------------
731    #[tokio::test]
732    async fn end_to_end_pre_tool_use_blocks_destructive_bash() {
733        use crate::atoms::{PreToolUseDecision, PreToolUseHook};
734        use crate::hook_adapter::PreToolUseHookAdapter;
735        use crate::tool_types::{BuiltinTool, DeferrablePolicy, ToolHints, ToolPolicy};
736        use crate::user_hook_types::{
737            ExecutorSpec, HookEvent, HookMatcher, HookSource, OnError, UserHookSpec,
738        };
739
740        let mock = Arc::new(MockFileStore::default());
741        let store: Arc<dyn SessionFileSystem> = mock.clone();
742
743        // The hook script inspects the bash command via $EVERRUNS_HOOK_PAYLOAD_JSON
744        // (the structured path the audit examples use) and emits a Block
745        // decision when it spots `rm -rf`. Allow otherwise.
746        let spec = UserHookSpec {
747            id: Some("guard_rm".into()),
748            event: HookEvent::PreToolUse,
749            matcher: HookMatcher {
750                tool_name: Some("bash".into()),
751                args_jsonpath: Some("$.command".into()),
752                deny_regex: Some(r"(?:^|;|&&|\|)\s*rm\s+-rf\b".into()),
753                ..Default::default()
754            },
755            executor: ExecutorSpec::Bash {
756                command:
757                    "printf '%s' '{\"decision\":\"block\",\"reason\":\"rm -rf is blocked\",\"user_message\":\"Blocked by policy.\"}'"
758                        .into(),
759                env: Default::default(),
760            },
761            timeout_ms: 5000,
762            on_error: OnError::Block,
763            description: None,
764            source: HookSource::UserConfig,
765        };
766
767        let dispatcher: Arc<dyn BashHookDispatcher> =
768            Arc::new(BashkitShellHookDispatcher::new(store.clone()));
769        let executor: Arc<dyn HookExecutor> = Arc::new(BashHookExecutor::with_dispatcher(
770            match &spec.executor {
771                ExecutorSpec::Bash { command, .. } => command.clone(),
772            },
773            match &spec.executor {
774                ExecutorSpec::Bash { env, .. } => env.clone(),
775            },
776            dispatcher,
777        ));
778        let adapter = PreToolUseHookAdapter::new(spec, executor);
779
780        let tool_def = crate::tool_types::ToolDefinition::Builtin(BuiltinTool {
781            name: "bash".into(),
782            display_name: None,
783            description: "x".into(),
784            parameters: serde_json::json!({}),
785            policy: ToolPolicy::Auto,
786            category: None,
787            deferrable: DeferrablePolicy::Never,
788            hints: ToolHints::default(),
789            full_parameters: None,
790        });
791        let ctx = crate::traits::ToolContext::new(SessionId::from(Uuid::nil()));
792
793        // 1. Destructive call → matcher fires → executor returns Block.
794        let bad = crate::tool_types::ToolCall {
795            id: "call_bad".into(),
796            name: "bash".into(),
797            arguments: serde_json::json!({"command": "rm -rf /"}),
798        };
799        match adapter.before_exec(bad, &tool_def, &ctx).await {
800            PreToolUseDecision::Block {
801                reason,
802                user_message,
803                ..
804            } => {
805                assert!(reason.contains("blocked"), "reason: {reason}");
806                assert_eq!(user_message.as_deref(), Some("Blocked by policy."));
807            }
808            other => panic!("expected Block, got {other:?}"),
809        }
810
811        // 2. Benign call → matcher does NOT fire (deny_regex doesn't match) →
812        // executor is skipped → Continue.
813        let good = crate::tool_types::ToolCall {
814            id: "call_good".into(),
815            name: "bash".into(),
816            arguments: serde_json::json!({"command": "ls -la"}),
817        };
818        match adapter.before_exec(good.clone(), &tool_def, &ctx).await {
819            PreToolUseDecision::Continue(tc) => {
820                assert_eq!(tc.id, "call_good");
821                assert_eq!(tc.arguments["command"], "ls -la");
822            }
823            other => panic!("expected Continue, got {other:?}"),
824        }
825    }
826
827    // Pull the first hook's bash command out of an `examples/hook-bundles/*.json`
828    // config so the example-bundle tests exercise the exact shipped command.
829    fn bundle_command(file_name: &str) -> String {
830        let path = format!(
831            "{}/../../examples/hook-bundles/{}",
832            env!("CARGO_MANIFEST_DIR"),
833            file_name
834        );
835        let raw = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}"));
836        let value: serde_json::Value = serde_json::from_str(&raw).unwrap();
837        value["hooks"][0]["executor"]["command"]
838            .as_str()
839            .unwrap_or_else(|| panic!("{file_name}: hooks[0].executor.command missing"))
840            .to_string()
841    }
842
843    #[tokio::test]
844    async fn example_block_secret_prompt_blocks_private_key() {
845        let cmd = bundle_command("block-secret-prompt.json");
846        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::default());
847
848        // A prompt that pastes a private key is blocked, with the user_message
849        // surfaced to the caller.
850        let blocked = run(
851            store.clone(),
852            &cmd,
853            Default::default(),
854            payload(
855                HookEvent::UserPromptSubmit,
856                json!({ "message": "please use this key:\n-----BEGIN RSA PRIVATE KEY-----\nMII...\n-----END RSA PRIVATE KEY-----" }),
857            ),
858        )
859        .await;
860        match blocked {
861            HookOutcome::Block {
862                reason,
863                user_message,
864            } => {
865                assert!(reason.contains("private key"), "reason: {reason}");
866                assert!(
867                    user_message
868                        .as_deref()
869                        .unwrap_or_default()
870                        .contains("blocked"),
871                    "user_message: {user_message:?}"
872                );
873            }
874            other => panic!("expected Block, got {other:?}"),
875        }
876
877        // A benign prompt is allowed through unchanged.
878        let allowed = run(
879            store,
880            &cmd,
881            Default::default(),
882            payload(
883                HookEvent::UserPromptSubmit,
884                json!({ "message": "summarize the README" }),
885            ),
886        )
887        .await;
888        assert!(matches!(allowed, HookOutcome::Allow), "got {allowed:?}");
889    }
890
891    #[tokio::test]
892    async fn example_turn_end_log_appends_line() {
893        let cmd = bundle_command("turn-end-log.json");
894        let mock = Arc::new(MockFileStore::default());
895        let store: Arc<dyn SessionFileSystem> = mock.clone();
896
897        let outcome = run(
898            store,
899            &cmd,
900            Default::default(),
901            payload(HookEvent::TurnEnd, json!({ "success": true })),
902        )
903        .await;
904        // turn_end is advisory; the command emits `{}` (allow).
905        assert!(matches!(outcome, HookOutcome::Allow), "got {outcome:?}");
906
907        // The hook wrote a summary line built from the payload (ts / turn_id /
908        // success) — proving jq field access and the `>>` append both work.
909        let line = mock
910            .read("/.turn-log")
911            .or_else(|| mock.read("/workspace/.turn-log"))
912            .map(|s| s.trim().to_string())
913            .expect("turn-end hook should have written /.turn-log");
914        assert_eq!(line, "2026-05-28T00:00:00Z turn trn_test success=true");
915    }
916
917    #[tokio::test]
918    async fn doc_mutate_prompt_rewrites_message() {
919        // Mirrors the `prepend_style_note` mutate snippet in
920        // docs/capabilities/user-hooks.md: build the rewritten message in jq so
921        // the `\n` is a real newline (a shell-quoted "\n" would be literal).
922        let cmd = r#"echo "$EVERRUNS_HOOK_PAYLOAD_JSON" | jq -c '{decision:"mutate",patch:{message:("[reminder: follow the house style guide]\n" + .data.message)}}'"#;
923        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::default());
924
925        let outcome = run(
926            store,
927            cmd,
928            Default::default(),
929            payload(
930                HookEvent::UserPromptSubmit,
931                json!({ "message": "fix the bug" }),
932            ),
933        )
934        .await;
935        match outcome {
936            HookOutcome::Mutate { patch, .. } => {
937                assert_eq!(
938                    patch.get("message").and_then(|v| v.as_str()),
939                    Some("[reminder: follow the house style guide]\nfix the bug")
940                );
941            }
942            other => panic!("expected Mutate, got {other:?}"),
943        }
944    }
945
946    #[test]
947    fn all_example_bundles_validate_against_user_hooks_schema() {
948        use crate::capabilities::{Capability, UserHooksCapability};
949
950        let dir = format!("{}/../../examples/hook-bundles", env!("CARGO_MANIFEST_DIR"));
951        let cap = UserHooksCapability;
952        let mut checked = 0;
953        for entry in std::fs::read_dir(&dir).unwrap() {
954            let path = entry.unwrap().path();
955            if path.extension().and_then(|e| e.to_str()) != Some("json") {
956                continue;
957            }
958            let raw = std::fs::read_to_string(&path).unwrap();
959            let config: serde_json::Value = serde_json::from_str(&raw)
960                .unwrap_or_else(|e| panic!("{}: invalid JSON: {e}", path.display()));
961            cap.validate_config(&config).unwrap_or_else(|e| {
962                panic!("{}: failed user_hooks validation: {e}", path.display())
963            });
964            checked += 1;
965        }
966        assert!(
967            checked >= 6,
968            "expected to validate the shipped bundles, saw {checked}"
969        );
970    }
971
972    #[tokio::test]
973    async fn jq_can_read_payload_from_env_path() {
974        // Smoke test for the documented `jq` workflow. We use a tiny
975        // shell-only json walk because the bashkit interpreter does not
976        // ship `jq` as a built-in.
977        let mock = Arc::new(MockFileStore::default());
978        let store: Arc<dyn SessionFileSystem> = mock.clone();
979        let cmd = r#"grep -o '"event":"[^"]*"' "$EVERRUNS_HOOK_PAYLOAD_PATH" > /workspace/.grep"#;
980        let outcome = run(
981            store,
982            cmd,
983            Default::default(),
984            payload(HookEvent::SessionStart, json!({"agent_id":"agt_x"})),
985        )
986        .await;
987        assert!(matches!(outcome, HookOutcome::Allow), "{:?}", outcome);
988        assert_eq!(
989            mock.read("/.grep")
990                .or_else(|| mock.read("/workspace/.grep"))
991                .map(|s| s.trim().to_string()),
992            Some("\"event\":\"session_start\"".to_string())
993        );
994    }
995}