Skip to main content

everruns_core/
hook_executor.rs

1// User-defined hooks: executor backends.
2//
3// `HookExecutor` is the per-backend trait. v1 ships only `BashHookExecutor`,
4// which routes the user-authored shell command through the session's
5// `virtual_bash` sandbox (the same FS isolation `bash` itself uses) and parses
6// the structured JSON contract documented in `specs/user-hooks.md`.
7//
8// The trait is deliberately backend-agnostic so future variants
9// (`WebhookHookExecutor`, `WasmHookExecutor`, `BlueprintHookExecutor`) can
10// land without changing the user-facing `UserHookSpec` shape.
11
12use 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// ============================================================================
20// HookPayload
21// ============================================================================
22
23/// Envelope handed to every executor. For bash hooks this is serialized
24/// into `$EVERRUNS_HOOK_PAYLOAD_JSON` / `$EVERRUNS_HOOK_PAYLOAD_PATH`;
25/// other backends (webhook, wasm, blueprint) consume it in their own
26/// format. `data` is event-specific; see `specs/user-hooks.md` for the
27/// per-event shape.
28#[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// ============================================================================
41// ExecutorOpts
42// ============================================================================
43
44/// Per-invocation knobs honored by every executor. The adapter clamps these
45/// against the global `UserHookSpec` validation; backends should treat them
46/// as already-validated.
47#[derive(Debug, Clone)]
48pub struct ExecutorOpts {
49    pub timeout_ms: u32,
50    /// 64 KiB max output (stdout + stderr combined).
51    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// ============================================================================
64// HookExecutor trait
65// ============================================================================
66
67/// Backend that runs a single hook invocation against a single payload.
68///
69/// Implementations are stateless per-call; the adapter constructs the
70/// `HookPayload` and calls `run` once per matching event firing.
71///
72/// Contract:
73///
74/// - On success, return `HookOutcome::Allow`, `Mutate`, or `Block` as
75///   parsed from the backend's output.
76/// - On failure (timeout, sandbox error, malformed output, output size
77///   overrun), return `HookOutcome::Error { message }` — never panic. The
78///   adapter applies the spec's `on_error` policy.
79#[async_trait]
80pub trait HookExecutor: Send + Sync {
81    /// Stable backend identifier (matches `ExecutorSpec` tag).
82    fn kind(&self) -> &'static str;
83
84    async fn run(&self, payload: HookPayload, opts: &ExecutorOpts) -> HookOutcome;
85}
86
87// ============================================================================
88// BashHookExecutor
89// ============================================================================
90
91/// Bash backend. Runs the configured command inside `virtual_bash` against
92/// the session VFS. JSON payload is delivered to the script via
93/// `$EVERRUNS_HOOK_PAYLOAD_JSON` (and the same JSON written to
94/// `$EVERRUNS_HOOK_PAYLOAD_PATH` on the session VFS); the script writes a
95/// JSON decision to stdout. Falls back to exit-code semantics when stdout is
96/// empty (Git-hook compatibility).
97///
98/// This struct is backend-agnostic: it serializes the payload, hands it to a
99/// `BashHookDispatcher`, and parses the dispatcher's stdout/exit_code/stderr
100/// into a `HookOutcome`. The production dispatcher
101/// (`crate::hook_dispatch::VirtualBashHookDispatcher`) runs the command
102/// through the same bashkit interpreter the `virtual_bash` capability uses.
103pub struct BashHookExecutor {
104    /// Command the user authored (validated non-empty).
105    pub command: String,
106    /// Extra env vars layered onto the executor's default env.
107    pub env: std::collections::BTreeMap<String, String>,
108    /// Sandbox dispatcher injected by the runtime. `None` keeps the type
109    /// constructible in unit tests; in production the capability collection
110    /// path supplies a real dispatcher.
111    pub dispatcher: Option<Arc<dyn BashHookDispatcher>>,
112}
113
114impl BashHookExecutor {
115    /// Build an executor wired to the given dispatcher. This is the
116    /// production constructor used by the capability collection path.
117    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
130/// Workspace-relative directory the hook script reads the payload file from.
131/// Concrete dispatchers may map this onto a different storage path (e.g.
132/// the bashkit `virtual_bash` adapter strips the `/workspace` prefix before
133/// hitting the session VFS).
134pub const HOOK_PAYLOAD_WORKSPACE_DIR: &str = "/workspace/.hooks";
135
136/// Storage-relative directory used by `SessionFileSystem` impls that strip
137/// the workspace prefix. The bashkit `VirtualBashHookDispatcher` writes to
138/// this path and exposes the workspace-prefixed equivalent to scripts.
139pub const HOOK_PAYLOAD_DIR: &str = "/.hooks";
140
141/// Build the standard env vars every bash hook receives — the canonical
142/// `EVERRUNS_HOOK_PAYLOAD_JSON` plus the convenience scalars documented in
143/// `specs/user-hooks.md`. Returns the env in declaration order so dispatcher
144/// logs render deterministically.
145pub 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    // Tool-event convenience scalars (extracted from data.tool_name /
175    // data.tool_call_id when present).
176    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
188/// Filename (without directory) for a payload file. Combines a sanitized
189/// hook id with a fresh UUIDv7 so concurrent invocations don't collide.
190pub 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/// Indirection used to route bash hook invocations through the session's
207/// existing `virtual_bash` sandbox without `everruns-core`'s executor module
208/// having to depend on bashkit directly. The concrete
209/// `VirtualBashHookDispatcher` (see `crate::hook_dispatch`) is the production
210/// implementation.
211#[async_trait]
212pub trait BashHookDispatcher: Send + Sync {
213    /// Run `command` inside the session sandbox with `payload` exposed to
214    /// the script via env vars and a VFS payload file. `extra_env` is the
215    /// user-authored env layered on top of the dispatcher's defaults. Honor
216    /// `opts.timeout_ms` and `opts.max_output_bytes`.
217    ///
218    /// Returns (`exit_code`, `stdout`, `stderr`) — semantically identical to
219    /// what a Unix `bash -c` invocation produces.
220    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
261/// Parse the bash backend's output per the spec contract.
262///
263/// 1. Empty stdout -> exit 0 = Allow, non-zero = Block (stderr as reason).
264/// 2. stdout starting with `{` -> parse as JSON decision.
265/// 3. Anything else -> Error (executor failed).
266pub 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}