stynx_code_engine/application/
hook_runner.rs1use std::sync::atomic::{AtomicBool, Ordering};
2
3use stynx_code_config::HooksConfig;
4use stynx_code_config::HookEntry;
5use tokio::process::Command;
6
7static HOOK_WARN_ONCE: AtomicBool = AtomicBool::new(false);
8
9fn hooks_trusted() -> bool {
10 if std::env::var("STYNX_TRUST_HOOKS").ok().is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes")) {
11 return true;
12 }
13 if let Some(home) = stynx_code_config::home_dir() {
14 let cwd = std::env::current_dir().ok();
15 if let Some(cwd) = cwd {
16 let marker = home.join(".stynx").join("trusted_hook_projects");
17 if let Ok(contents) = std::fs::read_to_string(&marker) {
18 let key = cwd.display().to_string();
19 if contents.lines().any(|l| l.trim() == key) {
20 return true;
21 }
22 }
23 }
24 }
25 false
26}
27
28fn warn_hooks_blocked_once(hook_kind: &str) {
29 if HOOK_WARN_ONCE.swap(true, Ordering::Relaxed) { return; }
30 tracing::warn!(
31 "blocked {hook_kind} hook from project .claude/settings.json — set STYNX_TRUST_HOOKS=1 or add the cwd to ~/.stynx/trusted_hook_projects to allow."
32 );
33}
34
35struct Outcome {
36 exit_code: i32,
37 stdout: String,
38}
39
40async fn run_cmd(command: &str, env_vars: &[(&str, &str)]) -> Outcome {
41 let (shell, flag) = stynx_code_config::shell_command();
42 let mut cmd = Command::new(shell);
43 cmd.arg(flag).arg(command);
44 for (k, v) in env_vars {
45 cmd.env(k, v);
46 }
47 match cmd.output().await {
48 Ok(out) => Outcome {
49 exit_code: out.status.code().unwrap_or(-1),
50 stdout: String::from_utf8_lossy(&out.stdout).trim().to_string(),
51 },
52 Err(_) => Outcome { exit_code: 1, stdout: String::new() },
53 }
54}
55
56fn tool_matches(entry: &HookEntry, tool_name: &str) -> bool {
57 match &entry.matcher {
58 None => true,
59 Some(m) if m.is_empty() => true,
60 Some(m) => tool_name.contains(m.as_str()),
61 }
62}
63
64pub struct PreToolDecision {
65 pub blocked: bool,
66 pub reason: String,
67 pub output: String,
68}
69
70pub async fn run_pre_tool_use(hooks: &HooksConfig, tool_name: &str, input_json: &str) -> PreToolDecision {
71 if !hooks.pre_tool_use.is_empty() && !hooks_trusted() {
72 warn_hooks_blocked_once("pre_tool_use");
73 return PreToolDecision { blocked: false, reason: String::new(), output: String::new() };
74 }
75 let env = [("CLAUDE_TOOL_NAME", tool_name), ("CLAUDE_TOOL_INPUT", input_json)];
76 for entry in hooks.pre_tool_use.iter().filter(|e| tool_matches(e, tool_name)) {
77 let out = run_cmd(&entry.command, &env).await;
78 if out.exit_code != 0 {
79 let reason = if out.stdout.is_empty() {
80 format!("blocked by hook (exit {})", out.exit_code)
81 } else {
82 out.stdout
83 };
84 return PreToolDecision { blocked: true, reason, output: String::new() };
85 }
86 if !out.stdout.is_empty() {
87 return PreToolDecision { blocked: false, reason: String::new(), output: out.stdout };
88 }
89 }
90 PreToolDecision { blocked: false, reason: String::new(), output: String::new() }
91}
92
93pub async fn run_post_tool_use(
94 hooks: &HooksConfig,
95 tool_name: &str,
96 input_json: &str,
97 tool_output: &str,
98) -> String {
99 if !hooks.post_tool_use.is_empty() && !hooks_trusted() {
100 warn_hooks_blocked_once("post_tool_use");
101 return String::new();
102 }
103 let env = [
104 ("CLAUDE_TOOL_NAME", tool_name),
105 ("CLAUDE_TOOL_INPUT", input_json),
106 ("CLAUDE_TOOL_OUTPUT", tool_output),
107 ];
108 let mut out = String::new();
109 for entry in hooks.post_tool_use.iter().filter(|e| tool_matches(e, tool_name)) {
110 let result = run_cmd(&entry.command, &env).await;
111 if !result.stdout.is_empty() {
112 if !out.is_empty() { out.push('\n'); }
113 out.push_str(&result.stdout);
114 }
115 }
116 out
117}
118
119pub async fn run_stop_hooks(hooks: &HooksConfig) -> String {
120 if !hooks.stop.is_empty() && !hooks_trusted() {
121 warn_hooks_blocked_once("stop");
122 return String::new();
123 }
124 let env: [(&str, &str); 0] = [];
125 let mut out = String::new();
126 for entry in &hooks.stop {
127 let result = run_cmd(&entry.command, &env).await;
128 if !result.stdout.is_empty() {
129 if !out.is_empty() { out.push('\n'); }
130 out.push_str(&result.stdout);
131 }
132 }
133 out
134}
135
136pub async fn run_session_start_hooks(hooks: &HooksConfig) -> String {
137 if !hooks.session_start.is_empty() && !hooks_trusted() {
138 warn_hooks_blocked_once("session_start");
139 return String::new();
140 }
141 let env: [(&str, &str); 0] = [];
142 let mut out = String::new();
143 for entry in &hooks.session_start {
144 let result = run_cmd(&entry.command, &env).await;
145 if !result.stdout.is_empty() {
146 if !out.is_empty() { out.push('\n'); }
147 out.push_str(&result.stdout);
148 }
149 }
150 out
151}