1 //! Extensibility hooks for agent lifecycle and tool execution.
2 //!
3 //! Hooks allow users to run custom logic at key moments:
4 //! - `SessionStart`: when a session begins (new/load/fork)
5 //! - `PreToolUse`: before a tool is executed
6 //! - `PostToolUse`: after a tool is executed
7 //!
8 //! Hooks are configured in `settings.toml` under `[hooks]`:
9 //! ```toml
10 //! [hooks]
11 //! session_start = ["echo 'Starting siGit session'"]
12 //! pre_tool_use = ["echo 'About to run {tool_name}'"]
13 //! post_tool_use = ["echo 'Finished {tool_name}: {tool_result_len} chars'"]
14 //! ```
15 //!
16 //! Hooks support substitution for context variables:
17 //! - SessionStart: `{cwd}`, `{session_id}`
18 //! - PreToolUse: `{tool_name}`, `{tool_args_len}`
19 //! - PostToolUse: `{tool_name}`, `{tool_result_len}`
20 //!
21 //! Every value used in `{var}` substitution is quote-escaped for the platform
22 //! shell before being spliced into the command line (`posix_quote` /
23 //! `windows_quote`), so a project directory or MCP-server-supplied tool name
24 //! containing shell metacharacters can never execute as shell syntax. The
25 //! same values are also exported as `SIGIT_HOOK_*` env vars, which hook
26 //! scripts should prefer when they need to consume a value programmatically
27 //! rather than embed it in a `{var}` placeholder.
28 //!
29 //! Hooks run in the session cwd and inherit the environment. Shell hook
30 //! failures are logged but do not interrupt the session. `PreToolUse` /
31 //! `PostToolUse` hooks fire for every call to [`crate::tools::execute_tool`],
32 //! including tool calls made by a `task` subagent — there is no separate
33 //! "top-level only" mode today.
34
35 use serde::{Deserialize, Serialize};
36 use std::path::Path;
37 use std::process::Command;
38
39 /// Hook configuration: lists of shell commands to run at key moments.
40 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
41 pub struct HookSettings {
42 /// Commands to run when a session starts (new/load/fork).
43 #[serde(default)]
44 pub session_start: Vec<String>,
45 /// Commands to run before a tool is executed.
46 #[serde(default)]
47 pub pre_tool_use: Vec<String>,
48 /// Commands to run after a tool is executed.
49 #[serde(default)]
50 pub post_tool_use: Vec<String>,
51 }
52
53 impl HookSettings {
54 /// Whether any hooks are configured.
55 pub fn has_hooks(&self) -> bool {
56 !self.session_start.is_empty()
57 || !self.pre_tool_use.is_empty()
58 || !self.post_tool_use.is_empty()
59 }
60 }
61
62 /// Context passed to hook substitution.
63 pub struct HookContext {
64 pub cwd: Option<String>,
65 pub session_id: Option<String>,
66 pub tool_name: Option<String>,
67 pub tool_args_len: Option<usize>,
68 pub tool_result_len: Option<usize>,
69 }
70
71 impl HookContext {
72 /// Create a new empty context.
73 pub fn new() -> Self {
74 Self {
75 cwd: None,
76 session_id: None,
77 tool_name: None,
78 tool_args_len: None,
79 tool_result_len: None,
80 }
81 }
82
83 /// Substitute `{var}` placeholders in the command string, passing each
84 /// value through `quote` first so it can never be interpreted as shell
85 /// syntax regardless of its contents.
86 fn substitute(&self, cmd: &str, quote: fn(&str) -> String) -> String {
87 let mut result = cmd.to_string();
88 if let Some(cwd) = &self.cwd {
89 result = result.replace("{cwd}", &quote(cwd));
90 }
91 if let Some(session_id) = &self.session_id {
92 result = result.replace("{session_id}", &quote(session_id));
93 }
94 if let Some(tool_name) = &self.tool_name {
95 result = result.replace("{tool_name}", &quote(tool_name));
96 }
97 if let Some(tool_args_len) = self.tool_args_len {
98 // Numeric — always a safe literal, no quoting needed.
99 result = result.replace("{tool_args_len}", &tool_args_len.to_string());
100 }
101 if let Some(tool_result_len) = self.tool_result_len {
102 result = result.replace("{tool_result_len}", &tool_result_len.to_string());
103 }
104 result
105 }
106
107 /// The same context values as `SIGIT_HOOK_*` environment variables, so a
108 /// hook script can read them without any quoting or substitution at all.
109 fn env_vars(&self) -> Vec<(&'static str, String)> {
110 let mut vars = Vec::new();
111 if let Some(cwd) = &self.cwd {
112 vars.push(("SIGIT_HOOK_CWD", cwd.clone()));
113 }
114 if let Some(session_id) = &self.session_id {
115 vars.push(("SIGIT_HOOK_SESSION_ID", session_id.clone()));
116 }
117 if let Some(tool_name) = &self.tool_name {
118 vars.push(("SIGIT_HOOK_TOOL_NAME", tool_name.clone()));
119 }
120 if let Some(tool_args_len) = self.tool_args_len {
121 vars.push(("SIGIT_HOOK_TOOL_ARGS_LEN", tool_args_len.to_string()));
122 }
123 if let Some(tool_result_len) = self.tool_result_len {
124 vars.push(("SIGIT_HOOK_TOOL_RESULT_LEN", tool_result_len.to_string()));
125 }
126 vars
127 }
128 }
129
130 impl Default for HookContext {
131 fn default() -> Self {
132 Self::new()
133 }
134 }
135
136 /// Wrap `value` as a single POSIX shell word, so it is always treated as
137 /// literal data by `sh -c` even if it contains spaces, quotes, or shell
138 /// metacharacters (e.g. a project directory or an MCP-server-supplied tool
139 /// name that happens to contain `$(...)` or backticks).
140 ///
141 /// Only reached from production code on Unix (`run_hook`'s `#[cfg(unix)]`
142 /// arm); always exercised via tests below regardless of host platform.
143 #[cfg_attr(not(unix), allow(dead_code))]
144 fn posix_quote(value: &str) -> String {
145 format!("'{}'", value.replace('\'', r"'\''"))
146 }
147
148 /// Best-effort quoting for `cmd.exe`: wrap in double quotes and escape
149 /// embedded double quotes. `cmd` has no fully safe quoting story (see
150 /// `spawn_shell`'s platform split in `tools.rs`), so this covers common
151 /// cases but callers on Windows should prefer the `SIGIT_HOOK_*` env vars
152 /// (set on every hook invocation, see [`run_hook`]) over `{cwd}`-style
153 /// substitution when values may contain special characters.
154 ///
155 /// Only reached from production code on Windows (`run_hook`'s `#[cfg(windows)]`
156 /// arm); exercised on every platform via `test_windows_quote_neutralizes_embedded_quotes`
157 /// below, since `aws-lc-sys` can't cross-compile to the Windows target from this host.
158 #[cfg_attr(not(windows), allow(dead_code))]
159 fn windows_quote(value: &str) -> String {
160 format!("\"{}\"", value.replace('"', "\"\""))
161 }
162
163 /// Run a single hook command in the given context. Substituted `{var}`
164 /// placeholders are shell-quoted so a value like a directory name or an
165 /// MCP tool name can never break out of its position and run as shell
166 /// syntax. The same values are also exported as `SIGIT_HOOK_*` env vars
167 /// (`SIGIT_HOOK_CWD`, `SIGIT_HOOK_SESSION_ID`, `SIGIT_HOOK_TOOL_NAME`,
168 /// `SIGIT_HOOK_TOOL_ARGS_LEN`, `SIGIT_HOOK_TOOL_RESULT_LEN`) so hook scripts
169 /// can read them without any quoting concerns at all.
170 fn run_hook(cmd: &str, context: &HookContext, cwd: &Path) {
171 #[cfg(unix)]
172 let (shell, flag, substituted) = ("sh", "-c", context.substitute(cmd, posix_quote));
173 #[cfg(windows)]
174 let (shell, flag, substituted) = ("cmd", "/C", context.substitute(cmd, windows_quote));
175
176 log::debug!("Running hook: {substituted}");
177 match Command::new(shell)
178 .arg(flag)
179 .arg(&substituted)
180 .current_dir(cwd)
181 .envs(context.env_vars())
182 .output()
183 {
184 Ok(output) => {
185 if !output.status.success() {
186 let stderr = String::from_utf8_lossy(&output.stderr);
187 log::warn!(
188 "Hook failed with status {:?}: {}",
189 output.status.code(),
190 stderr.trim()
191 );
192 } else {
193 let stdout = String::from_utf8_lossy(&output.stdout);
194 if !stdout.is_empty() {
195 log::debug!("Hook output: {}", stdout.trim());
196 }
197 }
198 }
199 Err(err) => {
200 log::warn!("Failed to run hook: {err}");
201 }
202 }
203 }
204
205 /// Run all session start hooks.
206 pub fn run_session_start_hooks(hooks: &HookSettings, cwd: &Path, session_id: &str) {
207 if hooks.session_start.is_empty() {
208 return;
209 }
210 log::info!("Running {} session_start hooks", hooks.session_start.len());
211 let context = HookContext {
212 cwd: Some(cwd.display().to_string()),
213 session_id: Some(session_id.to_string()),
214 ..HookContext::new()
215 };
216 for cmd in &hooks.session_start {
217 run_hook(cmd, &context, cwd);
218 }
219 }
220
221 /// Run all pre-tool-use hooks.
222 pub fn run_pre_tool_use_hooks(hooks: &HookSettings, tool_name: &str, tool_args: &str, cwd: &Path) {
223 if hooks.pre_tool_use.is_empty() {
224 return;
225 }
226 log::debug!("Running {} pre_tool_use hooks", hooks.pre_tool_use.len());
227 let context = HookContext {
228 cwd: Some(cwd.display().to_string()),
229 tool_name: Some(tool_name.to_string()),
230 tool_args_len: Some(tool_args.len()),
231 ..HookContext::new()
232 };
233 for cmd in &hooks.pre_tool_use {
234 run_hook(cmd, &context, cwd);
235 }
236 }
237
238 /// Run all post-tool-use hooks.
239 pub fn run_post_tool_use_hooks(
240 hooks: &HookSettings,
241 tool_name: &str,
242 tool_result: &str,
243 cwd: &Path,
244 ) {
245 if hooks.post_tool_use.is_empty() {
246 return;
247 }
248 log::debug!("Running {} post_tool_use hooks", hooks.post_tool_use.len());
249 let context = HookContext {
250 cwd: Some(cwd.display().to_string()),
251 tool_name: Some(tool_name.to_string()),
252 tool_result_len: Some(tool_result.len()),
253 ..HookContext::new()
254 };
255 for cmd in &hooks.post_tool_use {
256 run_hook(cmd, &context, cwd);
257 }
258 }
259
260 #[cfg(test)]
261 mod tests {
262 use super::*;
263
264 #[test]
265 fn test_hook_context_substitution() {
266 let context = HookContext {
267 cwd: Some("/home/user/project".to_string()),
268 session_id: Some("session-123".to_string()),
269 tool_name: Some("read_file".to_string()),
270 tool_args_len: Some(42),
271 tool_result_len: Some(1000),
272 };
273 assert_eq!(
274 context.substitute("cd {cwd} && echo {session_id}", posix_quote),
275 "cd '/home/user/project' && echo 'session-123'"
276 );
277 assert_eq!(
278 context.substitute(
279 "Tool: {tool_name}, args={tool_args_len}, result={tool_result_len}",
280 posix_quote
281 ),
282 "Tool: 'read_file', args=42, result=1000"
283 );
284 }
285
286 #[test]
287 fn test_posix_quote_neutralizes_shell_metacharacters() {
288 // A directory or MCP tool name containing shell syntax must come
289 // through as inert literal text, not execute.
290 let context = HookContext {
291 cwd: Some("/tmp/$(rm -rf ~); echo pwned".to_string()),
292 ..HookContext::new()
293 };
294 let substituted = context.substitute("echo {cwd}", posix_quote);
295 assert_eq!(
296 substituted, "echo '/tmp/$(rm -rf ~); echo pwned'",
297 "the payload must stay inside single quotes, not break out of them"
298 );
299
300 // Embedded single quotes are the classic escape attempt; confirm the
301 // standard '\'' technique closes and reopens the quoted string
302 // safely instead of terminating it early.
303 let context = HookContext {
304 tool_name: Some("evil'; rm -rf ~; echo '".to_string()),
305 ..HookContext::new()
306 };
307 let substituted = context.substitute("echo {tool_name}", posix_quote);
308 assert_eq!(substituted, "echo 'evil'\\''; rm -rf ~; echo '\\'''");
309 }
310
311 #[test]
312 fn test_windows_quote_neutralizes_embedded_quotes() {
313 // aws-lc-sys can't cross-compile to the Windows target from this
314 // host, so this is exercised as a plain unit test rather than a
315 // Windows-target integration run.
316 assert_eq!(
317 windows_quote(r#"evil" & del /f /q *"#),
318 r#""evil"" & del /f /q *""#
319 );
320 assert_eq!(
321 windows_quote("/home/user/project"),
322 "\"/home/user/project\""
323 );
324 }
325
326 #[test]
327 fn test_pre_tool_use_hook_does_not_execute_injected_shell_syntax() {
328 // End-to-end: run a real `sh -c` (not just the string-substitution
329 // layer) with a tool_name crafted to look like a shell command
330 // injection, and confirm the injected `touch` never actually runs.
331 let dir = std::env::temp_dir().join(format!(
332 "sigit-hooks-test-{:?}-{}",
333 std::thread::current().id(),
334 std::process::id()
335 ));
336 std::fs::create_dir_all(&dir).unwrap();
337 let marker = dir.join("pwned");
338 assert!(!marker.exists());
339
340 let malicious_tool_name = format!("read_file'; touch {}; echo '", marker.display());
341 let hooks = HookSettings {
342 pre_tool_use: vec!["echo hook-ran {tool_name}".to_string()],
343 ..HookSettings::default()
344 };
345 run_pre_tool_use_hooks(&hooks, &malicious_tool_name, "{}", &dir);
346
347 assert!(
348 !marker.exists(),
349 "injected `touch` executed — {{tool_name}} substitution is not shell-safe"
350 );
351 std::fs::remove_dir_all(&dir).ok();
352 }
353
354 #[test]
355 fn test_hook_settings_has_hooks() {
356 let empty = HookSettings::default();
357 assert!(!empty.has_hooks());
358
359 let with_hooks = HookSettings {
360 session_start: vec!["echo start".to_string()],
361 ..HookSettings::default()
362 };
363 assert!(with_hooks.has_hooks());
364 }
365 }