Skip to main content

everruns_core/
hook_dispatch.rs

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