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 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 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 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 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 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 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 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 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#[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 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 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 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 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 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 "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 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 #[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 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 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 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 #[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 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 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 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 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 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 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 assert!(matches!(outcome, HookOutcome::Allow), "got {outcome:?}");
895
896 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 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 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}