use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::wire::HOOK_SCHEMA_VERSION;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookEvent {
#[default]
PreToolUse,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookCall {
pub agent_id: String,
pub tool_name: String,
pub tool_call_id: String,
pub input_json: String,
}
impl HookCall {
pub fn new(
agent_id: impl Into<String>,
tool_name: impl Into<String>,
tool_call_id: impl Into<String>,
input_json: impl Into<String>,
) -> Self {
Self {
agent_id: agent_id.into(),
tool_name: tool_name.into(),
tool_call_id: tool_call_id.into(),
input_json: input_json.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HookRequest {
pub hook_schema: u32,
pub event: HookEvent,
pub workspace: PathBuf,
pub agent_id: String,
pub tool_call_id: String,
pub tool_name: String,
pub input: Value,
}
impl HookRequest {
pub fn from_call(event: HookEvent, workspace: &Path, call: &HookCall) -> Self {
Self {
hook_schema: HOOK_SCHEMA_VERSION,
event,
workspace: workspace.to_path_buf(),
agent_id: call.agent_id.clone(),
tool_call_id: call.tool_call_id.clone(),
tool_name: call.tool_name.clone(),
input: serde_json::from_str(&call.input_json)
.unwrap_or_else(|_| Value::String(call.input_json.clone())),
}
}
pub fn with_input(self, input: Value) -> Self {
Self { input, ..self }
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum HookOutcome {
Allow,
Deny(String),
Modify {
input: Value,
reason: Option<String>,
},
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn call(input_json: &str) -> HookCall {
HookCall::new("agent-1", "shell", "call-1", input_json)
}
#[test]
fn a_request_carries_the_schema_version_first() {
let request = HookRequest::from_call(
HookEvent::PreToolUse,
Path::new("/repo"),
&call(r#"{"command":"ls"}"#),
);
let json = serde_json::to_value(&request).expect("serializes");
assert_eq!(json["hook_schema"], HOOK_SCHEMA_VERSION);
assert_eq!(json["event"], "pre_tool_use");
assert_eq!(json["workspace"], "/repo");
assert_eq!(json["tool_name"], "shell");
assert_eq!(json["tool_call_id"], "call-1");
assert_eq!(json["input"]["command"], "ls");
}
#[test]
fn input_that_is_not_json_is_carried_as_the_raw_string() {
let request =
HookRequest::from_call(HookEvent::PreToolUse, Path::new("/repo"), &call("ls -l"));
assert_eq!(request.input, json!("ls -l"));
}
#[test]
fn a_request_can_be_rebuilt_around_a_new_input() {
let original = HookRequest::from_call(
HookEvent::PreToolUse,
Path::new("/repo"),
&call(r#"{"command":"rm -rf /"}"#),
);
let next = original.clone().with_input(json!({"command": "ls"}));
assert_eq!(
original.input,
json!({"command": "rm -rf /"}),
"the original must be untouched"
);
assert_eq!(next.input, json!({"command": "ls"}));
assert_eq!(next.tool_call_id, original.tool_call_id);
}
}