a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
518 lines
18 KiB
Rust
518 lines
18 KiB
Rust
//! User-configurable lifecycle hooks.
|
|
//!
|
|
//! Hooks are external commands that jcode runs at well-defined lifecycle
|
|
//! points so other programs can observe or gate agent behavior without
|
|
//! forking jcode. They are configured in `[hooks]` in config.toml (or
|
|
//! `JCODE_HOOK_*` env vars) and follow the same command-line conventions as
|
|
//! `[terminal] spawn_hook`: the command is parsed shell-style but executed
|
|
//! directly (no shell), with `JCODE_HOOK_*` metadata env vars describing the
|
|
//! event.
|
|
//!
|
|
//! Two dispatch styles:
|
|
//!
|
|
//! - **Observers** (`turn_start`, `turn_end`, `session_start`, `session_end`,
|
|
//! `post_tool`): spawned detached, fire-and-forget. Failures are logged and
|
|
//! never affect the agent.
|
|
//! - **Gate** (`pre_tool`): jcode waits (with a timeout) for the hook to
|
|
//! exit. Exit 0 allows the tool call, exit 2 blocks it and the hook's
|
|
//! stderr is fed back to the model as the tool error. Any other outcome
|
|
//! (other exit codes, timeout, spawn failure) fails open with a warning.
|
|
//!
|
|
//! Hook processes get `JCODE_HOOKS_DISABLED=1` in their environment so a
|
|
//! hook that itself invokes jcode does not recursively trigger hooks.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
/// Maximum bytes of JSON payload exported via `JCODE_HOOK_PAYLOAD`.
|
|
const PAYLOAD_ENV_LIMIT: usize = 16 * 1024;
|
|
/// Maximum bytes of tool input JSON exported to the pre_tool gate.
|
|
const TOOL_INPUT_ENV_LIMIT: usize = 16 * 1024;
|
|
/// Maximum chars of hook stderr used as a block reason.
|
|
const BLOCK_REASON_LIMIT: usize = 2000;
|
|
|
|
/// Decision returned by the `pre_tool` gate hook.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum GateDecision {
|
|
Allow,
|
|
Block { reason: String },
|
|
}
|
|
|
|
/// A lifecycle event to deliver to a hook.
|
|
#[derive(Debug, Clone)]
|
|
pub struct HookEvent {
|
|
/// Event name: "turn_start", "turn_end", "session_start", "session_end",
|
|
/// "post_tool".
|
|
pub event: &'static str,
|
|
pub session_id: Option<String>,
|
|
pub cwd: Option<String>,
|
|
/// Extra env fields. Keys are suffixes: ("STATUS", "ok") becomes
|
|
/// `JCODE_HOOK_STATUS=ok` and `"status": "ok"` in the JSON payload.
|
|
pub fields: Vec<(&'static str, String)>,
|
|
}
|
|
|
|
impl HookEvent {
|
|
pub fn new(event: &'static str) -> Self {
|
|
Self {
|
|
event,
|
|
session_id: None,
|
|
cwd: None,
|
|
fields: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn session_id(mut self, session_id: impl Into<String>) -> Self {
|
|
self.session_id = Some(session_id.into());
|
|
self
|
|
}
|
|
|
|
pub fn cwd(mut self, cwd: impl Into<String>) -> Self {
|
|
self.cwd = Some(cwd.into());
|
|
self
|
|
}
|
|
|
|
pub fn field(mut self, key: &'static str, value: impl Into<String>) -> Self {
|
|
self.fields.push((key, value.into()));
|
|
self
|
|
}
|
|
}
|
|
|
|
/// The configured command for `event`, if any.
|
|
pub fn hook_command(event: &str) -> Option<String> {
|
|
if hooks_suppressed() {
|
|
return None;
|
|
}
|
|
let hooks = &crate::config::config().hooks;
|
|
let raw = match event {
|
|
"turn_start" => hooks.turn_start.as_deref(),
|
|
"turn_end" => hooks.turn_end.as_deref(),
|
|
"session_start" => hooks.session_start.as_deref(),
|
|
"session_end" => hooks.session_end.as_deref(),
|
|
"pre_tool" => hooks.pre_tool.as_deref(),
|
|
"post_tool" => hooks.post_tool.as_deref(),
|
|
_ => None,
|
|
};
|
|
raw.map(str::trim)
|
|
.filter(|command| !command.is_empty())
|
|
.map(str::to_string)
|
|
}
|
|
|
|
/// Whether a hook is configured for `event`. Cheap; used by hot paths to
|
|
/// skip payload construction entirely when no hook is set.
|
|
pub fn hook_configured(event: &str) -> bool {
|
|
hook_command(event).is_some()
|
|
}
|
|
|
|
/// True when running inside a hook process (recursion guard).
|
|
fn hooks_suppressed() -> bool {
|
|
std::env::var_os("JCODE_HOOKS_DISABLED").is_some()
|
|
}
|
|
|
|
fn expand_home(program: &str) -> PathBuf {
|
|
if let Some(rest) = program.strip_prefix("~/")
|
|
&& let Some(home) = dirs::home_dir()
|
|
{
|
|
return home.join(rest);
|
|
}
|
|
PathBuf::from(program)
|
|
}
|
|
|
|
fn truncate_bytes(value: &str, limit: usize) -> &str {
|
|
if value.len() <= limit {
|
|
return value;
|
|
}
|
|
let mut end = limit;
|
|
while end > 0 && !value.is_char_boundary(end) {
|
|
end -= 1;
|
|
}
|
|
&value[..end]
|
|
}
|
|
|
|
/// JSON payload mirroring the env fields, exported as `JCODE_HOOK_PAYLOAD`.
|
|
fn payload_json(event: &HookEvent) -> String {
|
|
let mut map = serde_json::Map::new();
|
|
map.insert(
|
|
"event".to_string(),
|
|
serde_json::Value::String(event.event.to_string()),
|
|
);
|
|
if let Some(session_id) = &event.session_id {
|
|
map.insert(
|
|
"session_id".to_string(),
|
|
serde_json::Value::String(session_id.clone()),
|
|
);
|
|
}
|
|
if let Some(cwd) = &event.cwd {
|
|
map.insert("cwd".to_string(), serde_json::Value::String(cwd.clone()));
|
|
}
|
|
for (key, value) in &event.fields {
|
|
map.insert(
|
|
key.to_ascii_lowercase(),
|
|
serde_json::Value::String(value.clone()),
|
|
);
|
|
}
|
|
let payload = serde_json::Value::Object(map).to_string();
|
|
truncate_bytes(&payload, PAYLOAD_ENV_LIMIT).to_string()
|
|
}
|
|
|
|
fn apply_event_env(cmd: &mut std::process::Command, event: &HookEvent) {
|
|
cmd.env("JCODE_HOOKS_DISABLED", "1");
|
|
cmd.env("JCODE_HOOK_EVENT", event.event);
|
|
if let Some(session_id) = &event.session_id {
|
|
cmd.env("JCODE_HOOK_SESSION_ID", session_id);
|
|
}
|
|
if let Some(cwd) = &event.cwd {
|
|
cmd.env("JCODE_HOOK_CWD", cwd);
|
|
}
|
|
for (key, value) in &event.fields {
|
|
cmd.env(format!("JCODE_HOOK_{key}"), value);
|
|
}
|
|
cmd.env("JCODE_HOOK_PAYLOAD", payload_json(event));
|
|
}
|
|
|
|
fn build_hook_process(
|
|
command_line: &str,
|
|
event: &HookEvent,
|
|
) -> anyhow::Result<std::process::Command> {
|
|
let parts = crate::terminal_launch::parse_hook_command(command_line)?;
|
|
let (program, args) = parts
|
|
.split_first()
|
|
.expect("parse_hook_command guarantees at least one part");
|
|
let mut cmd = std::process::Command::new(expand_home(program));
|
|
cmd.args(args);
|
|
if let Some(cwd) = event.cwd.as_deref().filter(|cwd| !cwd.is_empty())
|
|
&& std::path::Path::new(cwd).is_dir()
|
|
{
|
|
cmd.current_dir(cwd);
|
|
}
|
|
apply_event_env(&mut cmd, event);
|
|
Ok(cmd)
|
|
}
|
|
|
|
/// Fire an observer hook for `event` if one is configured.
|
|
///
|
|
/// Detached and fire-and-forget: failures are logged, never propagated, and
|
|
/// the hook process cannot block the agent.
|
|
pub fn dispatch_observer(event: HookEvent) {
|
|
let Some(command_line) = hook_command(event.event) else {
|
|
return;
|
|
};
|
|
let event_name = event.event;
|
|
match build_hook_process(&command_line, &event) {
|
|
Ok(mut cmd) => {
|
|
cmd.stdin(std::process::Stdio::null())
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null());
|
|
match crate::platform::spawn_detached(&mut cmd) {
|
|
Ok(_) => crate::logging::debug(&format!(
|
|
"Hook '{event_name}' dispatched to '{command_line}' (session={:?})",
|
|
event.session_id
|
|
)),
|
|
Err(error) => crate::logging::warn(&format!(
|
|
"Hook '{event_name}' command '{command_line}' failed to start: {error}"
|
|
)),
|
|
}
|
|
}
|
|
Err(error) => crate::logging::warn(&format!(
|
|
"Hook '{event_name}' command '{command_line}' is invalid: {error}"
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Run the `pre_tool` gate hook for a tool call, if configured.
|
|
///
|
|
/// The hook receives `JCODE_HOOK_TOOL_NAME` plus the full tool input JSON on
|
|
/// stdin (and truncated in `JCODE_HOOK_TOOL_INPUT`). Contract:
|
|
///
|
|
/// - exit 0: allow the tool call
|
|
/// - exit 2: block it; stderr becomes the error shown to the model
|
|
/// - anything else (other exits, timeout, spawn failure): fail open
|
|
pub async fn run_pre_tool_gate(
|
|
session_id: &str,
|
|
working_dir: Option<&str>,
|
|
tool_name: &str,
|
|
tool_input_json: &str,
|
|
) -> GateDecision {
|
|
let Some(command_line) = hook_command("pre_tool") else {
|
|
return GateDecision::Allow;
|
|
};
|
|
|
|
let mut event = HookEvent::new("pre_tool")
|
|
.session_id(session_id)
|
|
.field("TOOL_NAME", tool_name)
|
|
.field(
|
|
"TOOL_INPUT",
|
|
truncate_bytes(tool_input_json, TOOL_INPUT_ENV_LIMIT),
|
|
);
|
|
if let Some(cwd) = working_dir {
|
|
event = event.cwd(cwd);
|
|
}
|
|
|
|
let std_cmd = match build_hook_process(&command_line, &event) {
|
|
Ok(cmd) => cmd,
|
|
Err(error) => {
|
|
crate::logging::warn(&format!(
|
|
"Hook 'pre_tool' command '{command_line}' is invalid: {error} (allowing tool call)"
|
|
));
|
|
return GateDecision::Allow;
|
|
}
|
|
};
|
|
|
|
let mut cmd = tokio::process::Command::from(std_cmd);
|
|
cmd.stdin(std::process::Stdio::piped())
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::piped())
|
|
.kill_on_drop(true);
|
|
|
|
let mut child = match cmd.spawn() {
|
|
Ok(child) => child,
|
|
Err(error) => {
|
|
crate::logging::warn(&format!(
|
|
"Hook 'pre_tool' command '{command_line}' failed to start: {error} (allowing tool call)"
|
|
));
|
|
return GateDecision::Allow;
|
|
}
|
|
};
|
|
|
|
if let Some(mut stdin) = child.stdin.take() {
|
|
use tokio::io::AsyncWriteExt;
|
|
let _ = stdin.write_all(tool_input_json.as_bytes()).await;
|
|
// Closing stdin signals EOF to hooks that read the whole input.
|
|
drop(stdin);
|
|
}
|
|
|
|
let timeout =
|
|
std::time::Duration::from_millis(crate::config::config().hooks.pre_tool_timeout_ms.max(1));
|
|
let output = match tokio::time::timeout(timeout, child.wait_with_output()).await {
|
|
Ok(Ok(output)) => output,
|
|
Ok(Err(error)) => {
|
|
crate::logging::warn(&format!(
|
|
"Hook 'pre_tool' command '{command_line}' failed: {error} (allowing tool call)"
|
|
));
|
|
return GateDecision::Allow;
|
|
}
|
|
Err(_elapsed) => {
|
|
crate::logging::warn(&format!(
|
|
"Hook 'pre_tool' command '{command_line}' timed out after {}ms (allowing tool call)",
|
|
timeout.as_millis()
|
|
));
|
|
return GateDecision::Allow;
|
|
}
|
|
};
|
|
|
|
match output.status.code() {
|
|
Some(0) => GateDecision::Allow,
|
|
Some(2) => {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
let reason = stderr.trim();
|
|
let reason = if reason.is_empty() {
|
|
"blocked by pre_tool hook".to_string()
|
|
} else {
|
|
truncate_bytes(reason, BLOCK_REASON_LIMIT).to_string()
|
|
};
|
|
crate::logging::info(&format!(
|
|
"Hook 'pre_tool' blocked tool '{tool_name}' for session {session_id}: {reason}"
|
|
));
|
|
GateDecision::Block { reason }
|
|
}
|
|
other => {
|
|
crate::logging::warn(&format!(
|
|
"Hook 'pre_tool' command '{command_line}' exited with {other:?} (expected 0=allow or 2=block; allowing tool call)"
|
|
));
|
|
GateDecision::Allow
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(clippy::await_holding_lock)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn payload_json_includes_event_and_lowercased_fields() {
|
|
let event = HookEvent::new("turn_end")
|
|
.session_id("ses_x")
|
|
.cwd("/work")
|
|
.field("STATUS", "ok")
|
|
.field("DURATION_MS", "1200");
|
|
let payload: serde_json::Value = serde_json::from_str(&payload_json(&event)).unwrap();
|
|
assert_eq!(payload["event"], "turn_end");
|
|
assert_eq!(payload["session_id"], "ses_x");
|
|
assert_eq!(payload["cwd"], "/work");
|
|
assert_eq!(payload["status"], "ok");
|
|
assert_eq!(payload["duration_ms"], "1200");
|
|
}
|
|
|
|
#[test]
|
|
fn truncate_bytes_respects_char_boundaries() {
|
|
let text = "héllo wörld";
|
|
let truncated = truncate_bytes(text, 3);
|
|
assert!(truncated.len() <= 3);
|
|
assert!(text.starts_with(truncated));
|
|
assert_eq!(truncate_bytes("short", 100), "short");
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn write_executable_script(dir: &std::path::Path, name: &str, body: &str) -> PathBuf {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let path = dir.join(name);
|
|
std::fs::write(&path, body).expect("write script");
|
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
|
|
.expect("chmod script");
|
|
path
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn gate_test_config(hook: &str, timeout_ms: u64) -> impl Drop + use<> {
|
|
struct EnvReset(Vec<(&'static str, Option<std::ffi::OsString>)>);
|
|
impl Drop for EnvReset {
|
|
fn drop(&mut self) {
|
|
for (key, previous) in self.0.drain(..) {
|
|
match previous {
|
|
Some(value) => crate::env::set_var(key, value),
|
|
None => crate::env::remove_var(key),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let reset = EnvReset(vec![
|
|
(
|
|
"JCODE_HOOK_PRE_TOOL",
|
|
std::env::var_os("JCODE_HOOK_PRE_TOOL"),
|
|
),
|
|
(
|
|
"JCODE_HOOK_PRE_TOOL_TIMEOUT_MS",
|
|
std::env::var_os("JCODE_HOOK_PRE_TOOL_TIMEOUT_MS"),
|
|
),
|
|
]);
|
|
crate::env::set_var("JCODE_HOOK_PRE_TOOL", hook);
|
|
crate::env::set_var("JCODE_HOOK_PRE_TOOL_TIMEOUT_MS", timeout_ms.to_string());
|
|
reset
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn pre_tool_gate_allows_on_exit_zero_and_blocks_on_exit_two() {
|
|
let _guard = crate::storage::lock_test_env();
|
|
let temp = tempfile::TempDir::new().expect("temp dir");
|
|
|
|
// Blocking hook: reads stdin, writes a reason to stderr, exits 2.
|
|
let block = write_executable_script(
|
|
temp.path(),
|
|
"block.sh",
|
|
"#!/bin/sh\ncat > /dev/null\necho \"dangerous tool: $JCODE_HOOK_TOOL_NAME\" >&2\nexit 2\n",
|
|
);
|
|
{
|
|
let _env = gate_test_config(&block.to_string_lossy(), 5000);
|
|
let decision =
|
|
run_pre_tool_gate("ses_g", None, "bash", r#"{"command":"rm -rf /"}"#).await;
|
|
assert_eq!(
|
|
decision,
|
|
GateDecision::Block {
|
|
reason: "dangerous tool: bash".to_string()
|
|
}
|
|
);
|
|
}
|
|
|
|
// Allowing hook: exit 0.
|
|
let allow = write_executable_script(temp.path(), "allow.sh", "#!/bin/sh\nexit 0\n");
|
|
{
|
|
let _env = gate_test_config(&allow.to_string_lossy(), 5000);
|
|
let decision = run_pre_tool_gate("ses_g", None, "read", "{}").await;
|
|
assert_eq!(decision, GateDecision::Allow);
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn pre_tool_gate_fails_open_on_timeout_and_odd_exits() {
|
|
let _guard = crate::storage::lock_test_env();
|
|
let temp = tempfile::TempDir::new().expect("temp dir");
|
|
|
|
// Hook that hangs: must fail open after the timeout.
|
|
let hang = write_executable_script(temp.path(), "hang.sh", "#!/bin/sh\nsleep 30\n");
|
|
{
|
|
let _env = gate_test_config(&hang.to_string_lossy(), 200);
|
|
let decision = run_pre_tool_gate("ses_g", None, "bash", "{}").await;
|
|
assert_eq!(decision, GateDecision::Allow);
|
|
}
|
|
|
|
// Hook with an unexpected exit code: fail open.
|
|
let odd = write_executable_script(temp.path(), "odd.sh", "#!/bin/sh\nexit 7\n");
|
|
{
|
|
let _env = gate_test_config(&odd.to_string_lossy(), 5000);
|
|
let decision = run_pre_tool_gate("ses_g", None, "bash", "{}").await;
|
|
assert_eq!(decision, GateDecision::Allow);
|
|
}
|
|
|
|
// Missing hook binary: fail open.
|
|
{
|
|
let _env = gate_test_config("/nonexistent/hook-binary", 5000);
|
|
let decision = run_pre_tool_gate("ses_g", None, "bash", "{}").await;
|
|
assert_eq!(decision, GateDecision::Allow);
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn pre_tool_gate_receives_input_on_stdin() {
|
|
let _guard = crate::storage::lock_test_env();
|
|
let temp = tempfile::TempDir::new().expect("temp dir");
|
|
let record = temp.path().join("stdin.txt");
|
|
let script = write_executable_script(
|
|
temp.path(),
|
|
"record.sh",
|
|
&format!(
|
|
"#!/bin/sh\ncat > {}\nexit 0\n",
|
|
crate::terminal_launch::sh_escape(&record.to_string_lossy())
|
|
),
|
|
);
|
|
let _env = gate_test_config(&script.to_string_lossy(), 5000);
|
|
let input = r#"{"file_path":"/tmp/x","content":"hello"}"#;
|
|
let decision = run_pre_tool_gate("ses_g", None, "write", input).await;
|
|
assert_eq!(decision, GateDecision::Allow);
|
|
let recorded = std::fs::read_to_string(&record).expect("stdin should be recorded");
|
|
assert_eq!(recorded, input);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn observer_dispatch_runs_hook_with_event_env() {
|
|
let _guard = crate::storage::lock_test_env();
|
|
let temp = tempfile::TempDir::new().expect("temp dir");
|
|
let record = temp.path().join("event.txt");
|
|
let script = write_executable_script(
|
|
temp.path(),
|
|
"observe.sh",
|
|
&format!(
|
|
"#!/bin/sh\nprintf '%s|%s|%s|%s' \"$JCODE_HOOK_EVENT\" \"$JCODE_HOOK_SESSION_ID\" \"$JCODE_HOOK_STATUS\" \"$JCODE_HOOKS_DISABLED\" > {}\n",
|
|
crate::terminal_launch::sh_escape(&record.to_string_lossy())
|
|
),
|
|
);
|
|
|
|
let prev = std::env::var_os("JCODE_HOOK_TURN_END");
|
|
crate::env::set_var("JCODE_HOOK_TURN_END", script.to_string_lossy().to_string());
|
|
|
|
dispatch_observer(
|
|
HookEvent::new("turn_end")
|
|
.session_id("ses_obs")
|
|
.field("STATUS", "ok"),
|
|
);
|
|
|
|
let mut recorded = String::new();
|
|
for _ in 0..100 {
|
|
if let Ok(data) = std::fs::read_to_string(&record)
|
|
&& !data.is_empty()
|
|
{
|
|
recorded = data;
|
|
break;
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(20));
|
|
}
|
|
match prev {
|
|
Some(value) => crate::env::set_var("JCODE_HOOK_TURN_END", value),
|
|
None => crate::env::remove_var("JCODE_HOOK_TURN_END"),
|
|
}
|
|
assert_eq!(recorded, "turn_end|ses_obs|ok|1");
|
|
}
|
|
}
|