- 1
//! vak-hooks: user-defined script handlers at agent lifecycle points. - 2
//! - 3
//! Handlers receive JSON context on stdin and may answer with JSON on - 4
//! stdout (`{"decision":"block"|"approve","reason":"..."}`) or exit code 2 - 5
//! (block, stderr becomes the reason). Exit 0 without output = no opinion. - 6
- 7
use std::path::Path; - 8
use std::process::Stdio; - 9
use std::sync::Arc; - 10
- 11
use serde_json::Value; - 12
use tokio::io::AsyncWriteExt; - 13
- 14
use vak_permission::Rule; - 15
- 16
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] - 17
#[serde(rename_all = "snake_case")] - 18
pub enum HookEvent { - 19
SessionStart, - 20
PreToolUse, - 21
PostToolUse, - 22
Stop, - 23
} - 24
- 25
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] - 26
pub enum HookFailureMode { - 27
#[default] - 28
Open, - 29
Closed, - 30
} - 31
- 32
impl HookEvent { - 33
pub fn as_str(&self) -> &'static str { - 34
match self { - 35
HookEvent::SessionStart => "session_start", - 36
HookEvent::PreToolUse => "pre_tool_use", - 37
HookEvent::PostToolUse => "post_tool_use", - 38
HookEvent::Stop => "stop", - 39
} - 40
} - 41
} - 42
- 43
#[derive(Debug, Clone)] - 44
pub struct HookDef { - 45
pub event: HookEvent, - 46
pub matcher: Option<Rule>, - 47
pub command: String, - 48
pub timeout_ms: u64, - 49
pub failure_mode: HookFailureMode, - 50
/// Set when the hook's definition could not be understood and it was - 51
/// declared fail-closed: it then refuses everything it might have - 52
/// guarded, with this reason, instead of silently not existing. - 53
pub refusal: Option<String>, - 54
} - 55
- 56
pub const DEFAULT_TIMEOUT_MS: u64 = 10_000; - 57
const MAX_CAPTURE: usize = 64 * 1024; - 58
- 59
#[derive(Debug, Clone, Default, PartialEq)] - 60
pub struct HookOutcome { - 61
pub blocked: bool, - 62
pub reason: Option<String>, - 63
} - 64
- 65
pub type HookRecorder<'a> = &'a (dyn Fn(&HookDef, bool, u64) + Send + Sync); - 66
- 67
impl HookOutcome { - 68
fn merge(&mut self, other: HookOutcome) { - 69
if other.blocked && !self.blocked { - 70
self.blocked = true; - 71
self.reason = other.reason.or_else(|| Some("blocked by hook".into())); - 72
} - 73
} - 74
} - 75
- 76
fn hook_input( - 77
event: HookEvent, - 78
session_id: &str, - 79
cwd: &Path, - 80
tool: Option<(&str, &Value)>, - 81
extra: Option<&str>, - 82
) -> Value { - 83
let mut v = serde_json::json!({ - 84
"event": event.as_str(), - 85
"session_id": session_id, - 86
"cwd": cwd.display().to_string(), - 87
}); - 88
if let Some((name, input)) = tool { - 89
v["tool"] = serde_json::json!({"name": name, "input": input}); - 90
} - 91
if let Some(text) = extra { - 92
v["text"] = Value::String(text.to_string()); - 93
} - 94
v - 95
} - 96
- 97
/// Runs every matching hook for the event sequentially; first block wins. - 98
pub async fn run_hooks( - 99
hooks: Arc<Vec<HookDef>>, - 100
event: HookEvent, - 101
session_id: &str, - 102
cwd: &Path, - 103
tool: Option<(&str, &Value)>, - 104
extra: Option<&str>, - 105
cancel: &tokio_util::sync::CancellationToken, - 106
) -> HookOutcome { - 107
run_hooks_with_recorder(hooks, event, session_id, cwd, tool, extra, cancel, None).await - 108
} - 109
- 110
#[allow(clippy::too_many_arguments)] - 111
pub async fn run_hooks_with_recorder( - 112
hooks: Arc<Vec<HookDef>>, - 113
event: HookEvent, - 114
session_id: &str, - 115
cwd: &Path, - 116
tool: Option<(&str, &Value)>, - 117
extra: Option<&str>, - 118
cancel: &tokio_util::sync::CancellationToken, - 119
recorder: Option<HookRecorder<'_>>, - 120
) -> HookOutcome { - 121
let mut combined = HookOutcome::default(); - 122
for hook in hooks.iter() { - 123
if hook.event != event { - 124
continue; - 125
} - 126
if let (Some(rule), Some((name, input))) = (&hook.matcher, tool) - 127
&& !rule.matches(name, input) - 128
{ - 129
continue; - 130
} - 131
- 132
let started = std::time::Instant::now(); - 133
let outcome = match &hook.refusal { - 134
Some(reason) => HookOutcome { - 135
blocked: true, - 136
reason: Some(reason.clone()), - 137
}, - 138
None => run_one(hook, event, session_id, cwd, tool, extra, cancel).await, - 139
}; - 140
if let Some(recorder) = recorder { - 141
recorder(hook, !outcome.blocked, started.elapsed().as_millis() as u64); - 142
} - 143
combined.merge(outcome); - 144
if combined.blocked { - 145
return combined; - 146
} - 147
} - 148
combined - 149
} - 150
- 151
async fn run_one( - 152
hook: &HookDef, - 153
event: HookEvent, - 154
session_id: &str, - 155
cwd: &Path, - 156
tool: Option<(&str, &Value)>, - 157
extra: Option<&str>, - 158
cancel: &tokio_util::sync::CancellationToken, - 159
) -> HookOutcome { - 160
let payload = - 161
serde_json::to_vec(&hook_input(event, session_id, cwd, tool, extra)).unwrap_or_default(); - 162
- 163
let mut cmd = tokio::process::Command::new("sh"); - 164
cmd.arg("-c") - 165
.arg(&hook.command) - 166
.current_dir(cwd) - 167
.stdin(Stdio::piped()) - 168
.stdout(Stdio::piped()) - 169
.stderr(Stdio::piped()); - 170
- 171
#[cfg(unix)] - 172
cmd.process_group(0); - 173
- 174
let mut child = match cmd.spawn() { - 175
Ok(c) => c, - 176
Err(e) => { - 177
return HookOutcome { - 178
blocked: hook.failure_mode == HookFailureMode::Closed, - 179
reason: Some(format!("hook spawn failed: {e}")), - 180
}; - 181
} - 182
}; - 183
- 184
let mut stdout = child.stdout.take(); - 185
let mut stderr = child.stderr.take(); - 186
let out_fut = tokio::spawn(async move { read_capped(&mut stdout).await }); - 187
let err_fut = tokio::spawn(async move { read_capped(&mut stderr).await }); - 188
- 189
// The stdin write happens inside the timed region too: a hook that - 190
// never reads stdin would otherwise block this task forever once the - 191
// payload exceeds the pipe buffer. - 192
let pid = child.id(); - 193
let stdin = child.stdin.take(); - 194
let interact = async move { - 195
if let Some(mut si) = stdin { - 196
let _ = si.write_all(&payload).await; - 197
let _ = si.shutdown().await; - 198
} - 199
child.wait().await - 200
}; - 201
- 202
let timeout = tokio::time::sleep(std::time::Duration::from_millis(hook.timeout_ms)); - 203
tokio::select! { - 204
_ = timeout => { - 205
kill_tree(&pid); - 206
HookOutcome { blocked: hook.failure_mode == HookFailureMode::Closed, reason: Some("hook timed out".into()) } - 207
} - 208
_ = cancel.cancelled() => { - 209
kill_tree(&pid); - 210
HookOutcome::default() - 211
} - 212
status = interact => { - 213
let status = match status { - 214
Ok(s) => s, - 215
Err(e) => return HookOutcome { blocked: hook.failure_mode == HookFailureMode::Closed, reason: Some(format!("hook wait failed: {e}")) }, - 216
}; - 217
let out = out_fut.await.unwrap_or_default(); - 218
let err = err_fut.await.unwrap_or_default(); - 219
- 220
if status.code() == Some(2) { - 221
let reason = if err.trim().is_empty() { out.trim() } else { err.trim() }; - 222
return HookOutcome { - 223
blocked: true, - 224
reason: Some(if reason.is_empty() { "blocked by hook (exit 2)".into() } else { reason.to_string() }), - 225
}; - 226
} - 227
- 228
if !status.success() { - 229
return HookOutcome { - 230
blocked: hook.failure_mode == HookFailureMode::Closed, - 231
reason: Some(format!( - 232
"hook exited with {}", - 233
status - 234
.code() - 235
.map_or_else(|| "signal".to_string(), |code| code.to_string()) - 236
)), - 237
}; - 238
} - 239
- 240
if let Ok(v) = serde_json::from_str::<Value>(out.trim()) { - 241
match v.get("decision").and_then(|d| d.as_str()) { - 242
Some("block") => { - 243
return HookOutcome { - 244
blocked: true, - 245
reason: Some( - 246
v.get("reason").and_then(|r| r.as_str()).unwrap_or("blocked by hook").to_string(), - 247
), - 248
}; - 249
} - 250
Some("approve") | None => {} - 251
Some(other) => { - 252
return HookOutcome { blocked: false, reason: Some(format!("unknown hook decision '{other}'")) }; - 253
} - 254
} - 255
} - 256
- 257
HookOutcome::default() - 258
} - 259
} - 260
} - 261
- 262
fn kill_tree(pid: &Option<u32>) { - 263
#[cfg(unix)] - 264
if let Some(pid) = pid { - 265
#[allow(unsafe_code)] - 266
unsafe { - 267
libc::kill(-(*pid as i32), libc::SIGKILL); - 268
} - 269
} - 270
#[cfg(windows)] - 271
if let Some(pid) = pid { - 272
let _ = std::process::Command::new("taskkill") - 273
.args(["/PID", &pid.to_string(), "/T", "/F"]) - 274
.output(); - 275
} - 276
} - 277
- 278
async fn read_capped<R: tokio::io::AsyncRead + Unpin>(r: &mut Option<R>) -> String { - 279
use tokio::io::AsyncReadExt; - 280
let Some(r) = r else { - 281
return String::new(); - 282
}; - 283
let mut buf = Vec::new(); - 284
let mut chunk = [0u8; 8192]; - 285
loop { - 286
match r.read(&mut chunk).await { - 287
Ok(0) | Err(_) => break, - 288
Ok(n) => { - 289
let space = MAX_CAPTURE.saturating_sub(buf.len()); - 290
buf.extend_from_slice(&chunk[..n.min(space)]); - 291
if buf.len() >= MAX_CAPTURE { - 292
break; - 293
} - 294
} - 295
} - 296
} - 297
String::from_utf8_lossy(&buf).into_owned() - 298
} - 299
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.