From a8fe135b3411dee0aa2e09f4cf0c54c9b4f418a4 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sat, 31 Oct 2026 00:10:44 +0100 Subject: [PATCH] claudecode: single-quote hook binary path for shell safety ResolveHookCommand bakes `shellSafeHookBinary(bin) + " hook"` into ~/.claude/settings.json and Claude Code runs it through its shell. The path was only backslash-normalized, so a space would word-split the command and a $var/backtick would expand at hook-fire time. Quote the path (POSIX single-quotes, with \'\' escaping) whenever it contains a character a shell would interpret; a normal install path is emitted unchanged, and the PATH-fallback bare word needs no quoting. Two regression cases added. Co-authored-by: security audit --- internal/agents/claudecode/hooks.go | 16 +++++++++++++++- internal/agents/claudecode/hooks_test.go | 2 ++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/internal/agents/claudecode/hooks.go b/internal/agents/claudecode/hooks.go index 5fb6b9aee..8956e211b 100644 --- a/internal/agents/claudecode/hooks.go +++ b/internal/agents/claudecode/hooks.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "regexp" "slices" "strings" @@ -142,8 +143,21 @@ func ResolveHookCommand(w io.Writer) string { // than Windows-guarded — any backslash in an unquoted shell command is // a bug regardless of OS, and a real Unix binary path never contains // one as a separator. +// +// The normalized path is then single-quoted when it contains a character +// a shell would interpret (space, tab, or any of `$"'&|;()` etc.). A +// normal install path — /opt/homebrew/bin/gortex — is emitted unchanged; +// a space would otherwise word-split the command and a `$var`/backtick +// would expand at hook-fire time. A bare word from the PATH fallback is +// already a simple token and needs no quoting. +var shellSafePathToken = regexp.MustCompile(`^[A-Za-z0-9_./:+-]+$`) + func shellSafeHookBinary(path string) string { - return strings.ReplaceAll(path, "\\", "/") + path = strings.ReplaceAll(path, "\\", "/") + if !shellSafePathToken.MatchString(path) { + return "'" + strings.ReplaceAll(path, "'", `'\''`) + "'" + } + return path } // HookCommandPathIsEphemeral reports whether cmd's binary path lives diff --git a/internal/agents/claudecode/hooks_test.go b/internal/agents/claudecode/hooks_test.go index 514000eea..56ec45add 100644 --- a/internal/agents/claudecode/hooks_test.go +++ b/internal/agents/claudecode/hooks_test.go @@ -221,6 +221,8 @@ func TestShellSafeHookBinary(t *testing.T) { {"unixAbsolute", "/opt/homebrew/bin/gortex", "/opt/homebrew/bin/gortex"}, {"bareName", "gortex", "gortex"}, {"forwardSlashesUnchanged", "C:/Users/me/gortex.exe", "C:/Users/me/gortex.exe"}, + {"spaceInPath", "/opt/homebrew/bin/gortex beta/gortex.exe", "'/opt/homebrew/bin/gortex beta/gortex.exe'"}, + {"dollarInPath", "/opt/$(whoami)/gortex", "'/opt/$(whoami)/gortex'"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) {