Files
2026-08-14 23:41:57 +08:00

163 lines
5.2 KiB
Go

// This file implements Runner.Run: executing a single hook command via the
// system shell with the event payload on stdin, a per-hook timeout, bounded
// output capture, and exit-code classification. It is the one place that forks
// a process, so all isolation guarantees (timeout kill, output cap, error
// containment) live here.
package hooks
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"time"
)
// MaxOutputBytes caps the bytes captured from a hook's stdout and stderr each
// (FR-13). Output beyond this is dropped and the truncation is flagged; the
// captured prefix is still parsed so a hook that prints a small JSON decision
// followed by noise still works.
const MaxOutputBytes = 1 << 20 // 1 MB
// blockExitCode is the exit code that signals a block (Claude Code semantics):
// the command exited 2, stderr carries the reason.
const blockExitCode = 2
// Runner executes hook commands. Shell defaults to "sh" with a "-c" flag; it is
// a field so tests can substitute a shell and future platforms can override it.
// ProjectDir is the working directory hook commands run in (the project root).
// ExtraEnv is appended to the process environment (PIGO_* variables).
type Runner struct {
Shell string
ProjectDir string
WarnLog io.Writer
}
// Run executes one hook, writing input as a single-line JSON document to the
// command's stdin. It returns the parsed HookOutput and a non-nil error only
// for an execution *failure* (could not start, timed out, or exited non-zero
// and non-2). A clean exit 0 or a block (exit 2) both return err == nil; the
// caller distinguishes a block via HookOutput.blocks(). The PIGO_* environment
// variables are injected on top of the current process environment.
func (r *Runner) Run(ctx context.Context, h HookConfig, input HookInput) (HookOutput, error) {
payload, err := json.Marshal(input)
if err != nil {
return HookOutput{}, fmt.Errorf("marshal hook input: %w", err)
}
timeout := time.Duration(h.TimeoutSeconds()) * time.Second
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
shell := r.Shell
if shell == "" {
shell = "sh"
}
cmd := exec.CommandContext(runCtx, shell, "-c", h.Command)
cmd.Dir = r.ProjectDir
cmd.Env = r.env(input)
cmd.Stdin = bytes.NewReader(payload)
// On timeout, CommandContext kills the shell, but a grandchild it spawned
// (e.g. `sh -c "sleep 5"` forking sleep) can inherit the stdout/stderr pipes
// and keep them open, blocking cmd.Run until that grandchild exits on its own
// — so Run would return only after the full command duration, not the timeout.
// WaitDelay bounds that wait: after the process is killed, Go force-closes the
// I/O pipes so Run returns promptly.
cmd.WaitDelay = time.Second
var stdout, stderr cappedBuffer
stdout.limit = MaxOutputBytes
stderr.limit = MaxOutputBytes
cmd.Stdout = &stdout
cmd.Stderr = &stderr
runErr := cmd.Run()
if stdout.truncated() || stderr.truncated() {
warnf(r.WarnLog, "pigo: hooks: output from command %q exceeded %d bytes and was truncated\n", h.Command, MaxOutputBytes)
}
// Timeout: the context deadline fired and the process was killed.
if runCtx.Err() == context.DeadlineExceeded {
return HookOutput{}, fmt.Errorf("hook timed out after %s", timeout)
}
exitCode := 0
if runErr != nil {
var ee *exec.ExitError
if errors.As(runErr, &ee) {
exitCode = ee.ExitCode()
} else {
// Could not start (ENOENT etc.) or was killed.
return HookOutput{}, fmt.Errorf("hook failed to run: %w", runErr)
}
}
switch exitCode {
case 0:
out, _ := parseHookOutput(stdout.Bytes())
return out, nil
case blockExitCode:
// Block: prefer a JSON decision if present, else synthesize one from
// stderr as the reason.
if out, ok := parseHookOutput(stdout.Bytes()); ok {
if out.Reason == "" {
out.Reason = strings.TrimSpace(string(stderr.Bytes()))
}
out.Decision = "block"
return out, nil
}
return HookOutput{Decision: "block", Reason: strings.TrimSpace(string(stderr.Bytes()))}, nil
default:
return HookOutput{}, fmt.Errorf("hook exited with code %d: %s", exitCode, strings.TrimSpace(string(stderr.Bytes())))
}
}
// env builds the command environment: the current process environment plus the
// PIGO_* variables derived from the input.
func (r *Runner) env(input HookInput) []string {
env := append([]string(nil), os.Environ()...)
env = append(env,
"PIGO_SESSION_ID="+input.SessionID,
"PIGO_PROJECT_DIR="+r.ProjectDir,
"PIGO_EVENT_TYPE="+input.EventType,
)
return env
}
// cappedBuffer is an io.Writer that stores at most limit bytes and counts how
// many it dropped, so hook output cannot exhaust memory (FR-13).
type cappedBuffer struct {
buf bytes.Buffer
limit int
dropped int
}
func (c *cappedBuffer) Write(p []byte) (int, error) {
if c.limit <= 0 {
return c.buf.Write(p)
}
room := c.limit - c.buf.Len()
if room <= 0 {
c.dropped += len(p)
return len(p), nil
}
if len(p) > room {
c.buf.Write(p[:room])
c.dropped += len(p) - room
return len(p), nil
}
return c.buf.Write(p)
}
// Bytes returns the captured (possibly truncated) output.
func (c *cappedBuffer) Bytes() []byte { return c.buf.Bytes() }
// truncated reports whether any output was dropped by the cap.
func (c *cappedBuffer) truncated() bool { return c.dropped > 0 }