Files
gnoma/internal/hook/event.go
T
vikingowl f8c85a26e9 docs(security): ADR-004 PostToolUse hook ordering + invariant test
Closes the last remaining 2026-05-19 audit finding by documenting the
existing transitive guarantee rather than restructuring the hook
contract.

The audit observed that PostToolUse hooks receive raw tool output
before the firewall scan runs, and proposed reordering or splitting
the event into raw-local-only and redacted-for-LLM variants. After
Wave 1 (SafeProvider boundary at every router arm + non-engine
provider consumer), the audit's threat model is closed transitively:

- Shell hooks see raw output but never reach an LLM.
- Prompt hooks route Stream calls through routerStreamer → router →
  arm.Provider, every arm.Provider is now *SafeProvider, outgoing
  messages are scanned at the boundary.
- Agent hooks spawn an elf whose engine has Firewall set;
  buildRequest scans inline.

Reordering would regress legitimate shell-hook use cases (audit,
forensic, local alert) that need raw access. Splitting the contract
forces every existing hook config to migrate and introduces a
wrong-variant footgun. Neither is justified by the residual risk.

Three changes ship with the ADR:
- ADR-004 records the decision and the conditions for re-opening it.
- Doc comments on hook.PostToolUse and the dispatcher call site in
  the engine point at the ADR.
- internal/hook/posttooluse_redaction_test.go locks in the invariant:
  a prompt PostToolUse hook firing on a secret-bearing tool result
  produces a redacted prompt at the inner provider. If this test
  fails, ADR-004's Position A is no longer correct and the audit
  finding re-opens.
2026-05-19 23:28:25 +02:00

137 lines
3.2 KiB
Go

package hook
import "fmt"
// EventType identifies when a hook fires.
type EventType int
const (
PreToolUse EventType = iota + 1
// PostToolUse fires after a tool executes, before the firewall scans
// the tool result. Hooks receive the raw output by design — shell
// hooks (audit log, forensic hash, local alerting) need it.
//
// LLM-bound hook types (CommandTypePrompt, CommandTypeAgent) do NOT
// leak raw output to a remote model: every LLM round-trip on those
// paths goes through security.SafeProvider (Wave 1), which scans
// outgoing messages before delegating. Adding a new hook type that
// talks to an LLM outside the router would break this guarantee —
// see docs/essentials/decisions/004-posttooluse-hook-ordering.md.
PostToolUse
SessionStart
SessionEnd
PreCompact
Stop
)
func (e EventType) String() string {
switch e {
case PreToolUse:
return "pre_tool_use"
case PostToolUse:
return "post_tool_use"
case SessionStart:
return "session_start"
case SessionEnd:
return "session_end"
case PreCompact:
return "pre_compact"
case Stop:
return "stop"
default:
return fmt.Sprintf("unknown_event(%d)", int(e))
}
}
// ParseEventType parses a TOML event string into an EventType.
func ParseEventType(s string) (EventType, error) {
switch s {
case "pre_tool_use":
return PreToolUse, nil
case "post_tool_use":
return PostToolUse, nil
case "session_start":
return SessionStart, nil
case "session_end":
return SessionEnd, nil
case "pre_compact":
return PreCompact, nil
case "stop":
return Stop, nil
default:
return 0, fmt.Errorf("hook: unknown event type %q", s)
}
}
// CommandType is the mechanism a hook uses to evaluate.
type CommandType int
const (
CommandTypeShell CommandType = iota + 1 // run a shell command
CommandTypePrompt // send a prompt to an LLM
CommandTypeAgent // spawn an elf
)
func (c CommandType) String() string {
switch c {
case CommandTypeShell:
return "command"
case CommandTypePrompt:
return "prompt"
case CommandTypeAgent:
return "agent"
default:
return fmt.Sprintf("unknown_command(%d)", int(c))
}
}
// ParseCommandType parses a TOML type string into a CommandType.
func ParseCommandType(s string) (CommandType, error) {
switch s {
case "command":
return CommandTypeShell, nil
case "prompt":
return CommandTypePrompt, nil
case "agent":
return CommandTypeAgent, nil
default:
return 0, fmt.Errorf("hook: unknown command type %q", s)
}
}
// Action is the outcome of a hook execution.
type Action int
const (
Allow Action = iota + 1 // exit 0 — hook approves
Deny // exit 2 — hook rejects
Skip // exit 1 — hook abstains
)
func (a Action) String() string {
switch a {
case Allow:
return "allow"
case Deny:
return "deny"
case Skip:
return "skip"
default:
return fmt.Sprintf("unknown_action(%d)", int(a))
}
}
// ParseAction maps a shell exit code to an Action.
func ParseAction(exitCode int) (Action, error) {
switch exitCode {
case 0:
return Allow, nil
case 1:
return Skip, nil
case 2:
return Deny, nil
default:
return 0, fmt.Errorf("hook: unrecognised exit code %d", exitCode)
}
}