dc084d5a82
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).
136 lines
4.3 KiB
Go
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)
|
|
}
|
|
}
|