use std::io::Read;
use std::path::{Path, PathBuf};
use farhand_core::config::ApprovalMode;
use farhand_core::Config;
use serde::Deserialize;
pub const CLAUDE_LOCAL_TOOLS: &[&str] = &[
"Bash",
"Edit",
"Write",
"MultiEdit",
"NotebookEdit",
"Read",
"Glob",
"Grep",
];
pub const MCP_PREFIX: &str = "mcp__farhand__";
#[derive(Deserialize)]
struct HookInput {
#[serde(default)]
cwd: Option<PathBuf>,
#[serde(default)]
tool_name: String,
}
fn decision(kind: &str, reason: &str) -> String {
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": kind,
"permissionDecisionReason": reason,
}
})
.to_string()
}
fn replacement(tool: &str) -> &'static str {
match tool {
"Bash" => "mcp__farhand__remote_bash",
"Edit" | "MultiEdit" | "NotebookEdit" => "mcp__farhand__remote_edit",
"Write" => "mcp__farhand__remote_write",
"Read" => "mcp__farhand__remote_read",
"Glob" => "mcp__farhand__remote_glob",
"Grep" => "mcp__farhand__remote_grep",
_ => "the mcp__farhand__* tools",
}
}
pub fn respond(input_json: &str, explicit_config: Option<&Path>) -> Option<String> {
let input: HookInput = serde_json::from_str(input_json).ok()?;
let tool = input.tool_name.as_str();
let is_local = CLAUDE_LOCAL_TOOLS.contains(&tool);
let farhand_tool = tool.strip_prefix(MCP_PREFIX);
if !is_local && farhand_tool.is_none() {
return None;
}
let loaded = Config::load_in(input.cwd.as_deref(), explicit_config);
let config = match loaded {
Ok(l) if !l.active() => return None,
Ok(l) => l.config,
Err(farhand_core::Error::NoConfig) => return None,
Err(e) => {
return Some(decision(
"ask",
&format!("FarHand: cannot read its config ({e}); asking to be safe"),
))
}
};
if is_local {
return Some(decision(
"deny",
&format!(
"FarHand: the local tool {tool} is disabled in this session; everything runs on \
the remote host. Use {} instead.",
replacement(tool)
),
));
}
let farhand_tool = farhand_tool?;
match config.approval.effective().get(farhand_tool).copied() {
Some(ApprovalMode::Auto) => Some(decision(
"allow",
&format!("FarHand [approval]: {farhand_tool} is set to auto"),
)),
Some(ApprovalMode::Ask) | Some(ApprovalMode::Strict) => Some(decision(
"ask",
&format!("FarHand [approval]: {farhand_tool} is set to ask"),
)),
None => None,
}
}
pub fn run(explicit_config: Option<&Path>) -> anyhow::Result<()> {
let mut input = String::new();
std::io::stdin().read_to_string(&mut input)?;
if let Some(out) = respond(&input, explicit_config) {
println!("{out}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg_dir(approval: &str) -> tempfile::TempDir {
let d = tempfile::tempdir().unwrap();
std::fs::write(
d.path().join(".farhand.toml"),
format!("[remote]\nhost='h'\nworkdir='/'\n[approval]\n{approval}\n"),
)
.unwrap();
d
}
fn call(cwd: &Path, tool: &str) -> Option<serde_json::Value> {
let input = serde_json::json!({ "cwd": cwd, "tool_name": tool, "tool_input": {} });
respond(&input.to_string(), None).map(|s| serde_json::from_str(&s).unwrap())
}
fn kind(v: &Option<serde_json::Value>) -> String {
v.as_ref().unwrap()["hookSpecificOutput"]["permissionDecision"]
.as_str()
.unwrap()
.to_string()
}
#[test]
fn local_tools_are_denied() {
let d = cfg_dir("mode='auto'");
for t in CLAUDE_LOCAL_TOOLS {
let v = call(d.path(), t);
assert_eq!(kind(&v), "deny", "{t}");
}
let v = call(d.path(), "Bash");
assert!(v.unwrap()["hookSpecificOutput"]["permissionDecisionReason"]
.as_str()
.unwrap()
.contains("mcp__farhand__remote_bash"));
}
#[test]
fn farhand_tools_follow_approval() {
let d = cfg_dir("mode='ask'\ntools={ remote_write='auto' }");
assert_eq!(kind(&call(d.path(), "mcp__farhand__remote_bash")), "ask");
assert_eq!(kind(&call(d.path(), "mcp__farhand__remote_write")), "allow");
assert_eq!(kind(&call(d.path(), "mcp__farhand__remote_read")), "allow");
let strict = cfg_dir("mode='strict'");
assert_eq!(
kind(&call(strict.path(), "mcp__farhand__remote_read")),
"ask"
);
}
#[test]
fn other_tools_get_no_decision() {
let d = cfg_dir("mode='auto'");
assert!(call(d.path(), "WebFetch").is_none());
assert!(call(d.path(), "mcp__other__thing").is_none());
assert!(call(d.path(), "mcp__farhand__unknown").is_none());
}
#[test]
fn inactive_config_makes_no_decision() {
use farhand_core::config::{Loaded, Source};
let cfg =
farhand_core::Config::parse("activation='project'\n[remote]\nhost='h'\nworkdir='/'")
.unwrap();
let l = Loaded {
config: cfg,
path: PathBuf::new(),
source: Source::Global,
};
assert!(!l.active());
}
#[test]
fn no_config_makes_no_decision() {
let d = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let prev = std::env::var_os("XDG_CONFIG_HOME");
std::env::set_var("XDG_CONFIG_HOME", home.path());
let r = call(d.path(), "Bash");
let r2 = call(d.path(), "mcp__farhand__remote_bash");
match prev {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
assert!(r.is_none());
assert!(r2.is_none());
}
#[test]
fn broken_config_asks() {
let d = tempfile::tempdir().unwrap();
std::fs::write(d.path().join(".farhand.toml"), "nonsense = [").unwrap();
assert_eq!(kind(&call(d.path(), "mcp__farhand__remote_bash")), "ask");
}
}