18
//! - PreToolUse: `{tool_name}`, `{tool_args_len}`
19
//! - PostToolUse: `{tool_name}`, `{tool_result_len}`
20
//!
21
-//! Hooks run in the session cwd and inherit the environment. Shell hook failures
22
-//! are logged but do not interrupt the session.
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;
80
}
81
}
82
72
- /// Substitute variables in the command string.
73
- fn substitute(&self, cmd: &str) -> String {
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 {
76
- result = result.replace("{cwd}", cwd);
89
+ result = result.replace("{cwd}", "e(cwd));
90
}
91
if let Some(session_id) = &self.session_id {
79
- result = result.replace("{session_id}", session_id);
92
+ result = result.replace("{session_id}", "e(session_id));
93
}
94
if let Some(tool_name) = &self.tool_name {
82
- result = result.replace("{tool_name}", tool_name);
95
+ result = result.replace("{tool_name}", "e(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 {
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 {
133
}
134
}
135
100
-/// Run a single hook command in the given context.
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) {
102
- let substituted = context.substitute(cmd);
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}");
104
- match Command::new("sh")
105
- .arg("-c")
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) => {
271
tool_result_len: Some(1000),
272
};
273
assert_eq!(
200
- context.substitute("cd {cwd} && echo {session_id}"),
201
- "cd /home/user/project && echo session-123"
274
+ context.substitute("cd {cwd} && echo {session_id}", posix_quote),
275
+ "cd '/home/user/project' && echo 'session-123'"
276
);
277
assert_eq!(
204
- context.substitute("Tool: {tool_name}, args={tool_args_len}, result={tool_result_len}"),
205
- "Tool: read_file, args=42, result=1000"
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]