1use async_trait::async_trait;
13use serde::{Deserialize, Serialize};
14use std::sync::Arc;
15
16use crate::typed_id::{OrgId, SessionId};
17use crate::user_hook_types::{HookEvent, HookId, HookOutcome};
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct HookPayload {
30 pub event: HookEvent,
31 pub hook_id: HookId,
32 pub session_id: SessionId,
33 pub turn_id: Option<String>,
34 pub org_id: Option<OrgId>,
35 pub agent_id: Option<String>,
36 pub ts: String,
37 pub data: serde_json::Value,
38}
39
40#[derive(Debug, Clone)]
48pub struct ExecutorOpts {
49 pub timeout_ms: u32,
50 pub max_output_bytes: usize,
52}
53
54impl Default for ExecutorOpts {
55 fn default() -> Self {
56 Self {
57 timeout_ms: 5000,
58 max_output_bytes: 64 * 1024,
59 }
60 }
61}
62
63#[async_trait]
80pub trait HookExecutor: Send + Sync {
81 fn kind(&self) -> &'static str;
83
84 async fn run(&self, payload: HookPayload, opts: &ExecutorOpts) -> HookOutcome;
85}
86
87pub struct BashHookExecutor {
104 pub command: String,
106 pub env: std::collections::BTreeMap<String, String>,
108 pub dispatcher: Option<Arc<dyn BashHookDispatcher>>,
112}
113
114impl BashHookExecutor {
115 pub fn with_dispatcher(
118 command: String,
119 env: std::collections::BTreeMap<String, String>,
120 dispatcher: Arc<dyn BashHookDispatcher>,
121 ) -> Self {
122 Self {
123 command,
124 env,
125 dispatcher: Some(dispatcher),
126 }
127 }
128}
129
130pub const HOOK_PAYLOAD_WORKSPACE_DIR: &str = "/workspace/.hooks";
135
136pub const HOOK_PAYLOAD_DIR: &str = "/.hooks";
140
141pub fn standard_hook_env(
146 payload: &HookPayload,
147 payload_path: &str,
148) -> Result<Vec<(String, String)>, String> {
149 let payload_json = serde_json::to_string(payload)
150 .map_err(|e| format!("failed to serialize hook payload: {e}"))?;
151
152 let mut env: Vec<(String, String)> = vec![
153 ("EVERRUNS_HOOK_PAYLOAD_JSON".to_string(), payload_json),
154 (
155 "EVERRUNS_HOOK_PAYLOAD_PATH".to_string(),
156 payload_path.to_string(),
157 ),
158 (
159 "EVERRUNS_HOOK_EVENT".to_string(),
160 payload.event.as_str().to_string(),
161 ),
162 (
163 "EVERRUNS_HOOK_ID".to_string(),
164 payload.hook_id.as_str().to_string(),
165 ),
166 (
167 "EVERRUNS_HOOK_SESSION_ID".to_string(),
168 payload.session_id.to_string(),
169 ),
170 ];
171 if let Some(turn_id) = &payload.turn_id {
172 env.push(("EVERRUNS_HOOK_TURN_ID".to_string(), turn_id.clone()));
173 }
174 if let Some(tool_name) = payload.data.get("tool_name").and_then(|v| v.as_str()) {
177 env.push(("EVERRUNS_HOOK_TOOL_NAME".to_string(), tool_name.to_string()));
178 }
179 if let Some(call_id) = payload.data.get("tool_call_id").and_then(|v| v.as_str()) {
180 env.push((
181 "EVERRUNS_HOOK_TOOL_CALL_ID".to_string(),
182 call_id.to_string(),
183 ));
184 }
185 Ok(env)
186}
187
188pub fn payload_filename(payload: &HookPayload) -> String {
191 let safe: String = payload
192 .hook_id
193 .as_str()
194 .chars()
195 .map(|c| {
196 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
197 c
198 } else {
199 '_'
200 }
201 })
202 .collect();
203 format!("{safe}-{}.json", uuid::Uuid::now_v7())
204}
205
206#[async_trait]
212pub trait BashHookDispatcher: Send + Sync {
213 async fn dispatch(
221 &self,
222 payload: &HookPayload,
223 command: &str,
224 extra_env: &std::collections::BTreeMap<String, String>,
225 opts: &ExecutorOpts,
226 ) -> Result<BashExecOutput, String>;
227}
228
229#[derive(Debug, Clone)]
230pub struct BashExecOutput {
231 pub exit_code: i32,
232 pub stdout: String,
233 pub stderr: String,
234}
235
236#[async_trait]
237impl HookExecutor for BashHookExecutor {
238 fn kind(&self) -> &'static str {
239 "bash"
240 }
241
242 async fn run(&self, payload: HookPayload, opts: &ExecutorOpts) -> HookOutcome {
243 let Some(dispatcher) = &self.dispatcher else {
244 return HookOutcome::Error {
245 message: "bash hook executor has no dispatcher; runtime did not wire it"
246 .to_string(),
247 };
248 };
249 let output = match dispatcher
250 .dispatch(&payload, &self.command, &self.env, opts)
251 .await
252 {
253 Ok(out) => out,
254 Err(message) => return HookOutcome::Error { message },
255 };
256
257 parse_bash_output(output)
258 }
259}
260
261pub fn parse_bash_output(out: BashExecOutput) -> HookOutcome {
267 let trimmed = out.stdout.trim_start();
268
269 if trimmed.is_empty() {
270 if out.exit_code == 0 {
271 return HookOutcome::Allow;
272 }
273 let reason = if out.stderr.trim().is_empty() {
274 "hook exited non-zero".to_string()
275 } else {
276 out.stderr.trim().to_string()
277 };
278 return HookOutcome::Block {
279 reason,
280 user_message: None,
281 };
282 }
283
284 if !trimmed.starts_with('{') {
285 return HookOutcome::Error {
286 message: format!(
287 "hook stdout is not JSON (first 80 bytes: {})",
288 first_n(trimmed, 80)
289 ),
290 };
291 }
292
293 #[derive(Deserialize)]
294 struct Decision {
295 #[serde(default)]
296 decision: Option<String>,
297 #[serde(default)]
298 reason: Option<String>,
299 #[serde(default)]
300 user_message: Option<String>,
301 #[serde(default)]
302 patch: Option<serde_json::Value>,
303 }
304
305 let decision: Decision = match serde_json::from_str(trimmed) {
306 Ok(d) => d,
307 Err(e) => {
308 return HookOutcome::Error {
309 message: format!("hook stdout JSON parse failed: {e}"),
310 };
311 }
312 };
313
314 match decision.decision.as_deref().unwrap_or("allow") {
315 "allow" => HookOutcome::Allow,
316 "block" => HookOutcome::Block {
317 reason: decision.reason.unwrap_or_else(|| "hook blocked".into()),
318 user_message: decision.user_message,
319 },
320 "mutate" => match decision.patch {
321 Some(patch) => HookOutcome::Mutate {
322 patch,
323 reason: decision.reason,
324 },
325 None => HookOutcome::Error {
326 message: "hook decision `mutate` missing `patch`".into(),
327 },
328 },
329 other => HookOutcome::Error {
330 message: format!("unknown hook decision `{other}`"),
331 },
332 }
333}
334
335fn first_n(s: &str, n: usize) -> &str {
336 if s.len() <= n {
337 s
338 } else {
339 let mut end = n;
340 while end > 0 && !s.is_char_boundary(end) {
341 end -= 1;
342 }
343 &s[..end]
344 }
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 fn out(exit: i32, stdout: &str, stderr: &str) -> BashExecOutput {
352 BashExecOutput {
353 exit_code: exit,
354 stdout: stdout.into(),
355 stderr: stderr.into(),
356 }
357 }
358
359 #[test]
360 fn empty_stdout_zero_exit_is_allow() {
361 assert!(matches!(
362 parse_bash_output(out(0, "", "")),
363 HookOutcome::Allow
364 ));
365 }
366
367 #[test]
368 fn empty_stdout_nonzero_exit_is_block_with_stderr_reason() {
369 let outcome = parse_bash_output(out(1, "", "denied: rm -rf"));
370 match outcome {
371 HookOutcome::Block { reason, .. } => assert_eq!(reason, "denied: rm -rf"),
372 _ => panic!("expected Block"),
373 }
374 }
375
376 #[test]
377 fn empty_stdout_nonzero_exit_no_stderr_uses_generic_reason() {
378 let outcome = parse_bash_output(out(1, "", ""));
379 match outcome {
380 HookOutcome::Block { reason, .. } => assert_eq!(reason, "hook exited non-zero"),
381 _ => panic!("expected Block"),
382 }
383 }
384
385 #[test]
386 fn json_allow_decision() {
387 let outcome = parse_bash_output(out(0, r#"{"decision":"allow"}"#, ""));
388 assert!(matches!(outcome, HookOutcome::Allow));
389 }
390
391 #[test]
392 fn json_block_with_reason_and_user_message() {
393 let outcome = parse_bash_output(out(
394 0,
395 r#"{"decision":"block","reason":"blocked","user_message":"nope"}"#,
396 "",
397 ));
398 match outcome {
399 HookOutcome::Block {
400 reason,
401 user_message,
402 } => {
403 assert_eq!(reason, "blocked");
404 assert_eq!(user_message.as_deref(), Some("nope"));
405 }
406 _ => panic!("expected Block"),
407 }
408 }
409
410 #[test]
411 fn json_mutate_requires_patch() {
412 let no_patch = parse_bash_output(out(0, r#"{"decision":"mutate"}"#, ""));
413 assert!(matches!(no_patch, HookOutcome::Error { .. }));
414
415 let with_patch = parse_bash_output(out(
416 0,
417 r#"{"decision":"mutate","patch":{"arguments":{"x":1}}}"#,
418 "",
419 ));
420 match with_patch {
421 HookOutcome::Mutate { patch, .. } => {
422 assert_eq!(patch["arguments"]["x"], 1);
423 }
424 _ => panic!("expected Mutate"),
425 }
426 }
427
428 #[test]
429 fn unknown_decision_is_error() {
430 let outcome = parse_bash_output(out(0, r#"{"decision":"explode"}"#, ""));
431 assert!(matches!(outcome, HookOutcome::Error { .. }));
432 }
433
434 #[test]
435 fn non_json_stdout_is_error() {
436 let outcome = parse_bash_output(out(0, "hello world", ""));
437 assert!(matches!(outcome, HookOutcome::Error { .. }));
438 }
439
440 #[test]
441 fn malformed_json_is_error() {
442 let outcome = parse_bash_output(out(0, "{not json", ""));
443 assert!(matches!(outcome, HookOutcome::Error { .. }));
444 }
445
446 #[test]
447 fn missing_decision_field_defaults_to_allow() {
448 let outcome = parse_bash_output(out(0, r#"{"reason":"all good"}"#, ""));
449 assert!(matches!(outcome, HookOutcome::Allow));
450 }
451
452 #[test]
453 fn first_n_safe_on_multibyte_boundary() {
454 let s = "héllo";
455 assert_eq!(first_n(s, 2), "h");
456 assert_eq!(first_n(s, 3), "hé");
457 }
458}