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:
SivanCola authored and GitHub committed 2026-07-24 23:07:09 +08:00
1 parent 4b2ef3dabb
commit 72a1e32b49
14 files changed
+1160 -58

No files matched your search

+29 -4
View File
@@ -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
+24 -3
View File
@@ -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 仍可
+4
View File
@@ -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
}
+4
View File
@@ -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
+316
View File
@@ -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
View File
@@ -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
View File
@@ -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")
+87
View File
@@ -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)
}
}
+47 -13
View File
@@ -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")
}
+30 -5
View File
@@ -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)
+147
View File
@@ -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)
}
})
}
+66
View File
@@ -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 == "" {
+69
View File
@@ -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
+8 -1
View File
@@ -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")}
}