use std::path::Path;
use openmoat_core::Verdict;
use openmoat_hosts::Host;
use serde::Serialize;
use serde_json::{Value, json};
use super::Call;
use super::hook::Reply;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Answer {
Allow,
Passthrough,
Error,
Ask,
Deny,
}
impl From<Verdict> for Answer {
fn from(verdict: Verdict) -> Self {
match verdict {
Verdict::Allow => Self::Allow,
Verdict::Ask => Self::Ask,
_ => Self::Deny,
}
}
}
impl Answer {
pub fn word(self) -> &'static str {
match self {
Self::Allow => "allow",
Self::Passthrough => "passthrough",
Self::Error => "error",
Self::Ask => "ask",
Self::Deny => "deny",
}
}
pub fn meets(self, want: Self) -> bool {
self == want || (want == Self::Allow && self == Self::Passthrough)
}
}
pub fn seen(host: Host, call: &Call, verdict: Verdict) -> Answer {
match (host, call, verdict) {
(Host::Codex, _, Verdict::Ask)
| (Host::Cursor, Call::Read(_) | Call::Write(_) | Call::Edit(_), Verdict::Ask) => {
Answer::Deny
}
(_, _, other) => other.into(),
}
}
pub fn classify(host: Host, call: &Call, reply: &Reply) -> Answer {
let Reply::Exited {
code,
stdout,
stderr,
} = reply
else {
return Answer::Error;
};
let document: Value = serde_json::from_str(stdout.trim()).unwrap_or(Value::Null);
match (host, *code) {
(Host::Cursor, Some(0)) => match (document["permission"].as_str(), call) {
(Some("allow"), _) => Answer::Allow,
(Some("deny"), _) => Answer::Deny,
(Some("ask"), Call::Write(_) | Call::Edit(_)) => Answer::Passthrough,
(Some("ask"), Call::Shell(_) | Call::Mcp { .. }) => Answer::Ask,
_ => Answer::Error,
},
(Host::Codex, Some(2)) if stderr.trim().is_empty() => Answer::Error,
(_, Some(2)) => Answer::Deny,
(Host::Cursor, _) => Answer::Error,
(_, Some(0)) => {
let decision = document["hookSpecificOutput"]["permissionDecision"]
.as_str()
.or_else(|| document["decision"].as_str());
match decision {
Some("allow" | "approve") => Answer::Allow,
Some("deny" | "block") => Answer::Deny,
Some("ask") if host != Host::Codex => Answer::Ask,
_ => Answer::Passthrough,
}
}
_ => Answer::Error,
}
}
pub fn failure(host: Host) -> &'static str {
match host {
Host::Codex => {
"runs the call when the hook is missing, cannot start, exits non-zero other \
than 2 (or 2 with nothing on stderr), or times out (default 600 s)"
}
Host::Cursor => {
"runs the call when the hook is missing, crashes or times out, unless the hook \
entry sets `failClosed: true`; then it blocks the call"
}
Host::ClaudeCode | Host::Continue => {
"runs the call when the hook is missing, cannot start, exits non-zero other \
than 2, or times out (default 600 s)"
}
}
}
pub fn payload(host: Host, call: &Call, at: &Place<'_>) -> Option<String> {
let (tool, input) = match (host, call) {
(Host::Cursor, _) => return cursor(call, at).map(|v| v.to_string()),
(_, Call::Shell(command)) => ("Bash".to_owned(), json!({ "command": command })),
(Host::ClaudeCode, Call::Read(path)) => {
("Read".to_owned(), json!({ "file_path": at.file(path) }))
}
(Host::ClaudeCode, Call::Write(path)) => (
"Write".to_owned(),
json!({ "file_path": at.file(path), "content": CONTENT }),
),
(Host::Codex, Call::Write(path)) => (
"apply_patch".to_owned(),
json!({ "command": format!(
"*** Begin Patch\n*** Add File: {}\n+{CONTENT}\n*** End Patch\n",
at.file(path)
) }),
),
(Host::ClaudeCode, Call::Edit(path)) => (
"Edit".to_owned(),
json!({ "file_path": at.file(path), "old_string": "old", "new_string": CONTENT }),
),
(Host::Codex, Call::Edit(path)) => (
"apply_patch".to_owned(),
json!({ "command": format!(
"*** Begin Patch\n*** Update File: {}\n@@\n-old\n+{CONTENT}\n*** End Patch\n",
at.file(path)
) }),
),
(Host::ClaudeCode, Call::Fetch(url)) => (
"WebFetch".to_owned(),
json!({ "url": url, "prompt": "summarise" }),
),
(Host::ClaudeCode | Host::Codex, Call::Mcp { server, tool, args }) => {
(format!("mcp__{server}__{tool}"), args.clone())
}
_ => return None,
};
Some(
json!({
"session_id": at.session, "turn_id": "t-1", "cwd": at.cwd(),
"hook_event_name": "PreToolUse", "tool_name": tool,
"tool_input": input, "tool_use_id": at.call_id,
})
.to_string(),
)
}
const CONTENT: &str = "written by the agent";
pub struct Place<'a> {
pub home: &'a Path,
pub project: &'a Path,
pub session: &'a str,
pub call_id: &'a str,
}
impl Place<'_> {
fn cwd(&self) -> String {
self.project.to_string_lossy().into_owned()
}
fn file(&self, path: &str) -> String {
let full = match path.strip_prefix("~/") {
Some(rest) => self.home.join(rest),
None => self.project.join(path),
};
full.to_string_lossy().into_owned()
}
}
fn cursor(call: &Call, at: &Place<'_>) -> Option<Value> {
let common = json!({
"conversation_id": at.session, "generation_id": at.call_id,
"cursor_version": "2.3.1", "workspace_roots": [at.cwd()],
});
let event = match call {
Call::Shell(command) => json!({
"hook_event_name": "beforeShellExecution", "command": command,
"cwd": at.cwd(), "sandbox": false,
}),
Call::Read(path) => json!({
"hook_event_name": "beforeReadFile", "file_path": at.file(path),
"content": "", "attachments": [],
}),
Call::Write(path) | Call::Edit(path) => json!({
"hook_event_name": "preToolUse", "cwd": at.cwd(), "tool_name": "Write",
"tool_input": { "file_path": at.file(path), "content": CONTENT },
"tool_use_id": at.call_id,
}),
Call::Mcp { server, tool, args } => json!({
"hook_event_name": "beforeMCPExecution", "tool_name": tool,
"tool_input": args.to_string(), "mcp_server_name": server,
}),
Call::Fetch(_) => return None,
};
let mut merged = common;
merged.as_object_mut()?.extend(event.as_object()?.clone());
Some(merged)
}