first commit
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
// Package hooks implements pigo's user-extensible lifecycle hook system: a
|
||||
// config-driven way to run shell commands at agent lifecycle points (tool
|
||||
// calls, prompt submission, session start/end, etc.) without writing Go or
|
||||
// compiling a plugin. It is a leaf package depending only on the standard
|
||||
// library, so it can be composed into the runtime/cli layers without creating
|
||||
// an import cycle.
|
||||
//
|
||||
// This file defines the configuration types and their validation. A hook is a
|
||||
// single shell command; a matcher binds a group of hooks to an event (and,
|
||||
// for tool events, a tool-name pattern); a HookSet maps each event type to its
|
||||
// matchers. The types carry JSON tags so they can live directly in the layered
|
||||
// config.json.
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DefaultTimeoutSeconds is the per-hook execution timeout when HookConfig.Timeout
|
||||
// is nil (FR-11). A slow or hung hook is killed after this many seconds and
|
||||
// treated as a failure (fail-open for blocking hooks).
|
||||
const DefaultTimeoutSeconds = 60
|
||||
|
||||
// CommandType is the only hook type supported in v1: a shell command. Other
|
||||
// types (embedded script engines, WASM) are explicitly out of scope.
|
||||
const CommandType = "command"
|
||||
|
||||
// HookConfig is a single hook command.
|
||||
type HookConfig struct {
|
||||
Type string `json:"type"` // v1: fixed "command"
|
||||
Command string `json:"command"` // handed to the system shell
|
||||
Timeout *int `json:"timeout,omitempty"` // seconds; nil = DefaultTimeoutSeconds
|
||||
}
|
||||
|
||||
// HookMatcherConfig binds a group of hooks to a matcher. An empty (or "*")
|
||||
// matcher applies to every trigger of the event; otherwise it is matched
|
||||
// against the tool name (see matcher.go).
|
||||
type HookMatcherConfig struct {
|
||||
Matcher string `json:"matcher,omitempty"`
|
||||
Hooks []HookConfig `json:"hooks"`
|
||||
}
|
||||
|
||||
// HookSet maps an event type (e.g. "PreToolUse") to its matcher list. It is
|
||||
// the shape stored in a ConfigLayer and in the resolved Config.
|
||||
type HookSet map[string][]HookMatcherConfig
|
||||
|
||||
// TimeoutSeconds returns the effective timeout for the hook: its own Timeout
|
||||
// when set to a positive value, otherwise DefaultTimeoutSeconds. A non-positive
|
||||
// override is ignored so a misconfigured 0/negative value cannot disable the
|
||||
// timeout guard.
|
||||
func (h HookConfig) TimeoutSeconds() int {
|
||||
if h.Timeout != nil && *h.Timeout > 0 {
|
||||
return *h.Timeout
|
||||
}
|
||||
return DefaultTimeoutSeconds
|
||||
}
|
||||
|
||||
// Validate reports whether the hook is well-formed: the type must be "command"
|
||||
// (empty is accepted and treated as "command" for convenience) and the command
|
||||
// must be non-empty. An invalid hook is rejected at load time and skipped with
|
||||
// a warning rather than executed.
|
||||
func (h HookConfig) Validate() error {
|
||||
if h.Type != "" && h.Type != CommandType {
|
||||
return errors.New("hook type must be \"command\"")
|
||||
}
|
||||
if strings.TrimSpace(h.Command) == "" {
|
||||
return errors.New("hook command must not be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// warnf writes a formatted warning to w when w is non-nil. Hook failures and
|
||||
// misconfigurations are surfaced this way (mirroring plugin.EventNotifier's
|
||||
// warnLog) so a bad hook never interrupts the agent.
|
||||
func warnf(w io.Writer, format string, args ...any) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, format, args...)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package hooks
|
||||
|
||||
import "testing"
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func TestHookConfigValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
h HookConfig
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid command", HookConfig{Type: "command", Command: "echo hi"}, false},
|
||||
{"empty type defaults ok", HookConfig{Command: "echo hi"}, false},
|
||||
{"empty command", HookConfig{Type: "command", Command: ""}, true},
|
||||
{"whitespace command", HookConfig{Type: "command", Command: " "}, true},
|
||||
{"wrong type", HookConfig{Type: "wasm", Command: "echo hi"}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.h.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("Validate() err = %v, wantErr = %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHookConfigTimeoutSeconds(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
h HookConfig
|
||||
want int
|
||||
}{
|
||||
{"nil timeout uses default", HookConfig{}, DefaultTimeoutSeconds},
|
||||
{"positive override", HookConfig{Timeout: ptr(10)}, 10},
|
||||
{"zero override ignored", HookConfig{Timeout: ptr(0)}, DefaultTimeoutSeconds},
|
||||
{"negative override ignored", HookConfig{Timeout: ptr(-5)}, DefaultTimeoutSeconds},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.h.TimeoutSeconds(); got != tt.want {
|
||||
t.Fatalf("TimeoutSeconds() = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// This file implements the Dispatcher: for one event it matches the configured
|
||||
// hooks, runs them in order via the Runner, and merges their outputs into a
|
||||
// single HookDecision. It owns the fail-open policy (a hook that fails to run
|
||||
// is warned about and skipped, never blocking the agent) and the PreToolUse
|
||||
// short-circuit (the first block stops further hooks so a blocked call is not
|
||||
// also rewritten).
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EventPreToolUse is the one event whose first block short-circuits the rest of
|
||||
// the chain. Declared here (rather than importing a shared constants file) so
|
||||
// the hooks package stays a leaf; the string must match what callers dispatch.
|
||||
const EventPreToolUse = "PreToolUse"
|
||||
|
||||
// Dispatcher runs the hooks configured for an event and merges their results.
|
||||
// A nil *Dispatcher is a valid no-op (Dispatch returns an empty decision), so
|
||||
// callers can hold a possibly-nil dispatcher without guarding every call.
|
||||
type Dispatcher struct {
|
||||
set HookSet
|
||||
runner *Runner
|
||||
warnLog io.Writer
|
||||
}
|
||||
|
||||
// NewDispatcher builds a dispatcher over the given hook set. It returns nil when
|
||||
// the set is empty, so the common no-hooks case costs nothing and callers can
|
||||
// treat nil as "hooks disabled" (FR-18). projectDir is where hook commands run;
|
||||
// warnLog receives isolation warnings (may be nil).
|
||||
func NewDispatcher(set HookSet, projectDir string, warnLog io.Writer) *Dispatcher {
|
||||
if len(set) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &Dispatcher{
|
||||
set: set,
|
||||
runner: &Runner{ProjectDir: projectDir, WarnLog: warnLog},
|
||||
warnLog: warnLog,
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch runs every hook matching (eventType, toolName) in order and returns
|
||||
// the merged decision. On a nil dispatcher or no matched hooks it returns the
|
||||
// zero HookDecision. A hook that fails to run is warned about and skipped
|
||||
// (fail-open, FR-15). For PreToolUse the first block stops the chain so a
|
||||
// blocked call is not subsequently rewritten.
|
||||
func (d *Dispatcher) Dispatch(ctx context.Context, eventType, toolName string, input HookInput) HookDecision {
|
||||
var dec HookDecision
|
||||
if d == nil {
|
||||
return dec
|
||||
}
|
||||
matched := d.set.MatchHooks(eventType, toolName, d.warnLog)
|
||||
for _, h := range matched {
|
||||
out, err := d.runner.Run(ctx, h, input)
|
||||
if err != nil {
|
||||
warnf(d.warnLog, "pigo: hooks: %s: %v\n", eventType, err)
|
||||
continue // fail-open
|
||||
}
|
||||
if out.blocks() {
|
||||
dec.Block = true
|
||||
dec.Reason = joinNonEmpty(dec.Reason, out.Reason, "\n")
|
||||
}
|
||||
if out.AdditionalContext != "" {
|
||||
dec.AdditionalContext = joinNonEmpty(dec.AdditionalContext, out.AdditionalContext, "\n")
|
||||
}
|
||||
if len(out.UpdatedInput) > 0 {
|
||||
dec.UpdatedInput = out.UpdatedInput // last writer wins (§5.4)
|
||||
}
|
||||
if dec.Block && eventType == EventPreToolUse {
|
||||
break // blocked tool call is not also rewritten
|
||||
}
|
||||
}
|
||||
return dec
|
||||
}
|
||||
|
||||
// joinNonEmpty joins a and b with sep, dropping empty operands so the result
|
||||
// never has a leading or dangling separator.
|
||||
func joinNonEmpty(a, b, sep string) string {
|
||||
a = strings.TrimSpace(a)
|
||||
b = strings.TrimSpace(b)
|
||||
switch {
|
||||
case a == "":
|
||||
return b
|
||||
case b == "":
|
||||
return a
|
||||
default:
|
||||
return a + sep + b
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewDispatcherNilOnEmpty(t *testing.T) {
|
||||
if d := NewDispatcher(nil, "/tmp", nil); d != nil {
|
||||
t.Fatal("expected nil dispatcher for empty set")
|
||||
}
|
||||
if d := NewDispatcher(HookSet{}, "/tmp", nil); d != nil {
|
||||
t.Fatal("expected nil dispatcher for empty set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilDispatcherDispatchIsNoOp(t *testing.T) {
|
||||
var d *Dispatcher
|
||||
dec := d.Dispatch(context.Background(), "PreToolUse", "bash", HookInput{})
|
||||
if dec.Block || dec.AdditionalContext != "" || dec.UpdatedInput != nil {
|
||||
t.Fatalf("expected empty decision, got %+v", dec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchMergesContext(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("sh -c not available on windows")
|
||||
}
|
||||
set := HookSet{
|
||||
"PostToolUse": {
|
||||
{Matcher: "*", Hooks: []HookConfig{
|
||||
{Command: `echo '{"additionalContext":"first"}'`},
|
||||
{Command: `echo '{"additionalContext":"second"}'`},
|
||||
}},
|
||||
},
|
||||
}
|
||||
d := NewDispatcher(set, t.TempDir(), nil)
|
||||
dec := d.Dispatch(context.Background(), "PostToolUse", "write", HookInput{})
|
||||
if dec.AdditionalContext != "first\nsecond" {
|
||||
t.Fatalf("expected merged context, got %q", dec.AdditionalContext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchPreToolUseFirstBlockShortCircuits(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("sh -c not available on windows")
|
||||
}
|
||||
set := HookSet{
|
||||
"PreToolUse": {
|
||||
{Matcher: "*", Hooks: []HookConfig{
|
||||
{Command: `echo "stop" >&2; exit 2`},
|
||||
{Command: `echo '{"updatedInput":{"changed":true}}'`},
|
||||
}},
|
||||
},
|
||||
}
|
||||
d := NewDispatcher(set, t.TempDir(), nil)
|
||||
dec := d.Dispatch(context.Background(), "PreToolUse", "bash", HookInput{})
|
||||
if !dec.Block {
|
||||
t.Fatal("expected block")
|
||||
}
|
||||
if dec.UpdatedInput != nil {
|
||||
t.Fatalf("expected short-circuit before rewrite, got %s", dec.UpdatedInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchFailOpen(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("sh -c not available on windows")
|
||||
}
|
||||
set := HookSet{
|
||||
"PreToolUse": {
|
||||
{Matcher: "*", Hooks: []HookConfig{
|
||||
{Command: `exit 1`},
|
||||
{Command: `echo '{"additionalContext":"survived"}'`},
|
||||
}},
|
||||
},
|
||||
}
|
||||
var warn bytes.Buffer
|
||||
d := NewDispatcher(set, t.TempDir(), &warn)
|
||||
dec := d.Dispatch(context.Background(), "PreToolUse", "bash", HookInput{})
|
||||
if dec.Block {
|
||||
t.Fatal("failed hook must not block (fail-open)")
|
||||
}
|
||||
if dec.AdditionalContext != "survived" {
|
||||
t.Fatalf("expected later hook to still run, got %q", dec.AdditionalContext)
|
||||
}
|
||||
if !strings.Contains(warn.String(), "PreToolUse") {
|
||||
t.Fatalf("expected failure warning, got %q", warn.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchUpdatedInputLastWins(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("sh -c not available on windows")
|
||||
}
|
||||
set := HookSet{
|
||||
"PreToolUse": {
|
||||
{Matcher: "*", Hooks: []HookConfig{
|
||||
{Command: `echo '{"updatedInput":{"n":1}}'`},
|
||||
{Command: `echo '{"updatedInput":{"n":2}}'`},
|
||||
}},
|
||||
},
|
||||
}
|
||||
d := NewDispatcher(set, t.TempDir(), nil)
|
||||
dec := d.Dispatch(context.Background(), "PreToolUse", "bash", HookInput{})
|
||||
if string(dec.UpdatedInput) != `{"n":2}` {
|
||||
t.Fatalf("expected last writer wins, got %s", dec.UpdatedInput)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// This file implements hook matching: given an event type and (for tool
|
||||
// events) a tool name, MatchHooks returns the flat, ordered list of hooks that
|
||||
// should run. Matcher semantics follow Claude Code to keep the learning curve
|
||||
// low: empty or "*" matches all, an exact tool name matches only that tool, a
|
||||
// "|"-separated list matches any listed tool, and anything else is compiled as
|
||||
// a Go regexp against the tool name.
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MatchHooks returns every valid hook under eventType whose matcher matches
|
||||
// toolName, preserving config order (layer order + declaration order within a
|
||||
// layer). For events that do not carry a tool name (toolName == ""), matchers
|
||||
// are ignored and all hooks under the event fire. Invalid hooks (see
|
||||
// HookConfig.Validate) are skipped; a matcher whose regexp fails to compile is
|
||||
// skipped with a warning on warnLog (when non-nil).
|
||||
func (s HookSet) MatchHooks(eventType, toolName string, warnLog io.Writer) []HookConfig {
|
||||
matchers := s[eventType]
|
||||
if len(matchers) == 0 {
|
||||
return nil
|
||||
}
|
||||
var out []HookConfig
|
||||
for _, m := range matchers {
|
||||
if !matcherApplies(m.Matcher, toolName, warnLog) {
|
||||
continue
|
||||
}
|
||||
for _, h := range m.Hooks {
|
||||
if err := h.Validate(); err != nil {
|
||||
warnf(warnLog, "pigo: hooks: skipping invalid hook: %v\n", err)
|
||||
continue
|
||||
}
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// matcherApplies reports whether a matcher pattern matches the given tool name.
|
||||
// An empty tool name (event without a tool) always matches, so tool-agnostic
|
||||
// events fire every hook regardless of matcher.
|
||||
func matcherApplies(pattern, toolName string, warnLog io.Writer) bool {
|
||||
if toolName == "" {
|
||||
return true
|
||||
}
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
if pattern == "" || pattern == "*" {
|
||||
return true
|
||||
}
|
||||
// "|"-separated multi-value: any exact tool name matches. This also covers
|
||||
// the single-exact-name case (no "|").
|
||||
parts := strings.Split(pattern, "|")
|
||||
exactCandidate := true
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == toolName {
|
||||
return true
|
||||
}
|
||||
if !isPlainToolName(p) {
|
||||
exactCandidate = false
|
||||
}
|
||||
}
|
||||
// If every alternative was a plain tool name, this was an exact/multi-value
|
||||
// matcher that simply did not match — do not fall through to regexp.
|
||||
if exactCandidate {
|
||||
return false
|
||||
}
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
warnf(warnLog, "pigo: hooks: skipping matcher with invalid regexp %q: %v\n", pattern, err)
|
||||
return false
|
||||
}
|
||||
return re.MatchString(toolName)
|
||||
}
|
||||
|
||||
// isPlainToolName reports whether s looks like a literal tool name rather than
|
||||
// a regexp — i.e. it contains no regexp metacharacters. Used to decide whether
|
||||
// a "|"-split alternative should be treated as an exact match or as part of a
|
||||
// regexp alternation.
|
||||
func isPlainToolName(s string) bool {
|
||||
return !strings.ContainsAny(s, ".*+?()[]{}^$\\")
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMatchHooks(t *testing.T) {
|
||||
set := HookSet{
|
||||
"PreToolUse": {
|
||||
{Matcher: "", Hooks: []HookConfig{{Command: "all"}}},
|
||||
{Matcher: "*", Hooks: []HookConfig{{Command: "star"}}},
|
||||
{Matcher: "bash", Hooks: []HookConfig{{Command: "bash-only"}}},
|
||||
{Matcher: "write|edit", Hooks: []HookConfig{{Command: "write-or-edit"}}},
|
||||
{Matcher: "Edit.*", Hooks: []HookConfig{{Command: "regex-edit"}}},
|
||||
},
|
||||
"SessionStart": {
|
||||
{Matcher: "ignored", Hooks: []HookConfig{{Command: "session"}}},
|
||||
},
|
||||
}
|
||||
|
||||
commands := func(hs []HookConfig) []string {
|
||||
out := make([]string, len(hs))
|
||||
for i, h := range hs {
|
||||
out[i] = h.Command
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
t.Run("bash matches empty, star, exact", func(t *testing.T) {
|
||||
got := commands(set.MatchHooks("PreToolUse", "bash", nil))
|
||||
want := []string{"all", "star", "bash-only"}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("write matches multi-value", func(t *testing.T) {
|
||||
got := commands(set.MatchHooks("PreToolUse", "write", nil))
|
||||
if !contains(got, "write-or-edit") {
|
||||
t.Fatalf("expected write-or-edit in %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("EditFile matches regex not exact", func(t *testing.T) {
|
||||
got := commands(set.MatchHooks("PreToolUse", "EditFile", nil))
|
||||
if !contains(got, "regex-edit") {
|
||||
t.Fatalf("expected regex-edit in %v", got)
|
||||
}
|
||||
if contains(got, "bash-only") {
|
||||
t.Fatalf("did not expect bash-only in %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no-tool-name event ignores matcher", func(t *testing.T) {
|
||||
got := commands(set.MatchHooks("SessionStart", "", nil))
|
||||
want := []string{"session"}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown event returns nil", func(t *testing.T) {
|
||||
if got := set.MatchHooks("Nope", "bash", nil); got != nil {
|
||||
t.Fatalf("expected nil, got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMatchHooksInvalidRegexSkipped(t *testing.T) {
|
||||
set := HookSet{
|
||||
"PreToolUse": {
|
||||
{Matcher: "(unterminated", Hooks: []HookConfig{{Command: "bad"}}},
|
||||
{Matcher: "bash", Hooks: []HookConfig{{Command: "good"}}},
|
||||
},
|
||||
}
|
||||
var warn bytes.Buffer
|
||||
got := set.MatchHooks("PreToolUse", "bash", &warn)
|
||||
if len(got) != 1 || got[0].Command != "good" {
|
||||
t.Fatalf("expected only good hook, got %v", got)
|
||||
}
|
||||
if !strings.Contains(warn.String(), "invalid regexp") {
|
||||
t.Fatalf("expected regexp warning, got %q", warn.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchHooksSkipsInvalidHook(t *testing.T) {
|
||||
set := HookSet{
|
||||
"PreToolUse": {
|
||||
{Matcher: "*", Hooks: []HookConfig{{Command: ""}, {Command: "ok"}}},
|
||||
},
|
||||
}
|
||||
var warn bytes.Buffer
|
||||
got := set.MatchHooks("PreToolUse", "bash", &warn)
|
||||
if len(got) != 1 || got[0].Command != "ok" {
|
||||
t.Fatalf("expected only ok hook, got %v", got)
|
||||
}
|
||||
if !strings.Contains(warn.String(), "invalid hook") {
|
||||
t.Fatalf("expected invalid-hook warning, got %q", warn.String())
|
||||
}
|
||||
}
|
||||
|
||||
func contains(xs []string, s string) bool {
|
||||
for _, x := range xs {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// This file bridges the agent's event stream to observer-only hooks (US-011/012/
|
||||
// 013, FR-1). Where PreToolUse/UserPromptSubmit/Stop are decision hooks wired at
|
||||
// dedicated seams, SessionEnd/PreCompact/Notification only observe: the agent
|
||||
// emits lifecycle events, HookNotifier maps each to a HookInput and fires the
|
||||
// matching hooks via the Dispatcher, discarding the decision (an observer hook
|
||||
// cannot block).
|
||||
//
|
||||
// It mirrors plugin.EventNotifier: created once per run, its Handle method is
|
||||
// wired as an event-stream OnEvent callback and coexists with the plugin
|
||||
// notifier (RunConfig.OnEvent already chains multiple observers). A nil
|
||||
// *Dispatcher makes NewHookNotifier return nil, and every method is a no-op on a
|
||||
// nil receiver, so callers can wire it unconditionally.
|
||||
//
|
||||
// Session id and project dir are fixed for a run, so they are captured at
|
||||
// construction rather than read from each event (AgentEndEvent/CompactionEvent
|
||||
// do not carry them).
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// HookNotifier forwards agent lifecycle events to observer-only hooks. Handle
|
||||
// maps AgentEndEvent→SessionEnd and CompactionEvent→PreCompact; Notify emits a
|
||||
// Notification event for out-of-band prompts (e.g. a trust confirmation). The
|
||||
// merged decision is intentionally discarded — these events have no in-flight
|
||||
// action to veto.
|
||||
type HookNotifier struct {
|
||||
d *Dispatcher
|
||||
sessionID string
|
||||
projectDir string
|
||||
}
|
||||
|
||||
// NewHookNotifier returns a notifier over d, or nil when d is nil (no hooks) so
|
||||
// the caller can skip the OnEvent wiring entirely. sessionID and projectDir
|
||||
// populate every emitted HookInput.
|
||||
func NewHookNotifier(d *Dispatcher, sessionID, projectDir string) *HookNotifier {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
return &HookNotifier{d: d, sessionID: sessionID, projectDir: projectDir}
|
||||
}
|
||||
|
||||
// Handle maps an observed event to its observer hook and fires it. Events with
|
||||
// no observer mapping (turn/message/tool events) are ignored. It is a no-op on a
|
||||
// nil notifier, so it can be chained onto OnEvent unconditionally.
|
||||
func (n *HookNotifier) Handle(ev agentcore.AgentEvent) {
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
switch e := ev.(type) {
|
||||
case agentcore.AgentEndEvent:
|
||||
n.dispatch("SessionEnd", HookInput{
|
||||
EventType: "SessionEnd",
|
||||
SessionID: n.sessionID,
|
||||
ProjectDir: n.projectDir,
|
||||
StopReason: sessionEndReason(e.Messages),
|
||||
})
|
||||
case agentcore.CompactionEvent:
|
||||
n.dispatch("PreCompact", HookInput{
|
||||
EventType: "PreCompact",
|
||||
SessionID: n.sessionID,
|
||||
ProjectDir: n.projectDir,
|
||||
Trigger: compactionTrigger(e.Reason),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Notify fires the Notification event with a human-readable message. It is used
|
||||
// for out-of-band prompts that are not part of the event stream, such as a trust
|
||||
// confirmation for an untrusted-directory bash/write. A no-op on a nil notifier
|
||||
// or an empty message.
|
||||
func (n *HookNotifier) Notify(message string) {
|
||||
if n == nil || message == "" {
|
||||
return
|
||||
}
|
||||
n.dispatch("Notification", HookInput{
|
||||
EventType: "Notification",
|
||||
SessionID: n.sessionID,
|
||||
ProjectDir: n.projectDir,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// dispatch fires the hooks for an observer event and discards the decision.
|
||||
func (n *HookNotifier) dispatch(event string, input HookInput) {
|
||||
n.d.Dispatch(context.Background(), event, "", input)
|
||||
}
|
||||
|
||||
// sessionEndReason derives the SessionEnd reason from the run's terminal
|
||||
// assistant message: "error"/"aborted" pass through, everything else (end_turn/
|
||||
// tool_use/length or no assistant message) is a "natural" end.
|
||||
func sessionEndReason(msgs []agentcore.AgentMessage) string {
|
||||
for i := len(msgs) - 1; i >= 0; i-- {
|
||||
if am, ok := msgs[i].(agentcore.AssistantMessage); ok {
|
||||
switch am.StopReason {
|
||||
case agentcore.StopReasonError:
|
||||
return "error"
|
||||
case agentcore.StopReasonAborted:
|
||||
return "aborted"
|
||||
default:
|
||||
return "natural"
|
||||
}
|
||||
}
|
||||
}
|
||||
return "natural"
|
||||
}
|
||||
|
||||
// compactionTrigger maps a CompactionEvent.Reason to the PreCompact trigger:
|
||||
// "manual" stays manual; "threshold"/"overflow" (and anything else) are "auto".
|
||||
func compactionTrigger(reason string) string {
|
||||
if reason == "manual" {
|
||||
return "manual"
|
||||
}
|
||||
return "auto"
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// captureNotifier builds a HookNotifier whose hook for `event` appends its stdin
|
||||
// payload to a file, returning the notifier and the capture-file path so a test
|
||||
// can assert on the payload the hook received.
|
||||
func captureNotifier(t *testing.T, event string) (*HookNotifier, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "capture.json")
|
||||
set := HookSet{
|
||||
event: {{Matcher: "*", Hooks: []HookConfig{{Command: "cat >> " + out}}}},
|
||||
}
|
||||
d := NewDispatcher(set, dir, nil)
|
||||
if d == nil {
|
||||
t.Fatal("expected non-nil dispatcher")
|
||||
}
|
||||
return NewHookNotifier(d, "sess-1", dir), out
|
||||
}
|
||||
|
||||
func readCapture(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("capture not written: %v", err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// TestSessionEndNaturalReason: an end_turn terminal message yields reason
|
||||
// "natural"; an aborted terminal message yields "aborted".
|
||||
func TestSessionEndReason(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
stop string
|
||||
expect string
|
||||
}{
|
||||
{"natural", agentcore.StopReasonEndTurn, `"stop_reason":"natural"`},
|
||||
{"aborted", agentcore.StopReasonAborted, `"stop_reason":"aborted"`},
|
||||
{"error", agentcore.StopReasonError, `"stop_reason":"error"`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
n, out := captureNotifier(t, "SessionEnd")
|
||||
n.Handle(agentcore.AgentEndEvent{Messages: []agentcore.AgentMessage{
|
||||
agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, StopReason: tc.stop},
|
||||
}})
|
||||
if got := readCapture(t, out); !strings.Contains(got, tc.expect) {
|
||||
t.Fatalf("SessionEnd reason: want %q in %q", tc.expect, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreCompactTrigger: a manual CompactionEvent maps to trigger "manual";
|
||||
// threshold/overflow map to "auto".
|
||||
func TestPreCompactTrigger(t *testing.T) {
|
||||
cases := []struct {
|
||||
reason string
|
||||
expect string
|
||||
}{
|
||||
{"manual", `"trigger":"manual"`},
|
||||
{"threshold", `"trigger":"auto"`},
|
||||
{"overflow", `"trigger":"auto"`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.reason, func(t *testing.T) {
|
||||
n, out := captureNotifier(t, "PreCompact")
|
||||
n.Handle(agentcore.CompactionEvent{Reason: tc.reason})
|
||||
if got := readCapture(t, out); !strings.Contains(got, tc.expect) {
|
||||
t.Fatalf("PreCompact trigger: want %q in %q", tc.expect, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotification: Notify fires the Notification event carrying the message.
|
||||
func TestNotification(t *testing.T) {
|
||||
n, out := captureNotifier(t, "Notification")
|
||||
n.Notify("approve bash in untrusted dir?")
|
||||
got := readCapture(t, out)
|
||||
if !strings.Contains(got, `"event_type":"Notification"`) || !strings.Contains(got, "approve bash in untrusted dir?") {
|
||||
t.Fatalf("Notification payload missing message, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifierNilSafe: a nil dispatcher yields a nil notifier whose methods are
|
||||
// safe no-ops; an empty Notify message is dropped.
|
||||
func TestNotifierNilSafe(t *testing.T) {
|
||||
if n := NewHookNotifier(nil, "s", "d"); n != nil {
|
||||
t.Fatal("nil dispatcher must yield a nil notifier")
|
||||
}
|
||||
var n *HookNotifier
|
||||
n.Handle(agentcore.AgentEndEvent{})
|
||||
n.Notify("x") // must not panic
|
||||
|
||||
// A live notifier with an empty message writes nothing.
|
||||
live, out := captureNotifier(t, "Notification")
|
||||
live.Notify("")
|
||||
if _, err := os.Stat(out); !os.IsNotExist(err) {
|
||||
t.Fatal("empty Notify message must not fire the hook")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifierIgnoresUnmappedEvents: turn/message/tool events have no observer
|
||||
// mapping and must not fire any hook.
|
||||
func TestNotifierIgnoresUnmappedEvents(t *testing.T) {
|
||||
n, out := captureNotifier(t, "SessionEnd")
|
||||
n.Handle(agentcore.TurnStartEvent{})
|
||||
n.Handle(agentcore.ToolExecutionStartEvent{ToolName: "bash"})
|
||||
if _, err := os.Stat(out); !os.IsNotExist(err) {
|
||||
t.Fatal("unmapped events must not fire the SessionEnd hook")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// This file defines the process contract between pigo and a user hook command:
|
||||
// what pigo writes to the hook's stdin (HookInput), what pigo reads back from
|
||||
// its stdout (HookOutput), and the internal merged decision the dispatcher
|
||||
// produces (HookDecision). It also defines the exit-code semantics parsing.
|
||||
//
|
||||
// The wire contract follows Claude Code: exit 0 = allow, exit 2 = block (with
|
||||
// stderr as the reason), any other non-zero = execution failure. On exit 0 a
|
||||
// well-formed JSON stdout is parsed as HookOutput; a non-JSON stdout is a no-op.
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HookInput is the JSON payload pigo writes to a hook command's stdin. It
|
||||
// carries only observable, non-secret fields (FR-17) — never API keys or
|
||||
// credentials. Per-event fields are omitempty so a payload only contains the
|
||||
// fields relevant to its event type.
|
||||
type HookInput struct {
|
||||
EventType string `json:"event_type"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
ProjectDir string `json:"project_dir,omitempty"`
|
||||
ToolName string `json:"tool_name,omitempty"` // Pre/PostToolUse
|
||||
ToolInput json.RawMessage `json:"tool_input,omitempty"` // Pre/PostToolUse
|
||||
ToolResponse json.RawMessage `json:"tool_response,omitempty"` // PostToolUse
|
||||
Prompt string `json:"prompt,omitempty"` // UserPromptSubmit
|
||||
StopReason string `json:"stop_reason,omitempty"` // Stop/SessionEnd
|
||||
Source string `json:"source,omitempty"` // SessionStart (startup/resume)
|
||||
Trigger string `json:"trigger,omitempty"` // PreCompact (manual/auto)
|
||||
Message string `json:"message,omitempty"` // Notification
|
||||
}
|
||||
|
||||
// HookOutput is the optional JSON a hook may print to stdout to influence the
|
||||
// agent. A non-JSON stdout on exit 0 is treated as an empty HookOutput (no
|
||||
// operation). Decision "block" is equivalent to exiting with code 2.
|
||||
type HookOutput struct {
|
||||
Decision string `json:"decision,omitempty"` // "block" | "approve" | ""
|
||||
Reason string `json:"reason,omitempty"`
|
||||
AdditionalContext string `json:"additionalContext,omitempty"`
|
||||
Continue *bool `json:"continue,omitempty"`
|
||||
UpdatedInput json.RawMessage `json:"updatedInput,omitempty"` // PreToolUse: rewrite tool args
|
||||
}
|
||||
|
||||
// blocks reports whether this output requests a block. A decision of "block"
|
||||
// blocks; an explicit continue=false also blocks. "approve" and the empty
|
||||
// decision allow.
|
||||
func (o HookOutput) blocks() bool {
|
||||
if strings.EqualFold(o.Decision, "block") {
|
||||
return true
|
||||
}
|
||||
if o.Continue != nil && !*o.Continue {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HookDecision is the dispatcher's merged result after running all matched
|
||||
// hooks for one event. Block is set if any hook blocked; Reason accumulates the
|
||||
// blocking reasons; AdditionalContext accumulates injected context in order;
|
||||
// UpdatedInput holds the last-provided rewrite (last writer wins, §5.4).
|
||||
type HookDecision struct {
|
||||
Block bool
|
||||
Reason string
|
||||
AdditionalContext string
|
||||
UpdatedInput json.RawMessage
|
||||
}
|
||||
|
||||
// parseHookOutput parses a hook's stdout into a HookOutput. On exit 0 a
|
||||
// non-JSON body is a no-op (returns the zero value, ok=false). Empty/whitespace
|
||||
// stdout is also a no-op. A valid JSON object is parsed and ok is true.
|
||||
func parseHookOutput(stdout []byte) (HookOutput, bool) {
|
||||
trimmed := strings.TrimSpace(string(stdout))
|
||||
if trimmed == "" {
|
||||
return HookOutput{}, false
|
||||
}
|
||||
var out HookOutput
|
||||
if err := json.Unmarshal([]byte(trimmed), &out); err != nil {
|
||||
return HookOutput{}, false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseHookOutput(t *testing.T) {
|
||||
t.Run("empty is no-op", func(t *testing.T) {
|
||||
if _, ok := parseHookOutput([]byte(" ")); ok {
|
||||
t.Fatal("expected ok=false for empty")
|
||||
}
|
||||
})
|
||||
t.Run("non-json is no-op", func(t *testing.T) {
|
||||
if _, ok := parseHookOutput([]byte("just some text")); ok {
|
||||
t.Fatal("expected ok=false for non-json")
|
||||
}
|
||||
})
|
||||
t.Run("valid decision block", func(t *testing.T) {
|
||||
out, ok := parseHookOutput([]byte(`{"decision":"block","reason":"nope"}`))
|
||||
if !ok || !out.blocks() || out.Reason != "nope" {
|
||||
t.Fatalf("unexpected: ok=%v out=%+v", ok, out)
|
||||
}
|
||||
})
|
||||
t.Run("additionalContext", func(t *testing.T) {
|
||||
out, ok := parseHookOutput([]byte(`{"additionalContext":"extra"}`))
|
||||
if !ok || out.AdditionalContext != "extra" {
|
||||
t.Fatalf("unexpected: ok=%v out=%+v", ok, out)
|
||||
}
|
||||
})
|
||||
t.Run("updatedInput preserved as raw", func(t *testing.T) {
|
||||
out, ok := parseHookOutput([]byte(`{"updatedInput":{"a":1}}`))
|
||||
if !ok || string(out.UpdatedInput) != `{"a":1}` {
|
||||
t.Fatalf("unexpected: ok=%v raw=%s", ok, out.UpdatedInput)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHookInputNoSecretFields(t *testing.T) {
|
||||
// A fully-populated payload must never carry credential-like keys: the
|
||||
// struct is a whitelist, so marshaling can only emit its declared fields.
|
||||
in := HookInput{
|
||||
EventType: "PreToolUse", SessionID: "s", ProjectDir: "/p", ToolName: "bash",
|
||||
ToolInput: json.RawMessage(`{"cmd":"ls"}`), Prompt: "hi", Message: "m",
|
||||
}
|
||||
data, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
forbidden := []string{"api_key", "apikey", "token", "credential", "password", "secret", "authorization"}
|
||||
lower := strings.ToLower(string(data))
|
||||
for _, k := range forbidden {
|
||||
if strings.Contains(lower, k) {
|
||||
t.Fatalf("payload contains forbidden key %q: %s", k, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHookOutputBlocks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
out HookOutput
|
||||
want bool
|
||||
}{
|
||||
{"decision block", HookOutput{Decision: "block"}, true},
|
||||
{"decision BLOCK case-insensitive", HookOutput{Decision: "BLOCK"}, true},
|
||||
{"decision approve", HookOutput{Decision: "approve"}, false},
|
||||
{"empty decision", HookOutput{}, false},
|
||||
{"continue false blocks", HookOutput{Continue: ptr(false)}, true},
|
||||
{"continue true allows", HookOutput{Continue: ptr(true)}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.out.blocks(); got != tt.want {
|
||||
t.Fatalf("blocks() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// 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 }
|
||||
@@ -0,0 +1,130 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRunnerRunStdinAndEnv(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("sh -c not available on windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
outFile := filepath.Join(dir, "captured.json")
|
||||
envFile := filepath.Join(dir, "env.txt")
|
||||
|
||||
r := &Runner{ProjectDir: dir}
|
||||
h := HookConfig{Command: "cat > " + outFile + "; printf '%s\\n%s\\n%s\\n' \"$PIGO_SESSION_ID\" \"$PIGO_PROJECT_DIR\" \"$PIGO_EVENT_TYPE\" > " + envFile}
|
||||
input := HookInput{EventType: "PreToolUse", SessionID: "sess-1", ProjectDir: dir, ToolName: "bash"}
|
||||
|
||||
if _, err := r.Run(context.Background(), h, input); err != nil {
|
||||
t.Fatalf("Run() error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("read captured stdin: %v", err)
|
||||
}
|
||||
var got HookInput
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("stdin was not valid JSON: %v (%s)", err, data)
|
||||
}
|
||||
if got.EventType != "PreToolUse" || got.SessionID != "sess-1" || got.ToolName != "bash" {
|
||||
t.Fatalf("unexpected decoded stdin: %+v", got)
|
||||
}
|
||||
|
||||
envData, _ := os.ReadFile(envFile)
|
||||
lines := strings.Split(strings.TrimSpace(string(envData)), "\n")
|
||||
if len(lines) != 3 || lines[0] != "sess-1" || lines[1] != dir || lines[2] != "PreToolUse" {
|
||||
t.Fatalf("unexpected env: %v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerExitCodes(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("sh -c not available on windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
r := &Runner{ProjectDir: dir}
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("exit 0 with json", func(t *testing.T) {
|
||||
out, err := r.Run(ctx, HookConfig{Command: `echo '{"additionalContext":"hi"}'`}, HookInput{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if out.AdditionalContext != "hi" {
|
||||
t.Fatalf("unexpected out: %+v", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exit 0 non-json is no-op", func(t *testing.T) {
|
||||
out, err := r.Run(ctx, HookConfig{Command: `echo hello world`}, HookInput{})
|
||||
if err != nil || out.blocks() || out.AdditionalContext != "" {
|
||||
t.Fatalf("expected no-op, got out=%+v err=%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exit 2 blocks with stderr reason", func(t *testing.T) {
|
||||
out, err := r.Run(ctx, HookConfig{Command: `echo "denied" >&2; exit 2`}, HookInput{})
|
||||
if err != nil {
|
||||
t.Fatalf("exit 2 should not be an error, got %v", err)
|
||||
}
|
||||
if !out.blocks() || out.Reason != "denied" {
|
||||
t.Fatalf("unexpected out: %+v", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exit 1 is failure", func(t *testing.T) {
|
||||
_, err := r.Run(ctx, HookConfig{Command: `echo boom >&2; exit 1`}, HookInput{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for exit 1")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("command not found is failure", func(t *testing.T) {
|
||||
_, err := r.Run(ctx, HookConfig{Command: `this-command-does-not-exist-pigo`}, HookInput{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing command")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunnerTimeout(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("sh -c not available on windows")
|
||||
}
|
||||
r := &Runner{ProjectDir: t.TempDir()}
|
||||
start := time.Now()
|
||||
_, err := r.Run(context.Background(), HookConfig{Command: "sleep 5", Timeout: ptr(1)}, HookInput{})
|
||||
if err == nil || !strings.Contains(err.Error(), "timed out") {
|
||||
t.Fatalf("expected timeout error, got %v", err)
|
||||
}
|
||||
if time.Since(start) > 3*time.Second {
|
||||
t.Fatalf("timeout took too long: %v", time.Since(start))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerOutputCap(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("sh -c not available on windows")
|
||||
}
|
||||
var buf cappedBuffer
|
||||
buf.limit = 10
|
||||
n, _ := buf.Write([]byte("0123456789abcdef"))
|
||||
if n != 16 {
|
||||
t.Fatalf("Write should report full length, got %d", n)
|
||||
}
|
||||
if len(buf.Bytes()) != 10 {
|
||||
t.Fatalf("expected 10 bytes retained, got %d", len(buf.Bytes()))
|
||||
}
|
||||
if !buf.truncated() {
|
||||
t.Fatal("expected truncated to be true")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user