Files
gnoma/internal/hook/command_test.go
T
vikingowl c44db99b41 fix(hook): execute hook Exec as a binary, not via sh -c
Plugin loader resolves HookSpec.Exec as a relative path joined to the
plugin directory, and manifest.checkSafePath rejects absolute paths and
'..' traversal — Exec was always meant to be an executable path.

The hook executor was wrapping it in 'sh -c', adding a redundant shell
interpretation step that turned any space, quote, or metacharacter in
the path into command-injection surface. Switch to exec.Command(path)
with no shell wrapping.

Closes audit finding C3. Adds a regression test that fails under the
old 'sh -c' code path: a canary file created via shell sequencing
remains absent when the executor treats Exec as a literal filename.

Hook command tests now write small /bin/sh scripts to t.TempDir and
point Exec at those — matching production semantics (resolved binary
path) rather than inline shell strings.
2026-05-19 16:30:23 +02:00

276 lines
8.4 KiB
Go

package hook
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// writeHookScript writes a /bin/sh script to a temp dir and returns its path.
// Hooks are executed as binaries (no shell wrapping), so tests that want to
// exercise shell-driven behaviour must do so by invoking an actual script.
func writeHookScript(t *testing.T, body string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "hook.sh")
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
t.Fatalf("writeHookScript: %v", err)
}
return path
}
func TestCommandExecutor_ExitZero_Allow(t *testing.T) {
def := HookDef{Name: "test", Event: PreToolUse, Command: CommandTypeShell, Exec: writeHookScript(t, "exit 0")}
ex := NewCommandExecutor(def)
result, err := ex.Execute(context.Background(), []byte(`{}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Action != Allow {
t.Errorf("action = %v, want Allow", result.Action)
}
}
func TestCommandExecutor_ExitOne_Skip(t *testing.T) {
def := HookDef{Name: "test", Event: PreToolUse, Command: CommandTypeShell, Exec: writeHookScript(t, "exit 1")}
ex := NewCommandExecutor(def)
result, err := ex.Execute(context.Background(), []byte(`{}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Action != Skip {
t.Errorf("action = %v, want Skip", result.Action)
}
}
func TestCommandExecutor_ExitTwo_Deny(t *testing.T) {
def := HookDef{Name: "test", Event: PreToolUse, Command: CommandTypeShell, Exec: writeHookScript(t, "exit 2")}
ex := NewCommandExecutor(def)
result, err := ex.Execute(context.Background(), []byte(`{}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Action != Deny {
t.Errorf("action = %v, want Deny", result.Action)
}
}
func TestCommandExecutor_StdinDelivered(t *testing.T) {
// Hook reads stdin and echoes it back to stdout as JSON with action=allow
def := HookDef{
Name: "test",
Event: PreToolUse,
Command: CommandTypeShell,
Exec: writeHookScript(t, `read -r line; echo '{"action":"allow","transformed":'"$line"'}'; exit 0`),
}
ex := NewCommandExecutor(def)
payload := []byte(`{"tool":"bash"}`)
result, err := ex.Execute(context.Background(), payload)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Action != Allow {
t.Errorf("action = %v, want Allow", result.Action)
}
}
func TestCommandExecutor_StdoutJSON_Parsed(t *testing.T) {
// Hook outputs JSON with transformed payload
def := HookDef{
Name: "test",
Event: PreToolUse,
Command: CommandTypeShell,
Exec: writeHookScript(t, `echo '{"action":"deny","transformed":{"command":"safe"}}'`),
}
ex := NewCommandExecutor(def)
result, err := ex.Execute(context.Background(), []byte(`{}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Action != Deny {
t.Errorf("action = %v, want Deny", result.Action)
}
if result.Output == nil {
t.Error("expected non-nil transformed output")
}
}
func TestCommandExecutor_StdoutJSON_ActionOverridesExitCode(t *testing.T) {
// exit 0, but stdout says deny
def := HookDef{
Name: "test",
Event: PreToolUse,
Command: CommandTypeShell,
Exec: writeHookScript(t, `echo '{"action":"deny"}'; exit 0`),
}
ex := NewCommandExecutor(def)
result, err := ex.Execute(context.Background(), []byte(`{}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Action != Deny {
t.Errorf("action = %v, want Deny", result.Action)
}
}
func TestCommandExecutor_EmptyStdout_ExitCodeFallback(t *testing.T) {
def := HookDef{
Name: "test",
Event: PreToolUse,
Command: CommandTypeShell,
Exec: writeHookScript(t, "exit 2"),
}
ex := NewCommandExecutor(def)
result, err := ex.Execute(context.Background(), []byte(`{}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Action != Deny {
t.Errorf("action = %v, want Deny", result.Action)
}
if result.Output != nil {
t.Error("expected nil output for empty stdout")
}
}
func TestCommandExecutor_Timeout_FailOpenTrue(t *testing.T) {
def := HookDef{
Name: "test",
Event: PreToolUse,
Command: CommandTypeShell,
Exec: writeHookScript(t, "sleep 10"),
Timeout: 50 * time.Millisecond,
FailOpen: true,
}
ex := NewCommandExecutor(def)
result, err := ex.Execute(context.Background(), []byte(`{}`))
if err == nil {
t.Fatal("expected error on timeout")
}
if result.Action != Allow {
t.Errorf("fail_open=true: action = %v, want Allow", result.Action)
}
}
func TestCommandExecutor_Timeout_FailOpenFalse(t *testing.T) {
def := HookDef{
Name: "test",
Event: PreToolUse,
Command: CommandTypeShell,
Exec: writeHookScript(t, "sleep 10"),
Timeout: 50 * time.Millisecond,
FailOpen: false,
}
ex := NewCommandExecutor(def)
result, err := ex.Execute(context.Background(), []byte(`{}`))
if err == nil {
t.Fatal("expected error on timeout")
}
if result.Action != Deny {
t.Errorf("fail_open=false: action = %v, want Deny", result.Action)
}
}
func TestCommandExecutor_InvalidJSON_Stdout(t *testing.T) {
// Hook writes garbage — should error
def := HookDef{
Name: "test",
Event: PreToolUse,
Command: CommandTypeShell,
Exec: writeHookScript(t, `echo "not valid json"`),
}
ex := NewCommandExecutor(def)
result, err := ex.Execute(context.Background(), []byte(`{}`))
if err == nil {
t.Fatal("expected error for invalid JSON stdout")
}
// fail_open=false (default) → Deny on error
if result.Action != Deny {
t.Errorf("action = %v, want Deny on error with fail_open=false", result.Action)
}
}
func TestCommandExecutor_Duration_Recorded(t *testing.T) {
def := HookDef{Name: "test", Event: PreToolUse, Command: CommandTypeShell, Exec: writeHookScript(t, "exit 0")}
ex := NewCommandExecutor(def)
result, _ := ex.Execute(context.Background(), []byte(`{}`))
if result.Duration <= 0 {
t.Error("expected Duration > 0")
}
}
func TestCommandExecutor_PreToolPayload_HasToolField(t *testing.T) {
// Verify that a real pre_tool_use payload round-trips through the executor
args := json.RawMessage(`{"command":"ls"}`)
payload := MarshalPreToolPayload("bash", args)
def := HookDef{
Name: "test",
Event: PreToolUse,
Command: CommandTypeShell,
// Read tool name from stdin, allow if it's "bash"
Exec: writeHookScript(t, `input=$(cat); tool=$(echo "$input" | grep -o '"tool":"[^"]*"' | cut -d'"' -f4); [ "$tool" = "bash" ] && exit 0 || exit 2`),
}
ex := NewCommandExecutor(def)
result, err := ex.Execute(context.Background(), payload)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Action != Allow {
t.Errorf("action = %v, want Allow for tool=bash", result.Action)
}
}
// Audit C3: hook Exec must be executed as a binary path, never piped through
// a shell. A path that contains shell metacharacters should be treated as a
// literal filename and produce a "file not found" error, not an injection.
func TestCommandExecutor_ShellMetacharsInExecNotInterpreted(t *testing.T) {
canary := filepath.Join(t.TempDir(), "canary")
// If Exec were ever wrapped in `sh -c`, this string would create the canary.
// With argv-style execution, the whole thing is the literal program name,
// which does not exist, so the canary must NOT be created.
def := HookDef{
Name: "test",
Event: PreToolUse,
Command: CommandTypeShell,
Exec: "/bin/true; touch " + canary,
}
ex := NewCommandExecutor(def)
_, err := ex.Execute(context.Background(), []byte(`{}`))
if err == nil {
t.Fatal("expected error executing a non-existent program")
}
if _, statErr := os.Stat(canary); statErr == nil {
t.Fatalf("canary %s was created — Exec was shell-interpreted", canary)
}
}
// Verify the executor honours context cancellation in addition to its own timeout.
func TestCommandExecutor_ContextCancelled(t *testing.T) {
def := HookDef{
Name: "test",
Event: PreToolUse,
Command: CommandTypeShell,
Exec: writeHookScript(t, "sleep 10"),
FailOpen: true,
}
ex := NewCommandExecutor(def)
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
result, err := ex.Execute(ctx, []byte(`{}`))
if err == nil {
t.Fatal("expected error on context cancellation")
}
if !strings.Contains(err.Error(), "hook") {
t.Logf("error = %v", err) // just informational
}
if result.Action != Allow { // fail_open=true
t.Errorf("action = %v, want Allow (fail_open=true)", result.Action)
}
}