84 lines
3.2 KiB
Go
84 lines
3.2 KiB
Go
// 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...)
|
|
}
|