docs: M8.1 hook system design spec
This commit is contained in:
406
docs/superpowers/specs/2026-04-06-m8-hooks-design.md
Normal file
406
docs/superpowers/specs/2026-04-06-m8-hooks-design.md
Normal file
@@ -0,0 +1,406 @@
|
||||
# M8.1 Hook System — Design Spec
|
||||
|
||||
## Context
|
||||
|
||||
M8 (Extensibility) is gnoma's next milestone after M7 (Elfs). It covers hooks, skills, MCP client, and plugins. This spec covers the first sub-project: **the hook system** — an event-driven extension mechanism that lets users run shell commands, LLM prompts, or elfs in response to engine lifecycle events.
|
||||
|
||||
Hooks enable policy enforcement (block dangerous bash commands), observability (log all tool calls), and transformation (rewrite tool args or results) without modifying gnoma's core code.
|
||||
|
||||
**Depends on:** M7 (complete). No dependency on other M8 sub-projects.
|
||||
**Enables:** Skills, MCP, and plugins will use hooks for lifecycle integration.
|
||||
|
||||
---
|
||||
|
||||
## 1. Core Types
|
||||
|
||||
**Package:** `internal/hook/`
|
||||
|
||||
### EventType
|
||||
|
||||
```
|
||||
PreToolUse — before tool execution; can deny or transform args
|
||||
PostToolUse — after tool execution; can transform result (deny treated as skip)
|
||||
SessionStart — session begins (TUI launch or pipe mode start)
|
||||
SessionEnd — session ends (quit, Ctrl-C, pipe completes)
|
||||
PreCompact — before context compaction
|
||||
Stop — engine stop signal (max turns, user abort)
|
||||
```
|
||||
|
||||
### CommandType
|
||||
|
||||
```
|
||||
Command — run a shell command; stdin/stdout JSON protocol
|
||||
Prompt — send a prompt to an LLM; use response as hook result
|
||||
Agent — spawn an elf; use its output as hook result
|
||||
```
|
||||
|
||||
### HookDef
|
||||
|
||||
Parsed from config. Drives handler construction.
|
||||
|
||||
```go
|
||||
type HookDef struct {
|
||||
Name string
|
||||
Event EventType
|
||||
Command CommandType
|
||||
Exec string // shell command, prompt template, or elf prompt
|
||||
Timeout time.Duration // default 30s
|
||||
FailOpen bool // true = allow on timeout/error; false = deny
|
||||
ToolPattern string // glob for tool name filtering (PreToolUse/PostToolUse only)
|
||||
}
|
||||
```
|
||||
|
||||
### HookResult
|
||||
|
||||
```go
|
||||
type HookResult struct {
|
||||
Action Action // Allow, Deny, Skip
|
||||
Output string // transformed payload (empty = no transform)
|
||||
Error error
|
||||
Duration time.Duration
|
||||
}
|
||||
```
|
||||
|
||||
### Action
|
||||
|
||||
```
|
||||
Allow — exit 0; hook approves
|
||||
Deny — exit 2; hook rejects
|
||||
Skip — exit 1; hook abstains (doesn't count as deny)
|
||||
```
|
||||
|
||||
### ToolPattern
|
||||
|
||||
`ToolPattern` uses `filepath.Match` glob semantics (consistent with permission rules). A PreToolUse hook with `tool_pattern = "bash*"` fires only for bash tool calls. Empty = all tools.
|
||||
|
||||
---
|
||||
|
||||
## 2. Dispatcher
|
||||
|
||||
### Structure
|
||||
|
||||
```go
|
||||
type Dispatcher struct {
|
||||
chains map[EventType][]Handler
|
||||
router *router.Router // for prompt/agent hook types
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
def HookDef
|
||||
executor Executor
|
||||
}
|
||||
```
|
||||
|
||||
### Executor Interface
|
||||
|
||||
```go
|
||||
type Executor interface {
|
||||
Execute(ctx context.Context, payload []byte) (HookResult, error)
|
||||
}
|
||||
```
|
||||
|
||||
Three implementations:
|
||||
- **CommandExecutor** — `os/exec` with stdin JSON pipe, reads stdout JSON, maps exit codes
|
||||
- **PromptExecutor** — sends `def.Exec` (with template vars) to `router.Select(Task{Type: TaskReview})` → `provider.Stream()`. Uses TaskReview for routing since hooks are evaluative.
|
||||
- **AgentExecutor** — spawns elf via `elf.Manager` with `TaskReview` task type, interprets output for allow/deny
|
||||
|
||||
### Dispatch Flow
|
||||
|
||||
`Dispatcher.Fire(event EventType, payload []byte) ([]byte, Action, error)`
|
||||
|
||||
1. Look up handler chain for event type
|
||||
2. For PreToolUse/PostToolUse: filter chain by `ToolPattern` match against tool name in payload
|
||||
3. Run ALL handlers, each with `context.WithTimeout(ctx, def.Timeout)`
|
||||
4. Transforms chain: handler N receives (possibly transformed) payload from handler N-1
|
||||
5. Collect all `HookResult`s
|
||||
6. Decision logic:
|
||||
- ANY handler returns `Deny` → final = **Deny**
|
||||
- ANY handler errors AND `FailOpen=false` → final = **Deny**
|
||||
- `Skip` results don't count (hook abstains)
|
||||
- All remaining are `Allow` → final = **Allow**
|
||||
- Empty chain (no handlers) → **Allow**
|
||||
7. For `PostToolUse`: Deny is treated as Skip (execution already happened)
|
||||
8. Return `(finalPayload, action, error)`
|
||||
|
||||
### Constructor
|
||||
|
||||
```go
|
||||
func NewDispatcher(defs []HookDef, router *router.Router, elfMgr *elf.Manager, logger *slog.Logger) (*Dispatcher, error)
|
||||
```
|
||||
|
||||
Validates defs, constructs appropriate executor per CommandType, groups handlers by EventType.
|
||||
|
||||
---
|
||||
|
||||
## 3. Protocol
|
||||
|
||||
### Command Executor: stdin/stdout JSON
|
||||
|
||||
**Input payloads** (written to hook's stdin):
|
||||
|
||||
| Event | Payload |
|
||||
|-------|---------|
|
||||
| PreToolUse | `{"event":"pre_tool_use","tool":"bash","args":{"command":"rm -rf /tmp"}}` |
|
||||
| PostToolUse | `{"event":"post_tool_use","tool":"bash","args":{...},"result":{"output":"...","metadata":{...}}}` |
|
||||
| SessionStart | `{"event":"session_start","session_id":"abc","mode":"tui"}` |
|
||||
| SessionEnd | `{"event":"session_end","session_id":"abc","turns":42}` |
|
||||
| PreCompact | `{"event":"pre_compact","message_count":87,"token_estimate":120000}` |
|
||||
| Stop | `{"event":"stop","reason":"max_turns"}` |
|
||||
|
||||
**Output** (hook writes to stdout):
|
||||
|
||||
```json
|
||||
{"action":"allow","transformed":{"command":"rm -rf /tmp --verbose"}}
|
||||
```
|
||||
|
||||
- `action`: `"allow"`, `"deny"`, `"skip"` — overrides exit code if present
|
||||
- `transformed`: optional; replaces args (PreToolUse) or result (PostToolUse)
|
||||
- Empty stdout → exit code alone determines action
|
||||
|
||||
**Exit codes** (fallback when no JSON stdout):
|
||||
- 0 = allow
|
||||
- 1 = skip
|
||||
- 2 = deny
|
||||
|
||||
### Prompt Executor
|
||||
|
||||
Template variables in `def.Exec`: `{{.Event}}`, `{{.Tool}}`, `{{.Args}}`, `{{.Result}}`. Tool-related variables (`.Tool`, `.Args`, `.Result`) are empty strings for non-tool events (SessionStart, SessionEnd, PreCompact, Stop).
|
||||
|
||||
The executor sends the rendered prompt to an LLM via the router. The response is parsed for the first occurrence of "ALLOW" or "DENY" (case-insensitive). No match = Skip.
|
||||
|
||||
### Agent Executor
|
||||
|
||||
Same template variables. Spawns an elf with the rendered prompt. Elf output parsed for ALLOW/DENY the same way as the prompt executor. Elf failure → error → fail_open check.
|
||||
|
||||
---
|
||||
|
||||
## 4. Engine Integration
|
||||
|
||||
### 4.1 executeSingleTool (loop.go)
|
||||
|
||||
The main injection point. Before `tool.Execute()`:
|
||||
|
||||
```go
|
||||
if e.cfg.Hooks != nil {
|
||||
payload := marshalPreToolPayload(toolName, args)
|
||||
transformed, action, err := e.cfg.Hooks.Fire(hook.PreToolUse, payload)
|
||||
if action == hook.Deny {
|
||||
return tool.Result{Output: "denied by hook: " + hookDenyReason(err)}, nil
|
||||
}
|
||||
if transformed != nil {
|
||||
args = transformed // use potentially modified args
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
After `tool.Execute()`:
|
||||
|
||||
```go
|
||||
if e.cfg.Hooks != nil {
|
||||
payload := marshalPostToolPayload(toolName, args, result)
|
||||
transformed, _, _ := e.cfg.Hooks.Fire(hook.PostToolUse, payload)
|
||||
if transformed != nil {
|
||||
result = unmarshalTransformedResult(transformed)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Engine lifecycle events
|
||||
|
||||
- **SessionStart**: fired in `main.go` after engine construction, before first `Submit()`
|
||||
- **SessionEnd**: fired in `main.go` shutdown (`defer`), or on `/quit` command
|
||||
- **Stop**: fired in the engine's turn loop when `MaxTurns` reached or context cancelled
|
||||
|
||||
### 4.3 Compaction
|
||||
|
||||
- **PreCompact**: wire `Dispatcher.Fire(PreCompact, ...)` to `context.Window.OnPreCompact` callback (field exists in `WindowConfig`, currently unwired in `main.go`)
|
||||
- No PostCompact hook in scope — existing `OnPostCompact` callback remains as-is
|
||||
|
||||
### 4.4 engine.Config
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
// ... existing fields ...
|
||||
Hooks *hook.Dispatcher // nil = no hooks
|
||||
}
|
||||
```
|
||||
|
||||
All hook call sites are nil-safe: `if e.cfg.Hooks != nil { ... }`.
|
||||
|
||||
### 4.5 main.go wiring
|
||||
|
||||
```go
|
||||
// After config loading, before engine construction:
|
||||
hookDefs := parseHookDefs(cfg.Hooks) // []HookDef from config
|
||||
dispatcher, err := hook.NewDispatcher(hookDefs, rtr, elfMgr, logger)
|
||||
|
||||
// Pass to engine:
|
||||
eng, err := engine.New(engine.Config{
|
||||
// ...
|
||||
Hooks: dispatcher,
|
||||
})
|
||||
|
||||
// Lifecycle hooks:
|
||||
dispatcher.Fire(hook.SessionStart, sessionStartPayload())
|
||||
defer dispatcher.Fire(hook.SessionEnd, sessionEndPayload())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Config Schema
|
||||
|
||||
### TOML format
|
||||
|
||||
```toml
|
||||
[[hooks]]
|
||||
name = "log-all-tools"
|
||||
event = "post_tool_use"
|
||||
type = "command"
|
||||
exec = "tee -a /tmp/gnoma-tool-log.jsonl"
|
||||
timeout = "5s"
|
||||
fail_open = true
|
||||
|
||||
[[hooks]]
|
||||
name = "block-dangerous-bash"
|
||||
event = "pre_tool_use"
|
||||
type = "command"
|
||||
exec = "bash-safety-check.sh"
|
||||
tool_pattern = "bash*"
|
||||
timeout = "10s"
|
||||
fail_open = false
|
||||
|
||||
[[hooks]]
|
||||
name = "llm-safety-review"
|
||||
event = "pre_tool_use"
|
||||
type = "prompt"
|
||||
exec = "Is this tool call safe? Tool: {{.Tool}}, Args: {{.Args}}. Reply ALLOW or DENY."
|
||||
tool_pattern = "bash*"
|
||||
timeout = "30s"
|
||||
fail_open = true
|
||||
```
|
||||
|
||||
### Merge behavior
|
||||
|
||||
User hooks (`~/.config/gnoma/config.toml`) run first, then project hooks (`.gnoma/config.toml`). Within a layer, order in the TOML file is preserved. This lets users set global policies while projects add their own.
|
||||
|
||||
### Config struct
|
||||
|
||||
```go
|
||||
type HookConfig struct {
|
||||
Name string `toml:"name"`
|
||||
Event string `toml:"event"`
|
||||
Type string `toml:"type"`
|
||||
Exec string `toml:"exec"`
|
||||
Timeout string `toml:"timeout"`
|
||||
FailOpen bool `toml:"fail_open"`
|
||||
ToolPattern string `toml:"tool_pattern"`
|
||||
}
|
||||
```
|
||||
|
||||
Added to `config.Config`:
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
// ... existing ...
|
||||
Hooks []HookConfig `toml:"hooks"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Testing Strategy
|
||||
|
||||
### Unit tests (`internal/hook/`)
|
||||
|
||||
**dispatcher_test.go:**
|
||||
- Single handler allow/deny/skip
|
||||
- All-must-allow: 2 allow + 1 deny = deny
|
||||
- All-must-allow: 2 allow + 1 skip = allow (skip abstains)
|
||||
- Transform chaining: handler A transforms, handler B receives transformed
|
||||
- ToolPattern filtering: handler only fires for matching tools
|
||||
- Empty chain = allow
|
||||
- PostToolUse deny treated as skip
|
||||
|
||||
**command_test.go:**
|
||||
- Exit 0/1/2 → allow/skip/deny
|
||||
- Stdin JSON delivered correctly
|
||||
- Stdout JSON parsed, transformed payload extracted
|
||||
- Empty stdout (exit code fallback)
|
||||
- Timeout + fail_open=true → allow
|
||||
- Timeout + fail_open=false → deny
|
||||
- Broken hook (crash, invalid JSON) → error + fail_open check
|
||||
|
||||
**prompt_test.go:**
|
||||
- Template variable substitution
|
||||
- LLM response ALLOW/DENY parsing
|
||||
- No match → Skip
|
||||
- Timeout handling
|
||||
|
||||
**agent_test.go:**
|
||||
- Elf spawned with templated prompt
|
||||
- Output parsed for allow/deny
|
||||
- Elf failure → error + fail_open check
|
||||
|
||||
**config_test.go:**
|
||||
- Valid TOML round-trip
|
||||
- Invalid event/type/timeout rejected
|
||||
- Merge order: user hooks before project hooks
|
||||
|
||||
### Integration tests (`internal/engine/`)
|
||||
|
||||
**hook_integration_test.go:**
|
||||
- PreToolUse deny prevents tool execution
|
||||
- PreToolUse transform modifies args seen by tool
|
||||
- PostToolUse transform modifies result seen by LLM
|
||||
- Nil dispatcher = normal execution unchanged
|
||||
|
||||
---
|
||||
|
||||
## 7. Files
|
||||
|
||||
### New files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `internal/hook/event.go` | EventType, Action enums |
|
||||
| `internal/hook/hook.go` | HookDef, HookResult, Handler types |
|
||||
| `internal/hook/dispatcher.go` | Dispatcher, Fire(), NewDispatcher() |
|
||||
| `internal/hook/command.go` | CommandExecutor |
|
||||
| `internal/hook/prompt.go` | PromptExecutor |
|
||||
| `internal/hook/agent.go` | AgentExecutor |
|
||||
| `internal/hook/payload.go` | Payload marshal/unmarshal helpers |
|
||||
| `internal/hook/dispatcher_test.go` | Dispatcher unit tests |
|
||||
| `internal/hook/command_test.go` | CommandExecutor tests |
|
||||
| `internal/hook/prompt_test.go` | PromptExecutor tests |
|
||||
| `internal/hook/agent_test.go` | AgentExecutor tests |
|
||||
| `internal/hook/config_test.go` | Config parsing tests |
|
||||
|
||||
### Modified files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `internal/config/config.go` | Add `Hooks []HookConfig` field |
|
||||
| `internal/engine/engine.go` | Add `Hooks *hook.Dispatcher` to Config |
|
||||
| `internal/engine/loop.go` | PreToolUse/PostToolUse/Stop hook calls in executeSingleTool and turn loop |
|
||||
| `cmd/gnoma/main.go` | Parse hook config, construct Dispatcher, fire SessionStart/End, wire PreCompact |
|
||||
|
||||
---
|
||||
|
||||
## 8. Verification
|
||||
|
||||
```sh
|
||||
# All hook unit tests
|
||||
go test ./internal/hook/ -v
|
||||
|
||||
# Engine integration tests
|
||||
go test ./internal/engine/ -run "TestHook" -v
|
||||
|
||||
# Full suite (no regressions)
|
||||
make test
|
||||
|
||||
# Build
|
||||
make build
|
||||
|
||||
# Manual: add a test hook to .gnoma/config.toml, run gnoma, verify hook fires
|
||||
```
|
||||
Reference in New Issue
Block a user