mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Fix cross-platform plugin Hook execution contracts / 修复跨平台插件 Hook 执行契约 (#6911)
* Fix plugin hook execution contracts Problem: Windows plugin hooks with compound commands or nested quoting could still fail after the narrow quoted-batch compatibility fix, while explicit empty args could lose exec-form semantics across JSON round trips. Root cause: Hook launch behavior was inferred from argument length and platform shell heuristics instead of preserving the manifest's exec-form or shell-form contract. CMD and PowerShell command strings also passed through incompatible quoting layers. Fix: Preserve args field presence and shell selection through plugin parsing and loading, add explicit legacy/exec/shell execution modes, transport raw CMD and encoded PowerShell scripts without token re-rendering, retain the existing simple batch compatibility path, and document the cross-platform contract. Verification: Focused Hook and plugin package tests, race and shuffled tests, fuzzing, root and Desktop full suites, go vet, and Windows/Linux cross-compilation. * Fix Windows hook output regressions Problem: The new compound CMD regression expected cmd.exe to strip quotes from %1, leaving the Windows job red. PowerShell hook scripts also preserved UTF-16LE input transport without forcing captured output to UTF-8. Root cause: The test confused %1 with the dequoting %~1 form, and the Hook-specific EncodedCommand path did not reuse the shell runtime's existing PowerShell output-encoding prologue. Fix: Align the CMD assertion with native batch semantics, share the PowerShell UTF-8 script wrapper across shell and Hook launchers, and exercise Chinese plus emoji output in the Windows runtime test. Verification: Focused Hook/plugin/sandbox tests and vet; race/shuffle count 3; root and Desktop full suites; full vet; Windows amd64 test cross-compilation; PowerShell encoding fuzzing.
This commit is contained in:
1 parent
4b2ef3dabb
commit
72a1e32b49
14 files changed
+1160
-58
No files matched your search
+29
-4
@@ -222,7 +222,13 @@ Reasonix plugins can declare `reasonix-plugin.json` at the plugin root:
|
||||
"SessionStart": [
|
||||
{
|
||||
"command": "hooks/session-start",
|
||||
"args": [],
|
||||
"description": "Load startup context"
|
||||
},
|
||||
{
|
||||
"command": "printf 'ready' && ./hooks/audit",
|
||||
"shell": "bash",
|
||||
"description": "Run a compound shell script"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -237,6 +243,19 @@ Reasonix plugins can declare `reasonix-plugin.json` at the plugin root:
|
||||
Relative paths are resolved inside the plugin root. Reasonix does not run
|
||||
third-party install scripts during plugin installation.
|
||||
|
||||
Plugin hook execution is explicit:
|
||||
|
||||
- When `args` is present, including `"args": []`, the hook uses **exec form**.
|
||||
`command` is the executable and every argument is passed literally, without
|
||||
shell parsing or interpolation.
|
||||
- When `args` is absent and `shell` is present, the hook uses **shell form**.
|
||||
The complete `command` is handed unchanged to `bash`, `powershell`/`pwsh`,
|
||||
`cmd` (Windows only), or `auto`. On Windows, `auto` prefers Git Bash and
|
||||
falls back to PowerShell.
|
||||
- Existing native hooks that declare neither field keep the historical
|
||||
Reasonix shell-command behavior. `shellCommand: true` remains supported as
|
||||
the legacy spelling of shell form.
|
||||
|
||||
## Codex & Claude Compatibility
|
||||
|
||||
Reasonix also reads Codex plugin manifests at `.codex-plugin/plugin.json` and
|
||||
@@ -291,8 +310,12 @@ such as Superpowers and Claude-style skill packs, Reasonix maps:
|
||||
- A plugin-root `CLAUDE.md` file to a built-in `SessionStart` context hook. The
|
||||
file is read directly by Reasonix, without spawning a shell command.
|
||||
- `.claude/settings.json` and `hooks/hooks.json` command hooks to Reasonix hook
|
||||
events when the event names match. `matcher`, `args`, `async`, `env`, and
|
||||
timeout are preserved. `matcher` and the `tool_name` a hook script sees are
|
||||
events when the event names match. `matcher`, `args`, `shell`, `async`,
|
||||
`env`, and timeout are preserved. Claude's execution contract is retained:
|
||||
an `args` field (even an empty array) selects exec form and preserves every
|
||||
argument literally; omitting `args` selects shell form and passes the raw
|
||||
command to the declared Bash or PowerShell interpreter. `matcher` and the
|
||||
`tool_name` a hook script sees are
|
||||
translated between Reasonix's own tool names and Claude's (`bash` ↔
|
||||
`Bash`, `write_file` ↔ `Write`, ...), so a matcher like `"Bash"` fires
|
||||
correctly; every Reasonix subagent-spawning tool (`task`, `read_only_task`,
|
||||
@@ -332,8 +355,10 @@ such as Superpowers and Claude-style skill packs, Reasonix maps:
|
||||
expands `${CLAUDE_PLUGIN_ROOT}` and `${REASONIX_PLUGIN_ROOT}` (plus their
|
||||
unbraced `$NAME` and Windows `%NAME%` spellings), so plugin-relative paths
|
||||
do not depend on the target shell's environment-variable syntax. On Windows,
|
||||
a bare `sh -c` or `bash -c` hook is routed through a discovered Git for
|
||||
Windows Bash even when it is not on `cmd.exe`'s `PATH`; an explicit
|
||||
shell-form hooks without an explicit shell use the same Git Bash-first,
|
||||
PowerShell-fallback selection as Reasonix's shell tool. Explicit Bash hooks
|
||||
and legacy bare `sh -c`/`bash -c` hooks are routed through a discovered Git
|
||||
for Windows Bash even when it is not on `cmd.exe`'s `PATH`; an explicit
|
||||
interpreter path remains untouched. If no usable Bash is installed, the hook
|
||||
reports a clear prerequisite error instead of the localized `sh is not
|
||||
recognized` output. Captured legacy-code-page output is normalized to UTF-8
|
||||
|
||||
@@ -202,7 +202,13 @@ Reasonix 原生插件在根目录声明 `reasonix-plugin.json`:
|
||||
"SessionStart": [
|
||||
{
|
||||
"command": "hooks/session-start",
|
||||
"args": [],
|
||||
"description": "Load startup context"
|
||||
},
|
||||
{
|
||||
"command": "printf 'ready' && ./hooks/audit",
|
||||
"shell": "bash",
|
||||
"description": "Run a compound shell script"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -216,6 +222,16 @@ Reasonix 原生插件在根目录声明 `reasonix-plugin.json`:
|
||||
|
||||
相对路径都按插件根目录解析。Reasonix 安装插件时不会执行第三方安装脚本。
|
||||
|
||||
插件 Hook 的执行形态是显式的:
|
||||
|
||||
- 只要出现 `args`(包括 `"args": []`),就使用 **exec form**。`command`
|
||||
是可执行文件,每个参数都会按原值直接传递,不经过 Shell 解析或变量展开。
|
||||
- 未提供 `args` 且提供 `shell` 时,使用 **shell form**。完整 `command`
|
||||
会原样交给 `bash`、`powershell`/`pwsh`、`cmd`(仅 Windows)或 `auto`。
|
||||
Windows 上 `auto` 优先选择 Git Bash,找不到时回退 PowerShell。
|
||||
- 既未声明 `args` 也未声明 `shell` 的已有原生 Hook 继续使用 Reasonix
|
||||
历史 Shell 命令行为;`shellCommand: true` 仍作为 shell form 的旧写法兼容。
|
||||
|
||||
## Codex 与 Claude 兼容
|
||||
|
||||
Reasonix 也会读取 `.codex-plugin/plugin.json` 和 `.claude-plugin/plugin.json`。
|
||||
@@ -257,7 +273,10 @@ Reasonix 的对应实现,并不代表导入 Hook 的每一种运行时决策
|
||||
- 插件根目录的 `CLAUDE.md` 会映射为内置的 `SessionStart` 上下文 hook。
|
||||
Reasonix 会直接读取该文件,不通过 shell 命令。
|
||||
- `.claude/settings.json` 和 `hooks/hooks.json` 里的 command hooks 会按同名事件映射。
|
||||
`matcher`、`args`、`async`、`env` 和 timeout 均会保留。`matcher` 以及 Hook 脚本看到的
|
||||
`matcher`、`args`、`shell`、`async`、`env` 和 timeout 均会保留。Claude 的执行契约
|
||||
也会完整保留:只要出现 `args`(即使是空数组)就按 exec form 执行,并逐项原样传参;
|
||||
省略 `args` 才按 shell form 执行,将原始命令交给声明的 Bash 或 PowerShell。
|
||||
`matcher` 以及 Hook 脚本看到的
|
||||
`tool_name` 会在 Reasonix 与 Claude 的工具名之间互译(`bash` ↔ `Bash`、
|
||||
`write_file` ↔ `Write` 等),因此 `"Bash"` 这类 matcher 能正确触发;Reasonix 里所有会
|
||||
启动子代理的工具(`task`、`read_only_task`、`parallel_tasks`,以及专用的
|
||||
@@ -286,8 +305,10 @@ Reasonix 的对应实现,并不代表导入 Hook 的每一种运行时决策
|
||||
stdin 使用 Claude 兼容的 snake_case 载荷(包括 `hook_event_name`)。宿主会在启动
|
||||
进程前展开 `${CLAUDE_PLUGIN_ROOT}` 和 `${REASONIX_PLUGIN_ROOT}`,也兼容不带花括号的
|
||||
`$NAME` 与 Windows `%NAME%` 写法,因此插件相对路径不再依赖目标 shell 的环境变量
|
||||
语法。Windows 上显式声明的裸 `sh -c`/`bash -c` hook 会复用 Git for Windows Bash
|
||||
探测,即使 Bash 不在 `cmd.exe` 的 `PATH` 中也能执行;带目录的显式解释器路径保持
|
||||
语法。Windows 上未显式指定 Shell 的 shell-form Hook 会和 Reasonix Shell 工具一样,
|
||||
优先选择 Git Bash,找不到时回退 PowerShell。显式 Bash Hook 以及旧式裸
|
||||
`sh -c`/`bash -c` Hook 会复用 Git for Windows Bash 探测,即使 Bash 不在
|
||||
`cmd.exe` 的 `PATH` 中也能执行;带目录的显式解释器路径保持
|
||||
不变。如果机器确实没有可用 Bash,hook 会返回清晰的依赖提示,而不是本地化的
|
||||
“无法识别 sh”乱码。旧代码页输出也会在进入界面前转换为 UTF-8。`PreToolUse` 和
|
||||
`UserPromptSubmit` hook 仍可
|
||||
|
||||
@@ -14,3 +14,7 @@ func windowsBatchCommand(context.Context, string) (*exec.Cmd, bool) {
|
||||
func windowsBatchArgvCommand(context.Context, string, []string) (*exec.Cmd, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func windowsCmdShellCommand(context.Context, string) (*exec.Cmd, bool) {
|
||||
return nil, false
|
||||
}
|
||||
@@ -18,6 +18,10 @@ func windowsBatchArgvCommand(ctx context.Context, command string, args []string)
|
||||
return newWindowsBatchCommand(ctx, commandLine, ok)
|
||||
}
|
||||
|
||||
func windowsCmdShellCommand(ctx context.Context, command string) (*exec.Cmd, bool) {
|
||||
return newWindowsBatchCommand(ctx, windowsCmdCommandLine(command), true)
|
||||
}
|
||||
|
||||
func newWindowsBatchCommand(ctx context.Context, commandLine string, ok bool) (*exec.Cmd, bool) {
|
||||
if !ok {
|
||||
return nil, false
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf16"
|
||||
"unicode/utf8"
|
||||
|
||||
"reasonix/internal/sandbox"
|
||||
)
|
||||
|
||||
func TestHookExecHelperProcess(t *testing.T) {
|
||||
if os.Getenv("REASONIX_HOOK_EXEC_HELPER") != "1" {
|
||||
return
|
||||
}
|
||||
for i, arg := range os.Args {
|
||||
if arg != "--" {
|
||||
continue
|
||||
}
|
||||
if err := json.NewEncoder(os.Stdout).Encode(os.Args[i+1:]); err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
os.Exit(3)
|
||||
}
|
||||
|
||||
func TestExecFormPreservesLiteralArgumentsEndToEnd(t *testing.T) {
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{
|
||||
"",
|
||||
" leading and trailing ",
|
||||
"$HOME",
|
||||
"%PATH%",
|
||||
"!DELAYED!",
|
||||
`a && b | c > out`,
|
||||
`double"quote`,
|
||||
"single'quote",
|
||||
`C:\Program Files\Reasonix\hook.cmd`,
|
||||
"第一行\n第二行",
|
||||
"emoji-🧪",
|
||||
}
|
||||
args := append([]string{"-test.run=^TestHookExecHelperProcess$", "--"}, want...)
|
||||
result := DefaultSpawner(context.Background(), SpawnInput{
|
||||
Command: executable,
|
||||
Args: args,
|
||||
Mode: ExecutionExec,
|
||||
Env: map[string]string{"REASONIX_HOOK_EXEC_HELPER": "1"},
|
||||
Timeout: realSpawnTimeout,
|
||||
})
|
||||
if result.ExitCode != 0 || result.SpawnErr != nil {
|
||||
t.Fatalf("exec-form helper failed: %+v", result)
|
||||
}
|
||||
var got []string
|
||||
if err := json.Unmarshal([]byte(result.Stdout), &got); err != nil {
|
||||
t.Fatalf("decode helper output %q: %v", result.Stdout, err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("literal argv changed:\n got %#v\nwant %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnCommandExecutionContractMatrix(t *testing.T) {
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
literalArgs := []string{"", "$VALUE", "a && b", `nested"quote`}
|
||||
cmd, err := spawnCommand(context.Background(), executable, ExecutionExec, "bash", literalArgs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(cmd.Args[1:], literalArgs) {
|
||||
t.Fatalf("exec argv = %#v, want %#v", cmd.Args[1:], literalArgs)
|
||||
}
|
||||
|
||||
if _, err := spawnCommand(context.Background(), "ignored", ExecutionMode("future"), "", nil); err == nil ||
|
||||
!strings.Contains(err.Error(), "unsupported hook execution mode") {
|
||||
t.Fatalf("unknown execution mode error = %v", err)
|
||||
}
|
||||
if _, err := spawnCommand(context.Background(), "ignored", ExecutionShell, "fish", nil); err == nil ||
|
||||
!strings.Contains(err.Error(), "unsupported hook shell") {
|
||||
t.Fatalf("unknown shell error = %v", err)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
if _, err := spawnCommand(context.Background(), "echo ok", ExecutionShell, "cmd", nil); err == nil ||
|
||||
!strings.Contains(err.Error(), "only available on Windows") {
|
||||
t.Fatalf("non-Windows cmd error = %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellSelectionBuildsExactInterpreterArgv(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows interpreter selection has native runtime tests")
|
||||
}
|
||||
script := `printf '%s' "a && b"`
|
||||
tests := []struct {
|
||||
name string
|
||||
preferred string
|
||||
wantPath string
|
||||
}{
|
||||
{name: "default", preferred: "", wantPath: "sh"},
|
||||
{name: "auto", preferred: "auto", wantPath: "sh"},
|
||||
{name: "bash", preferred: "bash", wantPath: "bash"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd, err := spawnShellCommand(context.Background(), script, tt.preferred)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := cmd.Args; len(got) != 3 || got[0] != tt.wantPath || got[1] != "-c" || got[2] != script {
|
||||
t.Fatalf("shell argv = %#v, want [%q -c <exact script>]", got, tt.wantPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath("pwsh"); err != nil {
|
||||
if _, err := spawnShellCommand(context.Background(), script, "pwsh"); err == nil ||
|
||||
!strings.Contains(err.Error(), "no usable PowerShell") {
|
||||
t.Fatalf("missing pwsh error = %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawShellCommandPreservesScriptForResolvedShells(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses POSIX executable paths for deterministic argv inspection")
|
||||
}
|
||||
script := `printf '%s' '"nested" && literal'`
|
||||
bashCmd, err := rawShellCommand(context.Background(), sandbox.Shell{Kind: sandbox.ShellBash, Path: "/bin/sh"}, script)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := bashCmd.Args, []string{"/bin/sh", "-c", script}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("raw Bash argv = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
powerShellScript := `$value = "a && 'b'"; Write-Output $value`
|
||||
powerShellCmd, err := rawShellCommand(context.Background(), sandbox.Shell{
|
||||
Kind: sandbox.ShellPowerShell,
|
||||
Path: "/bin/sh",
|
||||
}, powerShellScript)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoded, err := decodePowerShellCommandForTest(powerShellCmd.Args[4])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := sandbox.PowerShellUTF8Script(powerShellScript); decoded != want {
|
||||
t.Fatalf("PowerShell script = %q, want %q", decoded, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvedHookShellPathAcceptsExecutableAndRejectsMissing(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses POSIX executable paths")
|
||||
}
|
||||
got, err := resolvedHookShellPath(sandbox.Shell{Kind: sandbox.ShellBash, Path: "/bin/sh"})
|
||||
if err != nil || got != "/bin/sh" {
|
||||
t.Fatalf("resolved /bin/sh = %q, %v", got, err)
|
||||
}
|
||||
if _, err := resolvedHookShellPath(sandbox.Shell{Kind: sandbox.ShellBash, Path: "/definitely/missing/reasonix-hook-shell"}); err == nil {
|
||||
t.Fatal("missing absolute shell unexpectedly resolved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashShellFormComplexCommandMatrix(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows shell-form coverage lives in windows_batch_test.go")
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
command string
|
||||
stdin string
|
||||
env map[string]string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "operators inside literal quotes",
|
||||
command: `printf '%s' 'a && b | c > out'`,
|
||||
want: `a && b | c > out`,
|
||||
},
|
||||
{
|
||||
name: "nested quotes and variable expansion",
|
||||
command: `value='single "double"'; printf '%s:%s' "$HOOK_VALUE" "$value"`,
|
||||
env: map[string]string{"HOOK_VALUE": "expanded"},
|
||||
want: `expanded:single "double"`,
|
||||
},
|
||||
{
|
||||
name: "pipeline",
|
||||
command: `printf 'left\nright\n' | tail -n 1`,
|
||||
want: "right",
|
||||
},
|
||||
{
|
||||
name: "subshell and chaining",
|
||||
command: `(printf one; printf two) && printf three`,
|
||||
want: "onetwothree",
|
||||
},
|
||||
{
|
||||
name: "command substitution",
|
||||
command: `printf '<%s>' "$(printf nested)"`,
|
||||
want: "<nested>",
|
||||
},
|
||||
{
|
||||
name: "stdin",
|
||||
command: `IFS= read -r value; printf '%s' "$value"`,
|
||||
stdin: `payload "quoted" && literal`,
|
||||
want: `payload "quoted" && literal`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := DefaultSpawner(context.Background(), SpawnInput{
|
||||
Command: tt.command,
|
||||
Mode: ExecutionShell,
|
||||
Shell: "bash",
|
||||
Env: tt.env,
|
||||
Stdin: tt.stdin,
|
||||
Timeout: realSpawnTimeout,
|
||||
})
|
||||
if result.ExitCode != 0 || result.SpawnErr != nil || result.Stdout != tt.want {
|
||||
t.Fatalf("shell-form result = %+v, want stdout %q", result, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellFormHonorsExitStderrAndTimeout(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses Bash")
|
||||
}
|
||||
failed := DefaultSpawner(context.Background(), SpawnInput{
|
||||
Command: `printf 'problem' >&2; exit 7`,
|
||||
Mode: ExecutionShell,
|
||||
Shell: "bash",
|
||||
Timeout: realSpawnTimeout,
|
||||
})
|
||||
if failed.ExitCode != 7 || failed.Stderr != "problem" || failed.SpawnErr != nil {
|
||||
t.Fatalf("shell failure result = %+v", failed)
|
||||
}
|
||||
|
||||
timedOut := DefaultSpawner(context.Background(), SpawnInput{
|
||||
Command: "sleep 5",
|
||||
Mode: ExecutionShell,
|
||||
Shell: "bash",
|
||||
Timeout: 50 * time.Millisecond,
|
||||
})
|
||||
if !timedOut.TimedOut || timedOut.ExitCode != -1 {
|
||||
t.Fatalf("shell timeout result = %+v", timedOut)
|
||||
}
|
||||
}
|
||||
|
||||
func decodePowerShellCommandForTest(encoded string) (string, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(raw)%2 != 0 {
|
||||
return "", &oddUTF16LengthError{length: len(raw)}
|
||||
}
|
||||
units := make([]uint16, len(raw)/2)
|
||||
for i := range units {
|
||||
units[i] = uint16(raw[i*2]) | uint16(raw[i*2+1])<<8
|
||||
}
|
||||
return string(utf16.Decode(units)), nil
|
||||
}
|
||||
|
||||
type oddUTF16LengthError struct {
|
||||
length int
|
||||
}
|
||||
|
||||
func (e *oddUTF16LengthError) Error() string {
|
||||
return fmt.Sprintf("odd UTF-16LE byte length %d", e.length)
|
||||
}
|
||||
|
||||
func FuzzPowerShellCommandEncodingRoundTrip(f *testing.F) {
|
||||
for _, seed := range []string{
|
||||
"",
|
||||
`Write-Output "a && 'b'"`,
|
||||
`$value = "C:\Program Files\Reasonix"; $value`,
|
||||
"第一行\n第二行",
|
||||
"Write-Output '🧪'",
|
||||
"`$literal; $(Write-Output nested)",
|
||||
} {
|
||||
f.Add(seed)
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, script string) {
|
||||
if !utf8.ValidString(script) {
|
||||
t.Skip()
|
||||
}
|
||||
cmd := powerShellCommand(context.Background(), "powershell", script)
|
||||
got, err := decodePowerShellCommandForTest(cmd.Args[4])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := sandbox.PowerShellUTF8Script(script)
|
||||
if got != want {
|
||||
t.Fatalf("PowerShell script changed:\n got %q\nwant %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
+169
-29
@@ -14,6 +14,7 @@ package hook
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -25,11 +26,13 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf16"
|
||||
|
||||
"reasonix/internal/config"
|
||||
fileencoding "reasonix/internal/fileutil/encoding"
|
||||
"reasonix/internal/pluginpkg"
|
||||
"reasonix/internal/proc"
|
||||
"reasonix/internal/sandbox"
|
||||
"reasonix/internal/secrets"
|
||||
)
|
||||
|
||||
@@ -107,17 +110,33 @@ const (
|
||||
ScopeGlobal Scope = "global"
|
||||
)
|
||||
|
||||
// ExecutionMode is the contract between a hook manifest and its process
|
||||
// launcher. The zero value is the legacy Reasonix settings behavior, where a
|
||||
// command string is interpreted by the platform shell after compatibility
|
||||
// repairs. Plugin manifests can opt into an unambiguous exec or shell form.
|
||||
type ExecutionMode string
|
||||
|
||||
const (
|
||||
ExecutionLegacy ExecutionMode = ""
|
||||
ExecutionExec ExecutionMode = "exec"
|
||||
ExecutionShell ExecutionMode = "shell"
|
||||
)
|
||||
|
||||
// HookConfig is one hook as written in settings.json.
|
||||
type HookConfig struct {
|
||||
// Match is an anchored regex selecting tools (Pre/PostToolUse and
|
||||
// PermissionRequest only); "" or "*" = every tool. Anchored: "file" won't
|
||||
// match "read_file" — use ".*file".
|
||||
Match string `json:"match,omitempty"`
|
||||
// Command is the shell command to run (spawned through the platform shell).
|
||||
// Command is the executable, shell script, or legacy shell command to run,
|
||||
// according to ExecutionMode.
|
||||
Command string `json:"command"`
|
||||
// Argv bypasses the shell and is used by imported Claude hooks whose
|
||||
// command and args are separate manifest fields.
|
||||
// Argv is the literal argument vector for exec-form plugin hooks.
|
||||
Argv []string `json:"-"`
|
||||
// ExecutionMode and Shell are internal plugin-package metadata. Native
|
||||
// Reasonix settings retain their legacy shell-command behavior.
|
||||
ExecutionMode ExecutionMode `json:"-"`
|
||||
Shell string `json:"-"`
|
||||
// ContextFile is an internal plugin-package helper: when set, the hook reads
|
||||
// this file and treats it as stdout instead of spawning a shell command.
|
||||
ContextFile string `json:"contextFile,omitempty"`
|
||||
@@ -296,13 +315,24 @@ func appendPluginHooks(out *[]ResolvedHook, reasonixHomeDir, projectRoot string)
|
||||
continue
|
||||
}
|
||||
for _, h := range pkg.Manifest.Hooks[eventName] {
|
||||
mode := ExecutionLegacy
|
||||
switch {
|
||||
case h.ArgsSet:
|
||||
mode = ExecutionExec
|
||||
case h.ShellCommand:
|
||||
mode = ExecutionShell
|
||||
}
|
||||
command := expandPluginRoot(h.Command, pkg.Root)
|
||||
if command != "" && !h.ShellCommand && !filepath.IsAbs(command) {
|
||||
resolveFromPluginRoot := mode != ExecutionShell &&
|
||||
!(mode == ExecutionExec && h.PayloadFormat == "claude")
|
||||
if command != "" && resolveFromPluginRoot && !filepath.IsAbs(command) {
|
||||
command = filepath.Join(pkg.Root, filepath.FromSlash(command))
|
||||
}
|
||||
command = NormalizeCommand(command)
|
||||
if mode == ExecutionLegacy {
|
||||
command = NormalizeCommand(command)
|
||||
}
|
||||
var argv []string
|
||||
if len(h.Args) > 0 {
|
||||
if h.ArgsSet {
|
||||
argv = make([]string, 0, len(h.Args))
|
||||
}
|
||||
for _, arg := range h.Args {
|
||||
@@ -346,6 +376,8 @@ func appendPluginHooks(out *[]ResolvedHook, reasonixHomeDir, projectRoot string)
|
||||
Match: h.Match,
|
||||
Command: command,
|
||||
Argv: argv,
|
||||
ExecutionMode: mode,
|
||||
Shell: h.Shell,
|
||||
ContextFile: contextFile,
|
||||
Description: h.Description,
|
||||
Timeout: h.Timeout,
|
||||
@@ -1012,6 +1044,8 @@ func claudeJSONAllow(event Event, stdout string) bool {
|
||||
type SpawnInput struct {
|
||||
Command string
|
||||
Args []string
|
||||
Mode ExecutionMode
|
||||
Shell string
|
||||
Cwd string
|
||||
Env map[string]string
|
||||
Stdin string
|
||||
@@ -1052,7 +1086,16 @@ func Run(ctx context.Context, payload Payload, hooks []ResolvedHook, spawner Spa
|
||||
}
|
||||
timeout := h.timeout()
|
||||
stdin := marshalPayload(payload, h.PayloadFormat)
|
||||
input := SpawnInput{Command: h.Command, Args: h.Argv, Cwd: cwd, Env: h.Env, Stdin: stdin, Timeout: timeout}
|
||||
input := SpawnInput{
|
||||
Command: h.Command,
|
||||
Args: h.Argv,
|
||||
Mode: h.ExecutionMode,
|
||||
Shell: h.Shell,
|
||||
Cwd: cwd,
|
||||
Env: h.Env,
|
||||
Stdin: stdin,
|
||||
Timeout: timeout,
|
||||
}
|
||||
if h.Async {
|
||||
asyncCtx := context.WithoutCancel(ctx)
|
||||
go runResolvedHook(asyncCtx, h, input, spawner)
|
||||
@@ -1175,14 +1218,14 @@ func stderrFor(r SpawnResult, timeout time.Duration) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// DefaultSpawner runs the command through the platform shell with the payload on
|
||||
// stdin, capping captured output and honoring both the per-hook timeout and the
|
||||
// parent context's cancellation.
|
||||
// DefaultSpawner executes the hook according to its explicit execution
|
||||
// contract, with the payload on stdin, capped output, and both per-hook timeout
|
||||
// and parent-context cancellation.
|
||||
func DefaultSpawner(ctx context.Context, in SpawnInput) SpawnResult {
|
||||
cctx, cancel := context.WithTimeout(ctx, in.Timeout)
|
||||
defer cancel()
|
||||
|
||||
cmd, spawnErr := spawnCommand(cctx, in.Command, in.Args)
|
||||
cmd, spawnErr := spawnCommand(cctx, in.Command, in.Mode, in.Shell, in.Args)
|
||||
if spawnErr != nil {
|
||||
return SpawnResult{ExitCode: -1, SpawnErr: spawnErr}
|
||||
}
|
||||
@@ -1233,10 +1276,39 @@ func DefaultSpawner(ctx context.Context, in SpawnInput) SpawnResult {
|
||||
return res
|
||||
}
|
||||
|
||||
// spawnCommand picks the execution vehicle for a hook command. Commands run
|
||||
// through the shell by default — that is the documented contract, and scripts
|
||||
// may rely on shell expansion ($VAR, backticks). Direct exec (no shell) is
|
||||
// used only where it is strictly better:
|
||||
// spawnCommand picks the execution vehicle from the manifest contract.
|
||||
// Explicit exec-form hooks pass their argv directly to the executable;
|
||||
// explicit shell-form hooks pass the raw command to the selected interpreter.
|
||||
// Legacy settings retain Reasonix's historical shell behavior and repairs.
|
||||
func spawnCommand(ctx context.Context, command string, mode ExecutionMode, shell string, args []string) (*exec.Cmd, error) {
|
||||
switch mode {
|
||||
case ExecutionExec:
|
||||
return spawnExecCommand(ctx, command, args)
|
||||
case ExecutionShell:
|
||||
return spawnShellCommand(ctx, command, shell)
|
||||
case ExecutionLegacy:
|
||||
return spawnLegacyCommand(ctx, command, args)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported hook execution mode %q", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func spawnExecCommand(ctx context.Context, command string, args []string) (*exec.Cmd, error) {
|
||||
if runtime.GOOS == "windows" {
|
||||
if cmd, matched := windowsBatchArgvCommand(ctx, command, args); matched {
|
||||
return cmd, nil
|
||||
}
|
||||
if resolvedShell, resolvedArgs, matched, err := windowsPOSIXShellArgvInvocation(command, args); matched {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return exec.CommandContext(ctx, resolvedShell, resolvedArgs...), nil
|
||||
}
|
||||
}
|
||||
return exec.CommandContext(ctx, command, args...), nil
|
||||
}
|
||||
|
||||
// spawnLegacyCommand preserves the pre-contract behavior:
|
||||
// - a command this call just repaired (its broken quoting means it never
|
||||
// worked through a shell, so there is no expansion behavior to preserve);
|
||||
// - on Windows, a recognized node -e stdin-hook command: `cmd /c` mangles
|
||||
@@ -1248,20 +1320,9 @@ func DefaultSpawner(ctx context.Context, in SpawnInput) SpawnResult {
|
||||
// POSIX commands that were already well-formed keep their shell semantics
|
||||
// verbatim — normalizeStaticNodeEval's rendering escapes $ and backticks, so
|
||||
// even repaired commands re-entering here behave identically under sh -c.
|
||||
func spawnCommand(ctx context.Context, command string, argv ...[]string) (*exec.Cmd, error) {
|
||||
if len(argv) > 0 && argv[0] != nil {
|
||||
if runtime.GOOS == "windows" {
|
||||
if cmd, matched := windowsBatchArgvCommand(ctx, command, argv[0]); matched {
|
||||
return cmd, nil
|
||||
}
|
||||
if shell, args, matched, err := windowsPOSIXShellArgvInvocation(command, argv[0]); matched {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return exec.CommandContext(ctx, shell, args...), nil
|
||||
}
|
||||
}
|
||||
return exec.CommandContext(ctx, command, argv[0]...), nil
|
||||
func spawnLegacyCommand(ctx context.Context, command string, args []string) (*exec.Cmd, error) {
|
||||
if args != nil {
|
||||
return spawnExecCommand(ctx, command, args)
|
||||
}
|
||||
if node, flag, script, ok := repairableNodeEvalArgs(command); ok {
|
||||
return exec.CommandContext(ctx, node, flag, script), nil
|
||||
@@ -1282,11 +1343,90 @@ func spawnCommand(ctx context.Context, command string, argv ...[]string) (*exec.
|
||||
if node, flag, script, ok := directNodeEvalArgs(command); ok {
|
||||
return exec.CommandContext(ctx, node, flag, script), nil
|
||||
}
|
||||
if cmd, ok := windowsCmdShellCommand(ctx, command); ok {
|
||||
return cmd, nil
|
||||
}
|
||||
}
|
||||
name, args := shellInvocation(command)
|
||||
return exec.CommandContext(ctx, name, args...), nil
|
||||
}
|
||||
|
||||
func spawnShellCommand(ctx context.Context, command, preferred string) (*exec.Cmd, error) {
|
||||
preferred = strings.ToLower(strings.TrimSpace(preferred))
|
||||
switch preferred {
|
||||
case "", "auto":
|
||||
if runtime.GOOS == "windows" {
|
||||
// Retain the established #6668 compatibility path for the common
|
||||
// quoted .cmd/.bat hook shape. More complex scripts continue to
|
||||
// the selected shell without being parsed or re-rendered.
|
||||
if cmd, matched := windowsBatchCommand(ctx, command); matched {
|
||||
return cmd, nil
|
||||
}
|
||||
sh, err := cachedWindowsDefaultHookShell()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rawShellCommand(ctx, sh, command)
|
||||
}
|
||||
return exec.CommandContext(ctx, "sh", "-c", command), nil
|
||||
case "bash":
|
||||
if runtime.GOOS == "windows" {
|
||||
path, err := cachedWindowsHookBash()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return exec.CommandContext(ctx, path, "-c", command), nil
|
||||
}
|
||||
return exec.CommandContext(ctx, "bash", "-c", command), nil
|
||||
case "powershell", "pwsh":
|
||||
sh := sandbox.ResolveShell(preferred, "", nil)
|
||||
if sh.Kind != sandbox.ShellPowerShell {
|
||||
return nil, fmt.Errorf("hook requires %s, but no usable PowerShell was found", preferred)
|
||||
}
|
||||
path, err := resolvedHookShellPath(sh)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return powerShellCommand(ctx, path, command), nil
|
||||
case "cmd":
|
||||
if cmd, ok := windowsCmdShellCommand(ctx, command); ok {
|
||||
return cmd, nil
|
||||
}
|
||||
return nil, errors.New("hook shell \"cmd\" is only available on Windows")
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported hook shell %q", preferred)
|
||||
}
|
||||
}
|
||||
|
||||
func rawShellCommand(ctx context.Context, sh sandbox.Shell, command string) (*exec.Cmd, error) {
|
||||
path, err := resolvedHookShellPath(sh)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sh.Kind == sandbox.ShellPowerShell {
|
||||
return powerShellCommand(ctx, path, command), nil
|
||||
}
|
||||
return exec.CommandContext(ctx, path, "-c", command), nil
|
||||
}
|
||||
|
||||
func powerShellCommand(ctx context.Context, path, command string) *exec.Cmd {
|
||||
// PowerShell's native command-line parser does not follow
|
||||
// CommandLineToArgvW consistently for a complex -Command argument.
|
||||
// -EncodedCommand transports the exact script as UTF-16LE and avoids a
|
||||
// second layer of quote/backslash interpretation. Force captured output to
|
||||
// UTF-8 before encoding so Windows PowerShell does not emit the host console
|
||||
// code page into Reasonix's stdout/stderr text contract.
|
||||
command = sandbox.PowerShellUTF8Script(command)
|
||||
codeUnits := utf16.Encode([]rune(command))
|
||||
raw := make([]byte, len(codeUnits)*2)
|
||||
for i, unit := range codeUnits {
|
||||
raw[i*2] = byte(unit)
|
||||
raw[i*2+1] = byte(unit >> 8)
|
||||
}
|
||||
encoded := base64.StdEncoding.EncodeToString(raw)
|
||||
return exec.CommandContext(ctx, path, "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded)
|
||||
}
|
||||
|
||||
func shellInvocation(command string) (string, []string) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "cmd", []string{"/c", command}
|
||||
|
||||
+160
-3
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
fileencoding "reasonix/internal/fileutil/encoding"
|
||||
"reasonix/internal/pluginpkg"
|
||||
"reasonix/internal/sandbox"
|
||||
)
|
||||
|
||||
func writeSettings(t *testing.T, dir, json string) {
|
||||
@@ -341,6 +342,52 @@ func TestRepairablePowerShellFileArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// installSuperpowersV611HookFixture reproduces the package shape reported in
|
||||
// #6602: a Codex-kind installation of superpowers 6.1.1 whose Claude
|
||||
// compatibility manifest launches a quoted, mixed-separator run-hook.cmd.
|
||||
// Keep the hooks document byte-for-byte equivalent to the upstream v6.1.1
|
||||
// declaration so changes in parsing, root expansion, or execution mode cannot
|
||||
// silently fall back to a synthetic contract that the affected plugin did not
|
||||
// use.
|
||||
func installSuperpowersV611HookFixture(t *testing.T, home string) string {
|
||||
t.Helper()
|
||||
reasonixHome := filepath.Join(home, ".reasonix")
|
||||
root := filepath.Join(reasonixHome, "plugins", "superpowers fixture")
|
||||
writeHookTestFile(t, filepath.Join(root, pluginpkg.CodexManifest), `{
|
||||
"name": "superpowers",
|
||||
"description": "Core skills library for Claude Code",
|
||||
"version": "6.1.1"
|
||||
}`)
|
||||
writeHookTestFile(t, filepath.Join(root, "hooks", "hooks.json"), `{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup|clear|compact",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start",
|
||||
"async": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
writeHookTestFile(t, filepath.Join(root, "hooks", "run-hook.cmd"),
|
||||
"@echo off\r\nset /p hook_input=\r\necho %1:%hook_input%\r\n")
|
||||
if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
|
||||
Name: "superpowers",
|
||||
Root: "plugins/superpowers fixture",
|
||||
Version: "6.1.1",
|
||||
ManifestKind: "codex",
|
||||
Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestLoadPermissionRequestHook(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
writeSettings(t, home, `{"hooks":{"PermissionRequest":[{"match":"bash","command":"notify"}]}}`)
|
||||
@@ -354,6 +401,31 @@ func TestLoadPermissionRequestHook(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSuperpowersV611SessionStartExecutionContract(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
root := installSuperpowersV611HookFixture(t, home)
|
||||
|
||||
got := Load(LoadOptions{HomeDir: home, ProjectRoot: filepath.Join(home, "workspace")})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("hooks = %+v, want the upstream superpowers SessionStart hook", got)
|
||||
}
|
||||
h := got[0]
|
||||
if h.Scope != ScopePlugin || h.Event != SessionStart || h.Match != "startup|clear|compact" {
|
||||
t.Fatalf("loaded hook identity = %+v", h)
|
||||
}
|
||||
if h.ExecutionMode != ExecutionShell || h.Shell != "" || h.Argv != nil {
|
||||
t.Fatalf("execution contract = mode %q shell %q argv %#v, want automatic shell form",
|
||||
h.ExecutionMode, h.Shell, h.Argv)
|
||||
}
|
||||
wantCommand := `"` + root + `/hooks/run-hook.cmd" session-start`
|
||||
if h.Command != wantCommand {
|
||||
t.Fatalf("command = %q, want exact expanded upstream command %q", h.Command, wantCommand)
|
||||
}
|
||||
if h.Cwd != root || h.PayloadFormat != "claude" || h.Env["CLAUDE_PLUGIN_ROOT"] != root {
|
||||
t.Fatalf("Claude execution metadata = %+v", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadIncludesPluginSessionStartHook(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
reasonixHome := filepath.Join(home, ".reasonix")
|
||||
@@ -457,6 +529,44 @@ func TestLoadIncludesPluginClaudeCompatibilityHooks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPluginHooksPreservesExecutionContract(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
reasonixHome := filepath.Join(home, ".reasonix")
|
||||
root := filepath.Join(reasonixHome, "plugins", "hook-contract")
|
||||
writeHookTestFile(t, filepath.Join(root, pluginpkg.NativeManifest), `{
|
||||
"name": "hook-contract",
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{"command":"bin/check","args":[],"shellCommand":true},
|
||||
{"command":"printf 'one' && printf 'two'","shell":"bash"}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
|
||||
Name: "hook-contract",
|
||||
Root: "plugins/hook-contract",
|
||||
ManifestKind: "native",
|
||||
Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := Load(LoadOptions{HomeDir: home})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("hooks = %+v, want two plugin hooks", got)
|
||||
}
|
||||
if got[0].ExecutionMode != ExecutionExec || got[0].Argv == nil || len(got[0].Argv) != 0 {
|
||||
t.Fatalf("empty args hook = %+v, want explicit exec form", got[0])
|
||||
}
|
||||
if want := filepath.Join(root, "bin", "check"); got[0].Command != want {
|
||||
t.Fatalf("exec command = %q, want plugin-relative %q", got[0].Command, want)
|
||||
}
|
||||
if got[1].ExecutionMode != ExecutionShell || got[1].Shell != "bash" ||
|
||||
got[1].Command != "printf 'one' && printf 'two'" {
|
||||
t.Fatalf("shell hook = %+v, want raw Bash shell form", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadExpandsReasonixPluginRootBeforeShellLaunch(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
reasonixHome := filepath.Join(home, ".reasonix")
|
||||
@@ -657,6 +767,14 @@ func TestWindowsBatchCommandLineLeavesOtherShellContractsAlone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsCmdCommandLinePreservesCompoundShellScript(t *testing.T) {
|
||||
command := `"C:\plugins\hook.cmd" "argument with spaces" && echo "chained" | findstr chained`
|
||||
want := `cmd.exe /d /s /c ""C:\plugins\hook.cmd" "argument with spaces" && echo "chained" | findstr chained"`
|
||||
if got := windowsCmdCommandLine(command); got != want {
|
||||
t.Fatalf("cmd command line = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsPOSIXShellPreservesExplicitInterpreterPaths(t *testing.T) {
|
||||
called := false
|
||||
resolve := func() (string, error) {
|
||||
@@ -1293,8 +1411,13 @@ func TestRunFiltersByEventAndTool(t *testing.T) {
|
||||
|
||||
func TestRunClaudePayloadAndDirectArgs(t *testing.T) {
|
||||
hooks := []ResolvedHook{{
|
||||
HookConfig: HookConfig{Command: "/tmp/agent-critter", Argv: []string{"--hook"}, PayloadFormat: "claude"},
|
||||
Event: PostToolUseFailure,
|
||||
HookConfig: HookConfig{
|
||||
Command: "/tmp/agent-critter",
|
||||
Argv: []string{"--hook"},
|
||||
ExecutionMode: ExecutionExec,
|
||||
PayloadFormat: "claude",
|
||||
},
|
||||
Event: PostToolUseFailure,
|
||||
}}
|
||||
var input SpawnInput
|
||||
Run(context.Background(), Payload{
|
||||
@@ -1302,7 +1425,7 @@ func TestRunClaudePayloadAndDirectArgs(t *testing.T) {
|
||||
ToolName: "bash", ToolArgs: json.RawMessage(`{"command":"false"}`),
|
||||
ToolResult: "remote: denied", Error: "exit 1",
|
||||
}, hooks, func(_ context.Context, in SpawnInput) SpawnResult { input = in; return SpawnResult{ExitCode: 0} })
|
||||
if input.Command != "/tmp/agent-critter" || len(input.Args) != 1 || input.Args[0] != "--hook" {
|
||||
if input.Command != "/tmp/agent-critter" || input.Mode != ExecutionExec || len(input.Args) != 1 || input.Args[0] != "--hook" {
|
||||
t.Fatalf("direct hook input = %+v", input)
|
||||
}
|
||||
var payload map[string]any
|
||||
@@ -1505,6 +1628,40 @@ func TestDefaultSpawner(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSpawnerExplicitShellPreservesCompoundScript(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows shell selection is covered by windows_batch_test.go")
|
||||
}
|
||||
r := DefaultSpawner(context.Background(), SpawnInput{
|
||||
Command: `printf '%s' "$HOOK_TEST_MARKER" && printf '%s' '|done'`,
|
||||
Mode: ExecutionShell,
|
||||
Shell: "bash",
|
||||
Env: map[string]string{"HOOK_TEST_MARKER": "shell"},
|
||||
Timeout: realSpawnTimeout,
|
||||
})
|
||||
if r.ExitCode != 0 || r.Stdout != "shell|done" {
|
||||
t.Fatalf("explicit shell-form hook failed: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPowerShellCommandEncodesScriptWithoutQuoteReparsing(t *testing.T) {
|
||||
command := `Write-Output "a && 'b'"; $value = "C:\Program Files\hook"`
|
||||
cmd := powerShellCommand(context.Background(), "powershell", command)
|
||||
if got, want := cmd.Args[:4], []string{"powershell", "-NoProfile", "-NonInteractive", "-EncodedCommand"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("PowerShell argv prefix = %#v, want %#v", got, want)
|
||||
}
|
||||
decoded, err := decodePowerShellCommandForTest(cmd.Args[4])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := decoded, sandbox.PowerShellUTF8Script(command); got != want {
|
||||
t.Fatalf("decoded command = %q, want %q", got, want)
|
||||
}
|
||||
if strings.Contains(cmd.Args[4], command) {
|
||||
t.Fatalf("raw script leaked into Windows command-line quoting: %#v", cmd.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSpawnerOutputCap(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX shell")
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -27,14 +28,17 @@ func TestDefaultSpawnerRunsQuotedPluginBatchHook(t *testing.T) {
|
||||
name string
|
||||
command string
|
||||
args []string
|
||||
mode ExecutionMode
|
||||
}{
|
||||
{name: "shell form", command: `"` + filepath.ToSlash(script) + `" session-start`},
|
||||
{name: "explicit default shell form", command: `"` + filepath.ToSlash(script) + `" session-start`, mode: ExecutionShell},
|
||||
{name: "argv form", command: filepath.ToSlash(script), args: []string{"session-start"}},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := DefaultSpawner(context.Background(), SpawnInput{
|
||||
Command: tt.command,
|
||||
Args: tt.args,
|
||||
Mode: tt.mode,
|
||||
Stdin: `{"event":"SessionStart"}`,
|
||||
Timeout: realSpawnTimeout,
|
||||
})
|
||||
@@ -47,3 +51,86 @@ func TestDefaultSpawnerRunsQuotedPluginBatchHook(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuperpowersV611SessionStartHookEndToEnd(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
installSuperpowersV611HookFixture(t, home)
|
||||
workspace := filepath.Join(home, "workspace")
|
||||
hooks := Load(LoadOptions{HomeDir: home, ProjectRoot: workspace})
|
||||
if len(hooks) != 1 {
|
||||
t.Fatalf("hooks = %+v, want one superpowers hook", hooks)
|
||||
}
|
||||
|
||||
report := Run(context.Background(), Payload{
|
||||
Event: SessionStart,
|
||||
SessionID: "issue-6602",
|
||||
Cwd: workspace,
|
||||
}, hooks, nil)
|
||||
if report.Blocked || len(report.Outcomes) != 1 {
|
||||
t.Fatalf("SessionStart report = %+v", report)
|
||||
}
|
||||
outcome := report.Outcomes[0]
|
||||
if outcome.Decision != DecisionPass || outcome.ExitCode != 0 || outcome.Stderr != "" || outcome.TimedOut {
|
||||
t.Fatalf("SessionStart outcome = %+v", outcome)
|
||||
}
|
||||
if !strings.HasPrefix(outcome.Stdout, "session-start:") ||
|
||||
!strings.Contains(outcome.Stdout, `"hook_event_name":"SessionStart"`) ||
|
||||
!strings.Contains(outcome.Stdout, `"session_id":"issue-6602"`) {
|
||||
t.Fatalf("SessionStart stdout = %q, want batch argument plus Claude-compatible stdin", outcome.Stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSpawnerRunsCompoundCmdShellHook(t *testing.T) {
|
||||
pluginRoot := filepath.Join(t.TempDir(), "plugin root")
|
||||
if err := os.MkdirAll(pluginRoot, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
script := filepath.Join(pluginRoot, "compound-hook.cmd")
|
||||
if err := os.WriteFile(script, []byte("@echo off\r\necho script:%1\r\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result := DefaultSpawner(context.Background(), SpawnInput{
|
||||
Command: `"` + filepath.ToSlash(script) + `" "argument with spaces" && echo chained`,
|
||||
Mode: ExecutionShell,
|
||||
Shell: "cmd",
|
||||
Timeout: realSpawnTimeout,
|
||||
})
|
||||
if result.ExitCode != 0 || result.SpawnErr != nil {
|
||||
t.Fatalf("compound cmd hook failed: %+v", result)
|
||||
}
|
||||
got := strings.ReplaceAll(result.Stdout, "\r\n", "\n")
|
||||
// %1 preserves the quoting required to keep the spaced argument together.
|
||||
// A batch script that wants the dequoted value uses %~1 instead.
|
||||
if got != "script:\"argument with spaces\"\nchained" {
|
||||
t.Fatalf("compound cmd stdout = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSpawnerRunsPowerShellHookWithNestedQuotes(t *testing.T) {
|
||||
result := DefaultSpawner(context.Background(), SpawnInput{
|
||||
Command: `$items = @("a b", "c'd", 'e"f', "中文", "🧪"); Write-Output ($items -join "|")`,
|
||||
Mode: ExecutionShell,
|
||||
Shell: "powershell",
|
||||
Timeout: realSpawnTimeout,
|
||||
})
|
||||
if result.ExitCode != 0 || result.SpawnErr != nil {
|
||||
t.Fatalf("PowerShell hook failed: %+v", result)
|
||||
}
|
||||
if got, want := result.Stdout, `a b|c'd|e"f|中文|🧪`; got != want {
|
||||
t.Fatalf("PowerShell stdout = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSpawnerCmdShellExpandsEnvironmentAndPipes(t *testing.T) {
|
||||
result := DefaultSpawner(context.Background(), SpawnInput{
|
||||
Command: `echo %HOOK_CMD_VALUE% | findstr cmd-ok`,
|
||||
Mode: ExecutionShell,
|
||||
Shell: "cmd",
|
||||
Env: map[string]string{"HOOK_CMD_VALUE": "cmd-ok"},
|
||||
Timeout: realSpawnTimeout,
|
||||
})
|
||||
if result.ExitCode != 0 || result.SpawnErr != nil || !strings.Contains(result.Stdout, "cmd-ok") {
|
||||
t.Fatalf("cmd shell hook failed: %+v", result)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package hook
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -19,6 +20,12 @@ var windowsHookBash struct {
|
||||
err error
|
||||
}
|
||||
|
||||
var windowsDefaultHookShell struct {
|
||||
sync.Once
|
||||
shell sandbox.Shell
|
||||
err error
|
||||
}
|
||||
|
||||
// windowsPOSIXShellInvocation preserves explicit `sh -c` / `bash -c` hook
|
||||
// contracts on Windows. Git for Windows normally ships a real Bash outside the
|
||||
// cmd.exe PATH, so reuse the same hardened discovery path as the shell tool
|
||||
@@ -108,6 +115,13 @@ func windowsBatchArgvCommandLine(command string, args []string) (string, bool) {
|
||||
return b.String(), true
|
||||
}
|
||||
|
||||
// windowsCmdCommandLine wraps a raw shell-form script without tokenizing or
|
||||
// re-rendering it. cmd.exe owns all quote, variable, pipeline, and chaining
|
||||
// semantics inside the /c string.
|
||||
func windowsCmdCommandLine(command string) string {
|
||||
return `cmd.exe /d /s /c "` + command + `"`
|
||||
}
|
||||
|
||||
func normalizeWindowsBatchExecutable(executable string) string {
|
||||
return strings.ReplaceAll(strings.TrimSpace(executable), "/", `\`)
|
||||
}
|
||||
@@ -204,26 +218,46 @@ func cachedWindowsHookBash() (string, error) {
|
||||
windowsHookBash.err = missingWindowsHookBashError()
|
||||
return
|
||||
}
|
||||
path := strings.TrimSpace(shell.Path)
|
||||
if path == "" {
|
||||
path, err := resolvedHookShellPath(shell)
|
||||
if err != nil {
|
||||
windowsHookBash.err = missingWindowsHookBashError()
|
||||
return
|
||||
}
|
||||
if resolved, err := exec.LookPath(path); err == nil {
|
||||
windowsHookBash.path = resolved
|
||||
return
|
||||
}
|
||||
if filepath.IsAbs(path) {
|
||||
if info, err := os.Stat(path); err == nil && !info.IsDir() {
|
||||
windowsHookBash.path = path
|
||||
return
|
||||
}
|
||||
}
|
||||
windowsHookBash.err = missingWindowsHookBashError()
|
||||
windowsHookBash.path = path
|
||||
})
|
||||
return windowsHookBash.path, windowsHookBash.err
|
||||
}
|
||||
|
||||
func cachedWindowsDefaultHookShell() (sandbox.Shell, error) {
|
||||
windowsDefaultHookShell.Do(func() {
|
||||
sh := sandbox.ResolveShell("", "", nil)
|
||||
path, err := resolvedHookShellPath(sh)
|
||||
if err != nil {
|
||||
windowsDefaultHookShell.err = errors.New("hook requires a shell on Windows, but neither Git Bash nor PowerShell is usable")
|
||||
return
|
||||
}
|
||||
sh.Path = path
|
||||
windowsDefaultHookShell.shell = sh
|
||||
})
|
||||
return windowsDefaultHookShell.shell, windowsDefaultHookShell.err
|
||||
}
|
||||
|
||||
func resolvedHookShellPath(shell sandbox.Shell) (string, error) {
|
||||
path := strings.TrimSpace(shell.Path)
|
||||
if path == "" {
|
||||
path = shell.Kind.String()
|
||||
}
|
||||
if resolved, err := exec.LookPath(path); err == nil {
|
||||
return resolved, nil
|
||||
}
|
||||
if filepath.IsAbs(path) {
|
||||
if info, err := os.Stat(path); err == nil && !info.IsDir() {
|
||||
return path, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("hook shell %q is not executable", path)
|
||||
}
|
||||
|
||||
func missingWindowsHookBashError() error {
|
||||
return errors.New("hook requires a POSIX shell on Windows, but no usable Git Bash was found; install Git for Windows or replace the POSIX shell hook with a native portable command")
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -34,7 +35,8 @@ type claudeHookDocument struct {
|
||||
Hooks []struct {
|
||||
Type string `json:"type"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
Args *[]string `json:"args"`
|
||||
Shell string `json:"shell"`
|
||||
Description string `json:"description"`
|
||||
Timeout int `json:"timeout"`
|
||||
Async bool `json:"async"`
|
||||
@@ -179,10 +181,26 @@ func appendClaudeHooksFile(root, rel string, manifest *Manifest) ([]string, []Co
|
||||
issues = append(issues, CompatibilityIssue{Capability: "hooks", Path: rel, Reason: reason})
|
||||
continue
|
||||
}
|
||||
command := strings.TrimSpace(item.Command)
|
||||
if command == "" {
|
||||
command := item.Command
|
||||
if strings.TrimSpace(command) == "" {
|
||||
continue
|
||||
}
|
||||
argsSet := item.Args != nil
|
||||
var args []string
|
||||
shell := ""
|
||||
if argsSet {
|
||||
// Exec-form arguments are literal. Preserve empty and
|
||||
// whitespace-only values exactly as declared.
|
||||
args = append([]string{}, (*item.Args)...)
|
||||
} else {
|
||||
shell = strings.ToLower(strings.TrimSpace(item.Shell))
|
||||
if !validClaudeHookShell(shell) {
|
||||
reason := fmt.Sprintf("%s hook %q uses unsupported shell %q", event, command, item.Shell)
|
||||
warnings = append(warnings, rel+": "+reason)
|
||||
issues = append(issues, CompatibilityIssue{Capability: "hooks", Path: rel, Reason: reason})
|
||||
continue
|
||||
}
|
||||
}
|
||||
if ifExpr := strings.TrimSpace(item.If); ifExpr != "" {
|
||||
reason := fmt.Sprintf("%s hook %q has a conditional \"if\": %q that Reasonix does not evaluate — it runs unconditionally instead of only for the matching case", event, command, ifExpr)
|
||||
warnings = append(warnings, rel+": "+reason)
|
||||
@@ -215,8 +233,10 @@ func appendClaudeHooksFile(root, rel string, manifest *Manifest) ([]string, []Co
|
||||
manifest.Hooks[event] = appendUniqueHook(manifest.Hooks[event], Hook{
|
||||
Match: match,
|
||||
Command: command,
|
||||
Args: cleanStringList(item.Args),
|
||||
Args: args,
|
||||
ArgsSet: argsSet,
|
||||
ShellCommand: true,
|
||||
Shell: shell,
|
||||
Async: item.Async,
|
||||
PayloadFormat: "claude",
|
||||
Description: firstNonEmpty(strings.TrimSpace(item.Description), "Claude-compatible hook from "+rel),
|
||||
@@ -265,12 +285,17 @@ func appendUniqueHook(hooks []Hook, candidate Hook) []Hook {
|
||||
// silently drop the second one.
|
||||
func hooksEqual(a, b Hook) bool {
|
||||
return a.Match == b.Match && a.Command == b.Command &&
|
||||
strings.Join(a.Args, "\x00") == strings.Join(b.Args, "\x00") &&
|
||||
a.ArgsSet == b.ArgsSet && slices.Equal(a.Args, b.Args) &&
|
||||
a.ShellCommand == b.ShellCommand && a.Shell == b.Shell &&
|
||||
a.PayloadFormat == b.PayloadFormat &&
|
||||
a.Async == b.Async && a.Timeout == b.Timeout && a.Cwd == b.Cwd &&
|
||||
maps.Equal(a.Env, b.Env)
|
||||
}
|
||||
|
||||
func validClaudeHookShell(shell string) bool {
|
||||
return shell == "" || shell == "bash" || shell == "powershell"
|
||||
}
|
||||
|
||||
func appendClaudeMCPFile(root string, manifest *Manifest) ([]string, []CompatibilityIssue) {
|
||||
path := filepath.Join(root, claudeMCPPath)
|
||||
body, err := fileencoding.ReadFileUTF8(path)
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package pluginpkg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestHookJSONExecutionFormPresenceMatrix(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantArgsSet bool
|
||||
wantArgs []string
|
||||
}{
|
||||
{name: "omitted", body: `{"command":"echo ok"}`},
|
||||
{name: "explicit empty", body: `{"command":"echo","args":[]}`, wantArgsSet: true, wantArgs: []string{}},
|
||||
{name: "case insensitive field", body: `{"command":"echo","Args":[]}`, wantArgsSet: true, wantArgs: []string{}},
|
||||
{name: "literal values", body: `{"command":"echo","args":[""," spaced ","$HOME","a && b"]}`, wantArgsSet: true, wantArgs: []string{"", " spaced ", "$HOME", "a && b"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var got Hook
|
||||
if err := json.Unmarshal([]byte(tt.body), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ArgsSet != tt.wantArgsSet || !reflect.DeepEqual(got.Args, tt.wantArgs) {
|
||||
t.Fatalf("hook args = %#v set=%v, want %#v set=%v", got.Args, got.ArgsSet, tt.wantArgs, tt.wantArgsSet)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHookJSONRoundTripPreservesExplicitEmptyArgs(t *testing.T) {
|
||||
before := Hook{Command: "bin/check", Args: []string{}, ArgsSet: true}
|
||||
body, err := json.Marshal(before)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(body), `"args":[]`) {
|
||||
t.Fatalf("explicit empty args disappeared from JSON: %s", body)
|
||||
}
|
||||
var after Hook
|
||||
if err := json.Unmarshal(body, &after); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !after.ArgsSet || after.Args == nil || len(after.Args) != 0 {
|
||||
t.Fatalf("round-tripped hook = %#v, want explicit empty exec form", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHookJSONRoundTripKeepsShellFormWithoutArgs(t *testing.T) {
|
||||
before := Hook{Command: "echo ok && echo done", ShellCommand: true, Shell: "bash"}
|
||||
body, err := json.Marshal(before)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(body), `"args"`) {
|
||||
t.Fatalf("shell-form JSON unexpectedly gained args: %s", body)
|
||||
}
|
||||
var after Hook
|
||||
if err := json.Unmarshal(body, &after); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.ArgsSet || after.Args != nil || after.Shell != "bash" {
|
||||
t.Fatalf("round-tripped shell hook = %#v", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeHooksExecutionContractMatrix(t *testing.T) {
|
||||
got := normalizeHooks(map[string][]Hook{
|
||||
" SessionStart ": {
|
||||
{Command: " echo legacy "},
|
||||
{Command: " echo shell ", Shell: " PoWeRsHeLl "},
|
||||
{Command: "bin/check", Args: []string{}, ArgsSet: true, Shell: "BASH"},
|
||||
{ContextFile: " CLAUDE.md "},
|
||||
{Command: " "},
|
||||
},
|
||||
})
|
||||
hooks := got["SessionStart"]
|
||||
if len(hooks) != 4 {
|
||||
t.Fatalf("normalized hooks = %#v, want four non-empty hooks", hooks)
|
||||
}
|
||||
if hooks[0].Command != "echo legacy" || hooks[0].ShellCommand {
|
||||
t.Fatalf("legacy hook changed mode: %#v", hooks[0])
|
||||
}
|
||||
if hooks[1].Command != "echo shell" || hooks[1].Shell != "powershell" || !hooks[1].ShellCommand {
|
||||
t.Fatalf("shell hook normalization = %#v", hooks[1])
|
||||
}
|
||||
if !hooks[2].ArgsSet || hooks[2].ShellCommand || hooks[2].Shell != "bash" {
|
||||
t.Fatalf("exec hook did not take precedence over shell: %#v", hooks[2])
|
||||
}
|
||||
if hooks[3].ContextFile != "CLAUDE.md" {
|
||||
t.Fatalf("context hook normalization = %#v", hooks[3])
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidHookShellMatrix(t *testing.T) {
|
||||
for _, shell := range []string{"", "auto", "AUTO", "bash", "powershell", "pwsh", "cmd", " CMD "} {
|
||||
if !validHookShell(shell) {
|
||||
t.Errorf("validHookShell(%q) = false", shell)
|
||||
}
|
||||
}
|
||||
for _, shell := range []string{"sh", "zsh", "fish", "cmd.exe", "powershell.exe"} {
|
||||
if validHookShell(shell) {
|
||||
t.Errorf("validHookShell(%q) = true", shell)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeManifestRejectsUnsupportedHookShell(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, NativeManifest)
|
||||
writeTestFile(t, path, `{
|
||||
"name":"bad-shell",
|
||||
"hooks":{"SessionStart":[{"command":"echo ok","shell":"fish"}]}
|
||||
}`)
|
||||
if _, _, err := parseNative(path, root); err == nil || !strings.Contains(err.Error(), `hook shell "fish" is not supported`) {
|
||||
t.Fatalf("parseNative unsupported shell error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzHookJSONExecFormRoundTrip(f *testing.F) {
|
||||
for _, seed := range []string{"", " spaced ", "$HOME", `%PATH%`, `a && b | c`, `quote"'`, "中文🧪"} {
|
||||
f.Add(seed)
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, arg string) {
|
||||
if !utf8.ValidString(arg) {
|
||||
t.Skip()
|
||||
}
|
||||
before := Hook{Command: "tool", Args: []string{arg, "", "tail"}, ArgsSet: true}
|
||||
body, err := json.Marshal(before)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var after Hook
|
||||
if err := json.Unmarshal(body, &after); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !after.ArgsSet || !reflect.DeepEqual(after.Args, before.Args) {
|
||||
t.Fatalf("exec-form args changed:\n got %#v\nwant %#v\njson %s", after.Args, before.Args, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -130,8 +130,10 @@ type Hook struct {
|
||||
Match string `json:"match,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
ArgsSet bool `json:"-"`
|
||||
ContextFile string `json:"contextFile,omitempty"`
|
||||
ShellCommand bool `json:"shellCommand,omitempty"`
|
||||
Shell string `json:"shell,omitempty"`
|
||||
Async bool `json:"async,omitempty"`
|
||||
PayloadFormat string `json:"payloadFormat,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
@@ -140,6 +142,54 @@ type Hook struct {
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON preserves whether args was present, including an explicit
|
||||
// empty array. Hook execution uses field presence — not argument count — to
|
||||
// distinguish exec form from shell form.
|
||||
func (h *Hook) UnmarshalJSON(data []byte) error {
|
||||
type hookJSON Hook
|
||||
var decoded hookJSON
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
return err
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &fields); err != nil {
|
||||
return err
|
||||
}
|
||||
*h = Hook(decoded)
|
||||
for name := range fields {
|
||||
if strings.EqualFold(name, "args") {
|
||||
h.ArgsSet = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON keeps an explicit empty args array visible. Without this custom
|
||||
// form, omitempty would erase args:[] and silently change exec form into shell
|
||||
// form after a JSON round trip.
|
||||
func (h Hook) MarshalJSON() ([]byte, error) {
|
||||
type hookJSON Hook
|
||||
data, err := json.Marshal(hookJSON(h))
|
||||
if err != nil || !h.ArgsSet {
|
||||
return data, err
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &fields); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := h.Args
|
||||
if args == nil {
|
||||
args = []string{}
|
||||
}
|
||||
rawArgs, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fields["args"] = rawArgs
|
||||
return json.Marshal(fields)
|
||||
}
|
||||
|
||||
type MCPServer struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
@@ -663,6 +713,10 @@ func normalizeHooks(in map[string][]Hook) map[string][]Hook {
|
||||
h.Command = strings.TrimSpace(h.Command)
|
||||
h.ContextFile = strings.TrimSpace(h.ContextFile)
|
||||
h.Cwd = strings.TrimSpace(h.Cwd)
|
||||
h.Shell = strings.ToLower(strings.TrimSpace(h.Shell))
|
||||
if h.Shell != "" && !h.ArgsSet {
|
||||
h.ShellCommand = true
|
||||
}
|
||||
if h.Command == "" && h.ContextFile == "" {
|
||||
continue
|
||||
}
|
||||
@@ -699,6 +753,9 @@ func validateManifest(root string, m *Manifest) error {
|
||||
if h.Command == "" && h.ContextFile == "" {
|
||||
return fmt.Errorf("hook command or contextFile is required")
|
||||
}
|
||||
if !h.ArgsSet && !validHookShell(h.Shell) {
|
||||
return fmt.Errorf("hook shell %q is not supported (use auto, bash, powershell, pwsh, or cmd)", h.Shell)
|
||||
}
|
||||
if h.Command != "" && !h.ShellCommand && !filepath.IsAbs(h.Command) {
|
||||
if err := validateRelativePath(h.Command); err != nil {
|
||||
return err
|
||||
@@ -727,6 +784,15 @@ func validateManifest(root string, m *Manifest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validHookShell(shell string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(shell)) {
|
||||
case "", "auto", "bash", "powershell", "pwsh", "cmd":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validateRelativePath(p string) error {
|
||||
p = filepath.Clean(strings.TrimSpace(p))
|
||||
if p == "" {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package pluginpkg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -344,6 +346,52 @@ func TestParseClaudeHooksKeepsDistinctEnvTimeoutAsyncCwd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudeHooksPreservesExecAndShellForms(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, ClaudeManifest), `{"name": "hook-contract-pack"}`)
|
||||
writeTestFile(t, filepath.Join(root, "hooks", "hooks.json"), `{
|
||||
"hooks": {"SessionStart": [
|
||||
{"hooks": [
|
||||
{"type":"command","command":"node","args":[],"shell":"powershell"},
|
||||
{"type":"command","command":"tool","args":[""," spaced ","$HOME"]},
|
||||
{"type":"command","command":"Write-Output \"a && b\"","shell":"powershell"}
|
||||
]}
|
||||
]}
|
||||
}`)
|
||||
|
||||
pkg, warnings, err := ParseDir(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDir: %v", err)
|
||||
}
|
||||
if len(warnings) != 0 {
|
||||
t.Fatalf("warnings = %v, want none", warnings)
|
||||
}
|
||||
hooks := pkg.Manifest.Hooks["SessionStart"]
|
||||
if len(hooks) != 3 {
|
||||
t.Fatalf("hooks = %#v, want 3", hooks)
|
||||
}
|
||||
if !hooks[0].ArgsSet || hooks[0].Args == nil || len(hooks[0].Args) != 0 || hooks[0].Shell != "" {
|
||||
t.Fatalf("explicit empty args did not remain exec form (and ignore shell): %#v", hooks[0])
|
||||
}
|
||||
wantArgs := []string{"", " spaced ", "$HOME"}
|
||||
if !hooks[1].ArgsSet || !reflect.DeepEqual(hooks[1].Args, wantArgs) {
|
||||
t.Fatalf("literal exec args = %#v, want %#v", hooks[1].Args, wantArgs)
|
||||
}
|
||||
if hooks[2].ArgsSet || hooks[2].Shell != "powershell" || !hooks[2].ShellCommand {
|
||||
t.Fatalf("PowerShell hook did not remain shell form: %#v", hooks[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHookJSONPreservesExplicitEmptyArgs(t *testing.T) {
|
||||
var hook Hook
|
||||
if err := json.Unmarshal([]byte(`{"command":"bin/check","args":[]}`), &hook); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !hook.ArgsSet || hook.Args == nil || len(hook.Args) != 0 {
|
||||
t.Fatalf("hook = %#v, want explicit empty exec-form args", hook)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudeHooksWarnOnUnsupportedSemantics(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -453,6 +501,27 @@ func TestParseClaudeHooksWarnOnUnsupportedSemantics(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudeHooksSkipsUnsupportedShell(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, ClaudeManifest), `{"name": "hook-pack"}`)
|
||||
writeTestFile(t, filepath.Join(root, "hooks", "hooks.json"),
|
||||
`{"hooks":{"PostToolUse":[{"hooks":[{"type":"command","command":"echo ok","shell":"cmd"}]}]}}`)
|
||||
|
||||
pkg, warnings, err := ParseDir(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDir: %v", err)
|
||||
}
|
||||
if pkg.Compatibility.Status != "none" {
|
||||
t.Fatalf("compatibility status = %q, want none", pkg.Compatibility.Status)
|
||||
}
|
||||
if len(pkg.Manifest.Hooks) != 0 {
|
||||
t.Fatalf("unsupported shell hook was imported: %#v", pkg.Manifest.Hooks)
|
||||
}
|
||||
if len(warnings) != 1 || !strings.Contains(warnings[0], `unsupported shell "cmd"`) {
|
||||
t.Fatalf("warnings = %v, want unsupported shell diagnostic", warnings)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseClaudeHooksReportsStructuralGapsOncePerFile pins the noise bound:
|
||||
// a plugin with several wildcard hooks reports each structural input gap
|
||||
// (WebFetch prompt, NotebookEdit cell_number, TaskOutput multi-job) once per
|
||||
|
||||
@@ -20,6 +20,13 @@ import (
|
||||
// text come back as valid UTF-8 rather than mojibake.
|
||||
const psUTF8Prologue = "$OutputEncoding=[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;"
|
||||
|
||||
// PowerShellUTF8Script prepares a PowerShell script for captured execution.
|
||||
// Setting both encodings keeps PowerShell's own output and native child-process
|
||||
// output UTF-8 across Windows console code pages.
|
||||
func PowerShellUTF8Script(command string) string {
|
||||
return psUTF8Prologue + command
|
||||
}
|
||||
|
||||
// ShellKind is the interpreter a shell command runs under.
|
||||
type ShellKind int
|
||||
|
||||
@@ -356,7 +363,7 @@ func (s Shell) argv(command string) []string {
|
||||
path = s.Kind.String()
|
||||
}
|
||||
if s.Kind == ShellPowerShell {
|
||||
return []string{path, "-NoProfile", "-NonInteractive", "-Command", psUTF8Prologue + normalizeNullRedirects(command, "$null")}
|
||||
return []string{path, "-NoProfile", "-NonInteractive", "-Command", PowerShellUTF8Script(normalizeNullRedirects(command, "$null"))}
|
||||
}
|
||||
return []string{path, "-c", normalizeNullRedirects(command, "/dev/null")}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user