use std::path::{Path, PathBuf};
use crate::verdict::{SafetyLevel, Verdict};
pub mod agy;
pub mod claude;
pub mod codex;
pub mod copilot;
pub mod cursor;
pub mod droid;
pub mod gemini;
pub mod grok;
pub mod opencode;
pub mod qwen;
pub trait Target: Send + Sync {
fn name(&self) -> &'static str;
fn shell_tool_name(&self) -> &'static str {
"Bash"
}
#[cfg(test)]
fn sample_envelope(&self, _tool: &str, _command: &str) -> Option<String> {
None
}
fn display_name(&self) -> &'static str;
fn detect_paths(&self, home: &Path) -> Vec<PathBuf>;
fn install(&self, home: &Path) -> Result<InstallOutcome, String>;
fn hook_format(&self) -> Option<&dyn HookFormat> {
None
}
}
pub trait HookFormat: Send + Sync {
fn parse_input(&self, stdin: &str) -> Result<HookInput, ParseError>;
fn render_response(&self, verdict: Verdict) -> HookResponse;
fn decision_pointer(&self) -> &'static str;
fn render_context(&self, _context: &str) -> HookResponse {
HookResponse {
stdout: String::new(),
exit_code: 0,
}
}
fn gated_policy(&self) -> GatedPolicy {
GatedPolicy::Defer
}
fn render_deny(&self, _reason: &str) -> HookResponse {
HookResponse {
stdout: String::new(),
exit_code: 0,
}
}
fn render_ask(&self, _reason: &str) -> HookResponse {
HookResponse {
stdout: String::new(),
exit_code: 0,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum GatedPolicy {
Defer,
Deny,
Ask,
}
#[derive(Debug)]
pub struct ParseError {
pub message: String,
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for ParseError {}
pub struct HookInput {
pub command: String,
pub cwd: Option<String>,
pub root: Option<String>,
pub session_id: Option<String>,
}
pub fn may_grant(command: &str, verdict: crate::Verdict) -> bool {
verdict.is_allowed() && !command.trim().is_empty()
}
pub fn respond(format: &dyn HookFormat, command: &str, verdict: crate::Verdict) -> Option<HookResponse> {
may_grant(command, verdict).then(|| format.render_response(verdict))
}
pub(crate) fn append_hook_entry(
settings: &mut serde_json::Value,
outer: &str,
event: &str,
entry: serde_json::Value,
) -> Result<(), String> {
use serde_json::json;
if !settings.is_object() {
*settings = json!({});
}
let Some(obj) = settings.as_object_mut() else {
unreachable!("settings was just set to an object");
};
let hooks = obj.entry(outer).or_insert_with(|| json!({}));
let Some(hooks) = hooks.as_object_mut() else {
return Err(format!(
"`{outer}` is {}, expected an object — leaving the file unchanged",
json_kind(&obj[outer])
));
};
let slot = hooks.entry(event).or_insert_with(|| json!([]));
if !slot.is_array() {
return Err(format!(
"`{outer}.{event}` is {}, expected an array — leaving the file unchanged",
json_kind(slot)
));
}
let Some(arr) = slot.as_array_mut() else {
unreachable!("just checked it is an array");
};
arr.push(entry);
Ok(())
}
fn json_kind(v: &serde_json::Value) -> &'static str {
match v {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "a boolean",
serde_json::Value::Number(_) => "a number",
serde_json::Value::String(_) => "a string",
serde_json::Value::Array(_) => "an array",
serde_json::Value::Object(_) => "an object",
}
}
pub(crate) fn env_root(var: &str) -> Option<String> {
std::env::var(var).ok().filter(|s| !s.is_empty())
}
pub struct HookResponse {
pub stdout: String,
pub exit_code: i32,
}
pub enum InstallOutcome {
Installed { path: PathBuf },
AlreadyConfigured { path: PathBuf },
Skipped { reason: String },
}
impl InstallOutcome {
pub fn message(&self, target_display: &str) -> String {
match self {
InstallOutcome::Installed { path } => {
format!("{target_display}: installed → {}", path.display())
}
InstallOutcome::AlreadyConfigured { path } => {
format!("{target_display}: already configured at {}", path.display())
}
InstallOutcome::Skipped { reason } => {
format!("{target_display}: skipped — {reason}")
}
}
}
}
pub fn registry() -> Vec<Box<dyn Target>> {
vec![
Box::new(claude::ClaudeTarget),
Box::new(codex::CodexTarget),
Box::new(agy::AntigravityTarget),
Box::new(cursor::CursorTarget),
Box::new(gemini::GeminiTarget),
Box::new(grok::GrokTarget),
Box::new(copilot::CopilotTarget),
Box::new(qwen::QwenTarget),
Box::new(droid::DroidTarget),
Box::new(opencode::OpenCodeTarget),
]
}
pub fn find(name: &str) -> Option<Box<dyn Target>> {
registry().into_iter().find(|t| t.name() == name)
}
pub fn detect_installed(home: &Path) -> Vec<Box<dyn Target>> {
registry()
.into_iter()
.filter(|t| t.detect_paths(home).iter().any(|p| p.exists()))
.collect()
}
pub fn allow_reason(verdict: Verdict) -> &'static str {
match verdict {
Verdict::Allowed(SafetyLevel::SafeWrite) => {
"All commands in chain are safe utilities (includes file writes)"
}
Verdict::Allowed(SafetyLevel::SafeRead) => {
"All commands in chain are safe utilities (includes code execution)"
}
_ => "All commands in chain are safe utilities",
}
}
#[cfg(test)]
mod append_hook_entry_tests {
use super::*;
use serde_json::json;
#[test]
fn creates_the_path_when_absent() {
let mut s = json!({});
append_hook_entry(&mut s, "hooks", "PreToolUse", json!({"matcher": "Bash"})).unwrap();
assert_eq!(s["hooks"]["PreToolUse"][0]["matcher"], "Bash");
}
#[test]
fn appends_beside_an_existing_entry() {
let mut s = json!({"hooks": {"PreToolUse": [{"matcher": "Other"}]}});
append_hook_entry(&mut s, "hooks", "PreToolUse", json!({"matcher": "Bash"})).unwrap();
let arr = s["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(arr.len(), 2, "the user's existing hook must survive");
assert_eq!(arr[0]["matcher"], "Other");
}
#[test]
fn refuses_a_wrong_typed_outer_key_without_panicking() {
for wrong in [json!("a string"), json!([1, 2]), json!(7), json!(null)] {
let mut s = json!({ "hooks": wrong });
let before = s.clone();
let err = append_hook_entry(&mut s, "hooks", "PreToolUse", json!({})).unwrap_err();
assert!(err.contains("expected an object"), "unhelpful error: {err}");
assert_eq!(s, before, "the user's value must be left alone, not replaced");
}
}
#[test]
fn refuses_a_wrong_typed_event_key_without_panicking() {
let mut s = json!({"hooks": {"PreToolUse": "a string"}});
let before = s.clone();
let err = append_hook_entry(&mut s, "hooks", "PreToolUse", json!({})).unwrap_err();
assert!(err.contains("expected an array"), "unhelpful error: {err}");
assert_eq!(s, before, "the user's value must be left alone, not replaced");
}
#[test]
fn replaces_a_non_object_root() {
let mut s = json!("garbage");
append_hook_entry(&mut s, "hooks", "PreToolUse", json!({"matcher": "Bash"})).unwrap();
assert_eq!(s["hooks"]["PreToolUse"][0]["matcher"], "Bash");
}
}
#[cfg(test)]
mod tool_filter_tests {
use super::*;
#[test]
fn no_target_decides_on_a_foreign_tool() {
let mut failures = Vec::new();
let mut checked = 0usize;
for target in registry() {
let Some(fmt) = target.hook_format() else { continue };
let Some(shell) = target.sample_envelope(target.shell_tool_name(), "ls") else {
continue; };
let name = target.name();
if let Err(e) = fmt.parse_input(&shell) {
failures.push(format!(
"{name}: rejected its own shell tool `{}`: {}",
target.shell_tool_name(),
e.message
));
}
for foreign in ["Read", "Write", "Edit", "WebFetch"] {
let Some(env) = target.sample_envelope(foreign, "rm -rf /") else { continue };
checked += 1;
if fmt.parse_input(&env).is_ok() {
failures.push(format!(
"{name}: parsed a `{foreign}` envelope instead of abstaining"
));
}
}
}
assert!(checked > 0, "no target was probed — the guard is vacuous");
assert!(failures.is_empty(), "foreign-tool decisions:\n{}", failures.join("\n"));
}
}