Files
gnoma/internal/hook/posttooluse_redaction_test.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

141 lines
4.8 KiB
Go

package hook_test
import (
"context"
"encoding/json"
"strings"
"testing"
"somegit.dev/Owlibou/gnoma/internal/hook"
"somegit.dev/Owlibou/gnoma/internal/message"
"somegit.dev/Owlibou/gnoma/internal/provider"
"somegit.dev/Owlibou/gnoma/internal/router"
"somegit.dev/Owlibou/gnoma/internal/security"
"somegit.dev/Owlibou/gnoma/internal/stream"
)
// This regression test locks in ADR-004's transitive guarantee:
// PostToolUse hooks of type "prompt" do not leak raw tool output to a
// remote LLM, because the LLM call routes through SafeProvider, which
// scans outgoing messages.
//
// If this test fails after a refactor, either:
// - The hook prompt path no longer goes through the router/SafeProvider
// (re-open ADR-004 — Position A is broken; switch to Position C/D), or
// - SafeProvider was removed/relaxed (re-open Wave 1).
// recordingProvider captures the last request it received.
type recordingProvider struct {
name string
lastReq provider.Request
}
func (p *recordingProvider) Name() string { return p.name }
func (p *recordingProvider) DefaultModel() string { return "rec-model" }
func (p *recordingProvider) Models(_ context.Context) ([]provider.ModelInfo, error) {
return []provider.ModelInfo{{ID: "rec-model", Name: "rec-model", Provider: p.name}}, nil
}
func (p *recordingProvider) Stream(_ context.Context, req provider.Request) (stream.Stream, error) {
p.lastReq = req
return &finalEventStream{
events: []stream.Event{
{Type: stream.EventTextDelta, Text: "ALLOW"},
{Type: stream.EventTextDelta, StopReason: message.StopEndTurn, Model: "rec-model"},
},
}, nil
}
type finalEventStream struct {
events []stream.Event
idx int
}
func (s *finalEventStream) Next() bool {
if s.idx >= len(s.events) {
return false
}
s.idx++
return true
}
func (s *finalEventStream) Current() stream.Event { return s.events[s.idx-1] }
func (s *finalEventStream) Err() error { return nil }
func (s *finalEventStream) Close() error { return nil }
// streamerThroughRouter mirrors cmd/gnoma/main.go's unexported
// routerStreamer adapter. PromptExecutor needs only Stream(ctx, prompt);
// the router selects an arm and that arm's Provider does the work.
type streamerThroughRouter struct {
rtr *router.Router
}
func (s *streamerThroughRouter) Stream(ctx context.Context, prompt string) (stream.Stream, error) {
req := provider.Request{
Messages: []message.Message{message.NewUserText(prompt)},
}
strm, decision, err := s.rtr.Stream(ctx, router.Task{Type: router.TaskReview}, req)
if err != nil {
return nil, err
}
decision.Commit(0)
return strm, nil
}
func TestPostToolUsePromptHook_RedactsSecretViaSafeProvider(t *testing.T) {
// Wire the same boundary main.go uses: SafeProvider wraps the
// inner provider, router dispatches through arm.Provider.Stream.
rec := &recordingProvider{name: "rec"}
fwRef := new(security.FirewallRef)
fwRef.Set(security.NewFirewall(security.FirewallConfig{
ScanOutgoing: true,
EntropyThreshold: 4.5,
}))
rtr := router.New(router.Config{})
rtr.RegisterArm(&router.Arm{
ID: router.NewArmID("rec", "rec-model"),
Provider: security.WrapProvider(rec, fwRef),
ModelName: "rec-model",
IsLocal: true,
Capabilities: provider.Capabilities{ToolUse: false},
})
streamer := &streamerThroughRouter{rtr: rtr}
// Prompt hook template that drops the raw tool result straight into
// the LLM prompt. This is the worst-case user config.
def := hook.HookDef{
Name: "leaky-prompt-hook",
Event: hook.PostToolUse,
Command: hook.CommandTypePrompt,
Exec: `The bash tool ran. Output was:\n{{.Result}}\n\nDoes this contain a secret? Answer ALLOW or DENY.`,
}
exec := hook.NewPromptExecutor(def, streamer)
// Build a PostToolUse payload whose result.output contains a
// detectable secret.
const secret = "sk-ant-api03-abcdefghijklmnopqrstuvwxyz"
rawOutput := "command completed.\nleaked secret: " + secret
payload := hook.MarshalPostToolPayload("bash", json.RawMessage(`{"cmd":"echo $K"}`), rawOutput, nil)
if _, err := exec.Execute(context.Background(), payload); err != nil {
t.Fatalf("PromptExecutor.Execute: %v", err)
}
// Assert: the recorded request reaching the inner provider does NOT
// contain the raw secret. SafeProvider should have scrubbed it.
if len(rec.lastReq.Messages) == 0 {
t.Fatal("recordingProvider saw no request")
}
text := rec.lastReq.Messages[0].TextContent()
if strings.Contains(text, secret) {
t.Errorf("ADR-004 invariant broken: secret %q reached inner provider verbatim.\n"+
"Recorded prompt: %q\n"+
"Either the hook prompt path no longer routes through SafeProvider, or SafeProvider's "+
"redaction was disabled. Re-open ADR-004.",
secret, text)
}
if !strings.Contains(text, "[REDACTED]") {
t.Errorf("expected [REDACTED] marker in recorded prompt, got %q", text)
}
}