use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::typed_id::{OrgId, SessionId};
use crate::user_hook_types::{HookEvent, HookId, HookOutcome};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookPayload {
pub event: HookEvent,
pub hook_id: HookId,
pub session_id: SessionId,
pub turn_id: Option<String>,
pub org_id: Option<OrgId>,
pub agent_id: Option<String>,
pub ts: String,
pub data: serde_json::Value,
}
#[derive(Debug, Clone)]
pub struct ExecutorOpts {
pub timeout_ms: u32,
pub max_output_bytes: usize,
}
impl Default for ExecutorOpts {
fn default() -> Self {
Self {
timeout_ms: 5000,
max_output_bytes: 64 * 1024,
}
}
}
#[async_trait]
pub trait HookExecutor: Send + Sync {
fn kind(&self) -> &'static str;
async fn run(&self, payload: HookPayload, opts: &ExecutorOpts) -> HookOutcome;
}
pub struct BashHookExecutor {
pub command: String,
pub env: std::collections::BTreeMap<String, String>,
pub dispatcher: Option<Arc<dyn BashHookDispatcher>>,
}
impl BashHookExecutor {
pub fn with_dispatcher(
command: String,
env: std::collections::BTreeMap<String, String>,
dispatcher: Arc<dyn BashHookDispatcher>,
) -> Self {
Self {
command,
env,
dispatcher: Some(dispatcher),
}
}
}
pub const HOOK_PAYLOAD_WORKSPACE_DIR: &str = "/workspace/.hooks";
pub const HOOK_PAYLOAD_DIR: &str = "/.hooks";
pub fn standard_hook_env(
payload: &HookPayload,
payload_path: &str,
) -> Result<Vec<(String, String)>, String> {
let payload_json = serde_json::to_string(payload)
.map_err(|e| format!("failed to serialize hook payload: {e}"))?;
let mut env: Vec<(String, String)> = vec![
("EVERRUNS_HOOK_PAYLOAD_JSON".to_string(), payload_json),
(
"EVERRUNS_HOOK_PAYLOAD_PATH".to_string(),
payload_path.to_string(),
),
(
"EVERRUNS_HOOK_EVENT".to_string(),
payload.event.as_str().to_string(),
),
(
"EVERRUNS_HOOK_ID".to_string(),
payload.hook_id.as_str().to_string(),
),
(
"EVERRUNS_HOOK_SESSION_ID".to_string(),
payload.session_id.to_string(),
),
];
if let Some(turn_id) = &payload.turn_id {
env.push(("EVERRUNS_HOOK_TURN_ID".to_string(), turn_id.clone()));
}
if let Some(tool_name) = payload.data.get("tool_name").and_then(|v| v.as_str()) {
env.push(("EVERRUNS_HOOK_TOOL_NAME".to_string(), tool_name.to_string()));
}
if let Some(call_id) = payload.data.get("tool_call_id").and_then(|v| v.as_str()) {
env.push((
"EVERRUNS_HOOK_TOOL_CALL_ID".to_string(),
call_id.to_string(),
));
}
Ok(env)
}
pub fn payload_filename(payload: &HookPayload) -> String {
let safe: String = payload
.hook_id
.as_str()
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
format!("{safe}-{}.json", uuid::Uuid::now_v7())
}
#[async_trait]
pub trait BashHookDispatcher: Send + Sync {
async fn dispatch(
&self,
payload: &HookPayload,
command: &str,
extra_env: &std::collections::BTreeMap<String, String>,
opts: &ExecutorOpts,
) -> Result<BashExecOutput, String>;
}
#[derive(Debug, Clone)]
pub struct BashExecOutput {
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
}
#[async_trait]
impl HookExecutor for BashHookExecutor {
fn kind(&self) -> &'static str {
"bash"
}
async fn run(&self, payload: HookPayload, opts: &ExecutorOpts) -> HookOutcome {
let Some(dispatcher) = &self.dispatcher else {
return HookOutcome::Error {
message: "bash hook executor has no dispatcher; runtime did not wire it"
.to_string(),
};
};
let output = match dispatcher
.dispatch(&payload, &self.command, &self.env, opts)
.await
{
Ok(out) => out,
Err(message) => return HookOutcome::Error { message },
};
parse_bash_output(output)
}
}
pub fn parse_bash_output(out: BashExecOutput) -> HookOutcome {
let trimmed = out.stdout.trim_start();
if trimmed.is_empty() {
if out.exit_code == 0 {
return HookOutcome::Allow;
}
let reason = if out.stderr.trim().is_empty() {
"hook exited non-zero".to_string()
} else {
out.stderr.trim().to_string()
};
return HookOutcome::Block {
reason,
user_message: None,
};
}
if !trimmed.starts_with('{') {
return HookOutcome::Error {
message: format!(
"hook stdout is not JSON (first 80 bytes: {})",
first_n(trimmed, 80)
),
};
}
#[derive(Deserialize)]
struct Decision {
#[serde(default)]
decision: Option<String>,
#[serde(default)]
reason: Option<String>,
#[serde(default)]
user_message: Option<String>,
#[serde(default)]
patch: Option<serde_json::Value>,
}
let decision: Decision = match serde_json::from_str(trimmed) {
Ok(d) => d,
Err(e) => {
return HookOutcome::Error {
message: format!("hook stdout JSON parse failed: {e}"),
};
}
};
match decision.decision.as_deref().unwrap_or("allow") {
"allow" => HookOutcome::Allow,
"block" => HookOutcome::Block {
reason: decision.reason.unwrap_or_else(|| "hook blocked".into()),
user_message: decision.user_message,
},
"mutate" => match decision.patch {
Some(patch) => HookOutcome::Mutate {
patch,
reason: decision.reason,
},
None => HookOutcome::Error {
message: "hook decision `mutate` missing `patch`".into(),
},
},
other => HookOutcome::Error {
message: format!("unknown hook decision `{other}`"),
},
}
}
fn first_n(s: &str, n: usize) -> &str {
if s.len() <= n {
s
} else {
let mut end = n;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
}
#[cfg(test)]
mod tests {
use super::*;
fn out(exit: i32, stdout: &str, stderr: &str) -> BashExecOutput {
BashExecOutput {
exit_code: exit,
stdout: stdout.into(),
stderr: stderr.into(),
}
}
#[test]
fn empty_stdout_zero_exit_is_allow() {
assert!(matches!(
parse_bash_output(out(0, "", "")),
HookOutcome::Allow
));
}
#[test]
fn empty_stdout_nonzero_exit_is_block_with_stderr_reason() {
let outcome = parse_bash_output(out(1, "", "denied: rm -rf"));
match outcome {
HookOutcome::Block { reason, .. } => assert_eq!(reason, "denied: rm -rf"),
_ => panic!("expected Block"),
}
}
#[test]
fn empty_stdout_nonzero_exit_no_stderr_uses_generic_reason() {
let outcome = parse_bash_output(out(1, "", ""));
match outcome {
HookOutcome::Block { reason, .. } => assert_eq!(reason, "hook exited non-zero"),
_ => panic!("expected Block"),
}
}
#[test]
fn json_allow_decision() {
let outcome = parse_bash_output(out(0, r#"{"decision":"allow"}"#, ""));
assert!(matches!(outcome, HookOutcome::Allow));
}
#[test]
fn json_block_with_reason_and_user_message() {
let outcome = parse_bash_output(out(
0,
r#"{"decision":"block","reason":"blocked","user_message":"nope"}"#,
"",
));
match outcome {
HookOutcome::Block {
reason,
user_message,
} => {
assert_eq!(reason, "blocked");
assert_eq!(user_message.as_deref(), Some("nope"));
}
_ => panic!("expected Block"),
}
}
#[test]
fn json_mutate_requires_patch() {
let no_patch = parse_bash_output(out(0, r#"{"decision":"mutate"}"#, ""));
assert!(matches!(no_patch, HookOutcome::Error { .. }));
let with_patch = parse_bash_output(out(
0,
r#"{"decision":"mutate","patch":{"arguments":{"x":1}}}"#,
"",
));
match with_patch {
HookOutcome::Mutate { patch, .. } => {
assert_eq!(patch["arguments"]["x"], 1);
}
_ => panic!("expected Mutate"),
}
}
#[test]
fn unknown_decision_is_error() {
let outcome = parse_bash_output(out(0, r#"{"decision":"explode"}"#, ""));
assert!(matches!(outcome, HookOutcome::Error { .. }));
}
#[test]
fn non_json_stdout_is_error() {
let outcome = parse_bash_output(out(0, "hello world", ""));
assert!(matches!(outcome, HookOutcome::Error { .. }));
}
#[test]
fn malformed_json_is_error() {
let outcome = parse_bash_output(out(0, "{not json", ""));
assert!(matches!(outcome, HookOutcome::Error { .. }));
}
#[test]
fn missing_decision_field_defaults_to_allow() {
let outcome = parse_bash_output(out(0, r#"{"reason":"all good"}"#, ""));
assert!(matches!(outcome, HookOutcome::Allow));
}
#[test]
fn first_n_safe_on_multibyte_boundary() {
let s = "héllo";
assert_eq!(first_n(s, 2), "h");
assert_eq!(first_n(s, 3), "hé");
}
}