1use 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
34fn 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) .max_ast_depth(64)
45 .parser_timeout(std::time::Duration::from_secs(2))
46}
47
48pub struct BashkitShellHookDispatcher {
52 store: Arc<dyn SessionFileSystem>,
53}
54
55impl BashkitShellHookDispatcher {
56 pub fn new(store: Arc<dyn SessionFileSystem>) -> Self {
57 Self {
63 store: crate::mount_fs::MountFs::wrap(store),
64 }
65 }
66
67 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 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 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 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 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 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 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#[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 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 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 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 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 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 "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 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 #[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 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 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 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 #[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 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 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 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 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 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 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 assert!(matches!(outcome, HookOutcome::Allow), "got {outcome:?}");
906
907 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 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 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}