// 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 }