first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+83
View File
@@ -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...)
}
+47
View File
@@ -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)
}
})
}
}
+91
View File
@@ -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
}
}
+112
View File
@@ -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)
}
}
+85
View File
@@ -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, ".*+?()[]{}^$\\")
}
+111
View File
@@ -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
}
+118
View File
@@ -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"
}
+122
View File
@@ -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")
}
}
+82
View File
@@ -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
}
+80
View File
@@ -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)
}
})
}
}
+162
View File
@@ -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 }
+130
View File
@@ -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")
}
}