use std::io::Read;
use std::path::PathBuf;
use super::classify::{Verdict, classify};
pub fn disabled_path() -> PathBuf {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".to_string())).join(".config")
});
base.join("burn").join("agent.disabled")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Host {
ClaudeCode,
Codex,
Gemini,
Cursor,
Copilot,
Antigravity,
Generic,
}
impl Host {
#[must_use]
pub fn parse(s: &str) -> Self {
match s {
"claude-code" | "claude" => Self::ClaudeCode,
"codex" => Self::Codex,
"gemini" => Self::Gemini,
"cursor" => Self::Cursor,
"copilot" => Self::Copilot,
"antigravity" | "agy" => Self::Antigravity,
_ => Self::Generic,
}
}
}
fn is_shell_tool(name: &str) -> bool {
matches!(
name,
"Bash"
| "bash"
| "shell"
| "Shell"
| "run_shell_command"
| "run_command"
| "run_terminal_cmd"
| "terminal"
)
}
fn extract_command(v: &serde_json::Value) -> Option<String> {
let tool = v
.get("tool_name")
.or_else(|| v.get("toolName"))
.or_else(|| v.get("tool"))
.and_then(|t| t.as_str());
if let Some(t) = tool
&& !is_shell_tool(t)
{
return None; }
for input_key in ["tool_input", "toolInput", "input", "arguments", "args"] {
if let Some(cmd) = v
.get(input_key)
.and_then(|i| i.get("command"))
.and_then(|c| c.as_str())
{
return Some(cmd.to_string());
}
}
if let Some(cmd) = v
.get("toolCall")
.and_then(|t| t.get("args"))
.and_then(|a| a.get("CommandLine"))
.and_then(|c| c.as_str())
{
return Some(cmd.to_string());
}
v.get("command")
.and_then(|c| c.as_str())
.map(ToString::to_string)
}
fn deny_json(host: Host, reason: &str) -> String {
let body = match host {
Host::ClaudeCode | Host::Codex => serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
}),
Host::Gemini | Host::Antigravity => {
serde_json::json!({ "decision": "deny", "reason": reason })
}
Host::Cursor => serde_json::json!({
"permission": "deny",
"user_message": "Rerouting JavaScript into the burn sandbox",
"agent_message": reason,
}),
Host::Copilot => serde_json::json!({
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}),
Host::Generic => serde_json::json!({ "decision": "deny", "reason": reason }),
};
body.to_string()
}
fn redirect_suppressed() -> bool {
matches!(
std::env::var("BURN_AGENT_HOOK").as_deref(),
Ok("0" | "off" | "false")
) || disabled_path().exists()
}
#[must_use]
pub fn respond(host: Host, stdin: &str) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(stdin).ok()?; let command = extract_command(&v)?;
match classify(&command) {
Verdict::Allow => None,
Verdict::Redirect { reason, .. } => Some(deny_json(host, &reason)),
}
}
pub fn run(host: &str) -> anyhow::Result<()> {
let mut stdin = String::new();
if std::io::stdin().read_to_string(&mut stdin).is_err() {
return Ok(());
}
if redirect_suppressed() {
return Ok(());
}
if let Some(out) = respond(Host::parse(host), &stdin) {
println!("{out}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn claude_payload(command: &str) -> String {
serde_json::json!({
"session_id": "s1",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": command },
})
.to_string()
}
#[test]
fn node_command_is_denied_with_the_corrected_command() {
let out = respond(Host::ClaudeCode, &claude_payload("node app.js")).expect("deny");
assert!(out.contains("\"permissionDecision\":\"deny\""));
assert!(out.contains("burn --sandbox node app.js"));
assert!(out.contains("PreToolUse"));
}
#[test]
fn safe_commands_stay_silent() {
assert!(respond(Host::ClaudeCode, &claude_payload("npm install express")).is_none());
assert!(respond(Host::ClaudeCode, &claude_payload("git status")).is_none());
assert!(respond(Host::ClaudeCode, &claude_payload("burn node app.js")).is_none());
}
#[test]
fn non_shell_tools_are_never_inspected() {
let payload = serde_json::json!({
"tool_name": "Write",
"tool_input": { "command": "node app.js", "file_path": "x" },
})
.to_string();
assert!(respond(Host::ClaudeCode, &payload).is_none());
}
#[test]
fn garbage_stdin_is_fail_open() {
assert!(respond(Host::ClaudeCode, "not json at all").is_none());
assert!(respond(Host::ClaudeCode, "").is_none());
assert!(respond(Host::ClaudeCode, "{}").is_none());
}
#[test]
fn gemini_dialect_uses_decision_field() {
let payload = serde_json::json!({
"tool_name": "run_shell_command",
"tool_input": { "command": "npx tsx main.ts" },
})
.to_string();
let out = respond(Host::Gemini, &payload).expect("deny");
assert!(out.contains("\"decision\":\"deny\""));
assert!(out.contains("burn --sandbox npx tsx main.ts"));
}
#[test]
fn cursor_top_level_command_shape_is_understood() {
let payload = serde_json::json!({ "command": "node -e 'console.log(1)'" }).to_string();
let out = respond(Host::Cursor, &payload).expect("deny");
assert!(out.contains("\"permission\":\"deny\""));
assert!(out.contains("agent_message"));
}
#[test]
fn antigravity_tool_call_shape_is_understood() {
let payload = serde_json::json!({
"toolCall": { "name": "run_command", "args": { "CommandLine": "node app.js" } },
})
.to_string();
let out = respond(Host::Antigravity, &payload).expect("deny");
assert!(out.contains("\"decision\":\"deny\""));
assert!(out.contains("burn --sandbox node app.js"));
let safe = serde_json::json!({
"toolCall": { "name": "run_command", "args": { "CommandLine": "git status" } },
})
.to_string();
assert!(respond(Host::Antigravity, &safe).is_none());
}
#[test]
fn copilot_dialect_is_flat() {
let payload = serde_json::json!({
"tool_name": "bash",
"tool_input": { "command": "npm test" },
})
.to_string();
let out = respond(Host::Copilot, &payload).expect("deny");
assert!(out.contains("\"permissionDecision\":\"deny\""));
assert!(!out.contains("hookSpecificOutput"));
}
}