Files
gnoma/internal/security/integration_test.go
T
vikingowl dc084d5a82 feat(security): wire SafeProvider into all provider sites (W1-2/3/4)
Construct security.FirewallRef early in main() and Set it immediately
after security.NewFirewall returns. Wrap every provider that may be
called outside engine.buildRequest():

  - primary provider arm (limitedProvider)
  - discovered local models (RegisterDiscoveredModels factory)
  - CLI agent arms (subprocprov.New)
  - background-discovery factory (StartDiscoveryLoop)
  - SLM arm + classifier transport
  - summarizer (gnomactx.NewSummarizeStrategy)

routerStreamer and hook PromptExecutor inherit redaction automatically
once every router arm is wrapped — they dispatch through router.Stream
→ arm.Provider.Stream.

engine.Config.Provider stays raw because the engine still scans inline
at buildRequest(); per the Wave 1 plan, removing that scan is deferred
one release as belt-and-suspenders.

Integration tests in internal/security/integration_test.go verify the
boundary end-to-end: a router arm wrapped with WrapProvider redacts an
'sk-ant-...' literal before the inner provider sees it, and the
pre-Set / post-Set transition works as documented (pass-through until
the FirewallRef has a Firewall installed).
2026-05-19 22:33:24 +02:00

136 lines
4.3 KiB
Go

package security_test
import (
"context"
"strings"
"testing"
"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"
)
// External test package (security_test) so we exercise the public surface
// the way main.go does — WrapProvider in front of an arm, router.Stream
// dispatches through it, the inner provider sees redacted content.
type capturingProvider struct {
name string
lastReq provider.Request
}
func (p *capturingProvider) Name() string { return p.name }
func (p *capturingProvider) DefaultModel() string { return "cap-model" }
func (p *capturingProvider) Models(_ context.Context) ([]provider.ModelInfo, error) {
return []provider.ModelInfo{{ID: "cap-model", Name: "cap-model", Provider: p.name}}, nil
}
func (p *capturingProvider) Stream(_ context.Context, req provider.Request) (stream.Stream, error) {
p.lastReq = req
return &nopStream{}, nil
}
type nopStream struct{}
func (s *nopStream) Next() bool { return false }
func (s *nopStream) Current() stream.Event { return stream.Event{} }
func (s *nopStream) Err() error { return nil }
func (s *nopStream) Close() error { return nil }
func TestRouterArmWrappedWithSafeProvider_RedactsBeforeDelivery(t *testing.T) {
cap := &capturingProvider{name: "cap"}
ref := new(security.FirewallRef)
ref.Set(security.NewFirewall(security.FirewallConfig{
ScanOutgoing: true,
EntropyThreshold: 4.5,
}))
rtr := router.New(router.Config{})
rtr.RegisterArm(&router.Arm{
ID: router.NewArmID("cap", "cap-model"),
Provider: security.WrapProvider(cap, ref),
ModelName: "cap-model",
IsLocal: true,
Capabilities: provider.Capabilities{ToolUse: false},
})
const secret = "sk-ant-api03-abcdefghijklmnopqrstuvwxyz"
req := provider.Request{
Messages: []message.Message{
message.NewUserText("here is my key: " + secret),
},
}
s, decision, err := rtr.Stream(context.Background(), router.Task{Type: router.TaskReview}, req)
if err != nil {
t.Fatalf("router.Stream err = %v", err)
}
defer func() { _ = s.Close() }()
decision.Commit(0)
got := cap.lastReq.Messages[0].TextContent()
if strings.Contains(got, secret) {
t.Errorf("secret reached inner provider via router: %q", got)
}
if !strings.Contains(got, "[REDACTED]") {
t.Errorf("expected [REDACTED] marker after router dispatch, got %q", got)
}
}
func TestRouterArmWrappedBeforeFirewallSet_PassesThroughUntilSet(t *testing.T) {
// Mirrors the construction order in main.go: arm is wrapped with a
// FirewallRef whose pointer isn't installed yet. A Stream call in that
// state must pass through unmodified; once Set fires, subsequent calls
// are scanned.
cap := &capturingProvider{name: "cap"}
ref := new(security.FirewallRef) // not Set yet
rtr := router.New(router.Config{})
rtr.RegisterArm(&router.Arm{
ID: router.NewArmID("cap", "cap-model"),
Provider: security.WrapProvider(cap, ref),
ModelName: "cap-model",
IsLocal: true,
Capabilities: provider.Capabilities{ToolUse: false},
})
const secret = "sk-ant-api03-abcdefghijklmnopqrstuvwxyz"
req := provider.Request{
Messages: []message.Message{message.NewUserText(secret)},
}
// First call — ref unset, must pass through.
s, decision, err := rtr.Stream(context.Background(), router.Task{Type: router.TaskReview}, req)
if err != nil {
t.Fatalf("router.Stream (pre-Set) err = %v", err)
}
_ = s.Close()
decision.Commit(0)
if got := cap.lastReq.Messages[0].TextContent(); !strings.Contains(got, secret) {
t.Fatalf("pre-Set call was modified: %q", got)
}
// Now install the firewall and call again.
ref.Set(security.NewFirewall(security.FirewallConfig{
ScanOutgoing: true,
EntropyThreshold: 4.5,
}))
s, decision, err = rtr.Stream(context.Background(), router.Task{Type: router.TaskReview}, req)
if err != nil {
t.Fatalf("router.Stream (post-Set) err = %v", err)
}
_ = s.Close()
decision.Commit(0)
got := cap.lastReq.Messages[0].TextContent()
if strings.Contains(got, secret) {
t.Errorf("secret reached inner provider after Set: %q", got)
}
if !strings.Contains(got, "[REDACTED]") {
t.Errorf("expected [REDACTED] after Set, got %q", got)
}
}