feat(security): SafeProvider boundary wrapper (Wave 1) #1

Merged
vikingowl merged 3 commits from feat/security-wave1-safeprovider into main 2026-05-19 22:40:47 +02:00
8 changed files with 780 additions and 9 deletions
+7
View File
@@ -2,6 +2,13 @@
Active plans, newest first:
- **[`docs/superpowers/plans/2026-05-19-security-wave1-safeprovider.md`](docs/superpowers/plans/2026-05-19-security-wave1-safeprovider.md)**
— post-audit hardening, Wave 1. Closes the four firewall-bypass
call sites (SLM classifier, summarizer, prompt hook, routerStreamer)
by introducing `security.SafeProvider` at the provider boundary.
**In progress on `feat/security-wave1-safeprovider`** — implementation
complete; ADR and merge pending. Waves 2 (incognito coherence) and
3 (scanner + path hygiene) are scoped but not yet drafted.
- **[`docs/superpowers/plans/2026-05-19-post-slm-unlock.md`](docs/superpowers/plans/2026-05-19-post-slm-unlock.md)**
— outstanding work after the SLM unlock session. Phases A (two-stage
tool routing), B (CLI agent binary override), C (user profiles), and
+29 -9
View File
@@ -342,6 +342,12 @@ func main() {
// sessions from cross-contaminating the resume list.
sessStore := session.NewSessionStoreAt(profile.SessionDir(gnomacfg.ProjectRoot()), cfg.Session.MaxKeep, logger)
// FirewallRef holds the *Firewall via atomic.Pointer so it can be
// installed into SafeProvider wrappers before NewFirewall runs below
// (~line 509). Until Set, wrappers pass through unmodified — matching
// pre-firewall behaviour for early-init code paths.
fwRef := new(security.FirewallRef)
// Create router and register the provider as a single arm
// (M4 foundation: one provider from CLI. Multi-provider routing comes with config.)
rtr := router.New(router.Config{Logger: logger})
@@ -392,7 +398,7 @@ func main() {
}
}
armID = router.NewArmID(*providerName, armModel)
armProvider := limitedProvider(prov, *providerName, armModel, cfg)
armProvider := security.WrapProvider(limitedProvider(prov, *providerName, armModel, cfg), fwRef)
arm := &router.Arm{
ID: armID,
Provider: armProvider,
@@ -423,7 +429,7 @@ func main() {
if err != nil {
return nil
}
return p
return security.WrapProvider(p, fwRef)
})
if len(localModels) > 0 {
logger.Debug("local models discovered", "count", len(localModels))
@@ -435,7 +441,7 @@ func main() {
if _, exists := rtr.LookupArm(cliArmID); !exists {
rtr.RegisterArm(&router.Arm{
ID: cliArmID,
Provider: subprocprov.New(agent),
Provider: security.WrapProvider(subprocprov.New(agent), fwRef),
ModelName: agent.Name,
IsCLIAgent: true,
Capabilities: agent.Capabilities,
@@ -481,7 +487,7 @@ func main() {
if err != nil {
return nil
}
return p
return security.WrapProvider(p, fwRef)
}
router.StartDiscoveryLoop(discoveryCtx, rtr, logger,
cfg.Provider.Endpoints["ollama"],
@@ -512,6 +518,10 @@ func main() {
EntropyThreshold: entropyThreshold,
Logger: logger,
})
// Install into the ref so every SafeProvider wrapper sees scanning
// from this point on. Wrappers created before this Set call
// pass through; wrappers created after see the active firewall.
fwRef.Set(fw)
// Wire custom scanner patterns from config
for _, p := range cfg.Security.Patterns {
action := security.ActionRedact
@@ -699,8 +709,11 @@ func main() {
logger.Debug("context window from arm capabilities", "arm", armID, "context_window", contextWindowSize)
}
// Create context window with summarize strategy (falls back to truncation)
var compactStrategy gnomactx.Strategy = gnomactx.NewSummarizeStrategy(prov)
// Create context window with summarize strategy (falls back to truncation).
// The summarizer talks to the provider directly (no engine.buildRequest in
// between), so it needs the SafeProvider wrapper to inherit firewall
// scanning. The engine itself still scans inline at buildRequest().
var compactStrategy gnomactx.Strategy = gnomactx.NewSummarizeStrategy(security.WrapProvider(prov, fwRef))
ctxWindow := gnomactx.NewWindow(gnomactx.WindowConfig{
MaxTokens: contextWindowSize,
Strategy: compactStrategy,
@@ -746,7 +759,11 @@ func main() {
case boot == nil:
fmt.Fprintln(os.Stderr, "SLM unavailable: no backend reachable — using heuristic classifier.")
default:
lazy.set(slm.NewClassifier(boot.Provider, boot.Model, logger))
// Wrap once — the SLM provider is used both as the classifier
// transport and as a router arm. Both paths route through the
// firewall after fwRef.Set fires above.
slmProvider := security.WrapProvider(boot.Provider, fwRef)
lazy.set(slm.NewClassifier(slmProvider, boot.Model, logger))
// ToolUse comes from the live probe of the actual model. For
// completion-only models (e.g. TinyLlama), the SLM arm only
// handles knowledge-only prompts where the trivial-prompt
@@ -755,7 +772,7 @@ func main() {
// by MaxComplexity=0.3.
rtr.RegisterArm(&router.Arm{
ID: router.ArmID("slm/" + string(boot.Backend)),
Provider: boot.Provider,
Provider: slmProvider,
ModelName: boot.Model,
IsLocal: true,
MaxComplexity: 0.3,
@@ -786,7 +803,10 @@ func main() {
// Create engine
eng, err := engine.New(engine.Config{
Provider: prov,
// Wrap even though the engine's own buildRequest scans inline —
// belt-and-suspenders so a future engine path that bypasses
// buildRequest still routes through the firewall.
Provider: security.WrapProvider(prov, fwRef),
Router: rtr,
Classifier: engineClassifier,
Tools: reg,
@@ -0,0 +1,265 @@
# Security Hardening Wave 1 — SafeProvider Boundary — 2026-05-19
Addresses findings 14 of the 2026-05-19 external audit
(`docs/audits/2026-05-19-main2-firewall-audit.md`, if archived; otherwise
the audit transcript in conversation). The audit's central architectural
critique is that the `Firewall` is wired at call sites (only inside
`engine.buildRequest()`), not at the provider boundary. As a result,
every non-engine consumer of a `provider.Provider` skips outgoing
redaction.
Verified non-engine consumers that today send raw payloads:
- `internal/slm/classifier.go:105` — sends raw user prompt to the SLM
provider. Risk depends on backend (low for `ollama`/`llamafile`,
real for `openaicompat` against a remote endpoint).
- `internal/context/summarize.go:109` — sends conversation history to
the primary provider. Tool outputs in history are already redacted
(`loop.go:706`), but user input and assistant prose are not.
- `cmd/gnoma/main.go:1225` (`routerStreamer`) — `/init` and similar
flows build a raw request and call `rs.router.Stream(...)`.
- `internal/hook/prompt.go:80` — prompt-type hooks call the streamer
directly with hook-supplied prompts.
Inside the main engine loop, `Router.Stream` and `prov.Stream` calls
at `loop.go:127, 158, 191, 206, 769, 772` consume requests already
scanned in `buildRequest()` — they're fine, just listed by the audit
for completeness.
This wave is scoped to closing the bypass at the provider boundary.
Incognito coherence (Wave 2) and scanner/path hygiene (Wave 3) are
separate plan docs.
---
## Approach
Wrap every `provider.Provider` with a decorator (`security.SafeProvider`)
that runs outgoing-message and system-prompt redaction before delegating
to the inner provider. Apply the wrapper at every registration boundary:
- primary provider construction in `cmd/gnoma/main.go`
- router arm `Provider` field (initial registration + discovery
factory + CLI agent registration)
- SLM `Classifier.provider` construction
- summarizer `SummarizeStrategy.Provider` construction
- hook streamer construction
Tool-result redaction stays in the engine (it depends on per-tool
context — origin tool name for logging, etc., which the engine has and
the provider boundary doesn't). The engine's existing scan at
`loop.go:704-707` is unchanged.
### The construction-order problem
Today `security.NewFirewall()` runs at `main.go:509`, after every
provider arm has been registered (lines 404, 421-427, 436). A naive
"wrap at registration" implementation would require pulling Firewall
construction earlier, which entangles config validation,
custom-pattern wiring, and incognito state initialization.
**Solution:** `SafeProvider` accepts a `*FirewallRef` — a tiny
indirection holding an `atomic.Pointer[Firewall]`. The wrapper checks
the pointer on each call; if nil, it delegates without scanning
(matching today's behavior for early-init code paths). Firewall is
installed into the ref once it's constructed. Late-binding keeps the
init order intact and stays goroutine-safe without locking.
```go
type FirewallRef struct {
p atomic.Pointer[Firewall]
}
func (r *FirewallRef) Set(fw *Firewall) { r.p.Store(fw) }
func (r *FirewallRef) Get() *Firewall { return r.p.Load() }
```
### SafeProvider sketch
```go
type SafeProvider struct {
inner provider.Provider
fwRef *FirewallRef
}
func WrapProvider(inner provider.Provider, ref *FirewallRef) *SafeProvider {
return &SafeProvider{inner: inner, fwRef: ref}
}
func (p *SafeProvider) Stream(ctx context.Context, req provider.Request) (stream.Stream, error) {
if fw := p.fwRef.Get(); fw != nil {
req.Messages = fw.ScanOutgoingMessages(req.Messages)
req.SystemPrompt = fw.ScanSystemPrompt(req.SystemPrompt)
}
return p.inner.Stream(ctx, req)
}
func (p *SafeProvider) Name() string { return p.inner.Name() }
func (p *SafeProvider) DefaultModel() string { return p.inner.DefaultModel() }
func (p *SafeProvider) Models(ctx context.Context) ([]provider.ModelInfo, error) {
return p.inner.Models(ctx)
}
```
`SafeProvider` lives in `internal/security/` because it consumes
`*Firewall`. Importing `security` from `provider` would create a
cycle; the wrapper lives below both. Engine consumers always already
import `security`.
### Engine reconciliation
Once `SafeProvider` runs on every arm, `engine.buildRequest()` does
redundant scanning. Options:
1. **Leave it.** Two scans cost the same as one in steady state
(redaction is idempotent — second pass finds nothing). Slight
defensive belt-and-suspenders.
2. **Remove it.** Simpler. The decorator becomes the sole boundary.
Engine tests need updating; some currently rely on Firewall being
on `engine.Config`.
**Recommendation: leave it for one release**, then revisit after we've
seen real telemetry that nothing regresses. The cost is two regex
passes per message; not material vs. provider latency.
---
## Tasks
### W1-1 — SafeProvider + FirewallRef
- [ ] `internal/security/saferef.go``FirewallRef` with
`atomic.Pointer[Firewall]` semantics.
- [ ] `internal/security/safeprovider.go``SafeProvider` decorator
implementing `provider.Provider`. Wraps `Stream`; delegates the rest.
- [ ] Unit tests in `internal/security/safeprovider_test.go`:
- ref unset → delegates without scanning (recording fake provider
asserts unmodified request)
- ref set → outgoing messages and system prompt scanned
- tool-call args with embedded API key are redacted on outgoing
path (already covered by `ScanOutgoingMessages` but verify via
the wrapper)
- `Name()`, `Models()`, `DefaultModel()` pass through unchanged
### W1-2 — Wire ref into main
- [ ] `cmd/gnoma/main.go` — construct `security.FirewallRef` early,
before any provider construction. Pass to every site that builds
a `provider.Provider`.
- [ ] Existing `fw := security.NewFirewall(...)` call site stays where
it is; immediately call `fwRef.Set(fw)` after construction.
### W1-3 — Apply to all provider sites
- [ ] Primary provider: wrap return from `createProvider()` (or wrap
at the call site in `main.go` around line 395 — `armProvider`).
- [ ] Discovered local models: the factory in
`router.RegisterDiscoveredModels(...)` at `main.go:421-427` returns
the inner provider; wrap before returning.
- [ ] Background-discovery factory at `main.go:479-485` — same.
- [ ] CLI agents: `subprocprov.New(agent)` at `main.go:438` — wrap
before passing to `RegisterArm`.
- [ ] SLM classifier: wrap the provider passed into
`slm.NewClassifier(...)`. Site is wherever the SLM manager builds
the classifier (likely `internal/slm/manager.go` or
`cmd/gnoma/main.go`).
- [ ] Summarizer: wrap the provider passed into
`gnomactx.NewSummarizeStrategy(...)` at `main.go:703`.
- [ ] Hook streamer: wrap the provider passed into the hook system
for prompt-type hooks (origin site in `cmd/gnoma/main.go` near
router init).
- [ ] `routerStreamer`: doesn't take a `provider.Provider` directly —
it takes a `*router.Router`. Once all arms are wrapped, this site
inherits the fix. Verify by reading `Router.Stream`'s call into
`decision.Arm.Provider.Stream(...)` at `internal/router/router.go:319`.
### W1-4 — Tests
- [ ] Integration test: `internal/security/safeprovider_test.go` adds
a recording fake `Provider` and verifies redaction is applied for
each of the four bypass paths (SLM, summarizer, hook, router).
Mock data: a request whose user message contains an
`ANTHROPIC_API_KEY=sk-ant-...` literal; assert the inner provider
sees the redacted form.
- [ ] Confirm `make test` is green across the security, slm, context,
hook, and router packages.
### W1-5 — Docs
- [ ] Update `docs/essentials/decisions/` with a new ADR
(`004-safe-provider-boundary.md` or next index) capturing:
- why scanning moved to the provider boundary
- the FirewallRef late-binding pattern
- the explicit decision to keep engine-level scanning for one
release
- [ ] Update `docs/essentials/INDEX.md` to reference the new ADR.
---
## Exit criteria
- Every provider arm registered in `router.Router` is a `SafeProvider`.
Verified by a startup-time assertion or test that iterates
`rtr.LookupArm` for each registered ID and type-asserts the
`Provider` field.
- A request whose user message contains a secret-shaped string
(`sk-ant-...`) is redacted before reaching the inner provider for
all four bypass paths (SLM classifier, summarizer, prompt hook,
router stream).
- `make test` and `make lint` green.
- No change to public `provider.Provider` interface.
---
## Out of scope (deferred to Wave 2 / Wave 3)
- Forced-arm + incognito local-only collision
(`router.go:67-73`) — Wave 2.
- Unconditional `persist.New(sessionID)` in incognito — Wave 2.
- TUI `m.incognito` not initialized from `fw.Incognito().Active()`
— Wave 2.
- `saveQuality()` / `ReportOutcome()` gating on CLI flag instead of
firewall state — Wave 2.
- PEM block regex completion — Wave 3.
- Optional strict high-entropy mode for non-local arms — Wave 3.
- Canonical `AllowedPaths` (Clean+Prefix → Abs+EvalSymlinks) — Wave 3.
- MCP `PathSensitiveTool` policy hook — Wave 3.
- `fs.grep` per-file symlink resolution — Wave 3.
- PostToolUse hook ordering vs. redaction — open question, see
"Tool-result hook ordering" below.
### Tool-result hook ordering — open question
The audit's finding #4 also covers PostToolUse hooks seeing raw tool
output before `ScanToolResult` runs. Wave 1 doesn't fix this because:
- The audit's preferred fix ("redact, then fire hook") would change
the contract for command-type hooks (e.g. an audit-logger hook that
wants to record raw output). Splitting hooks into
`PostToolUseRawLocalOnly` vs. `PostToolUseRedactedForLLM` is a
design decision, not a refactor.
- Mitigation already exists: tool output written to the engine's
message history *is* redacted; the hook leak is only for prompt
hooks that re-inject hook-supplied content into an LLM. Today, no
shipped hook does this with raw tool output.
Tracked separately. If/when a prompt-type PostToolUse hook ships, the
split-contract design must land first.
---
## Effort estimate
- W1-1: ~80 LOC + ~120 LOC tests.
- W1-2: ~20 LOC.
- W1-3: ~50 LOC across 5 call sites.
- W1-4: ~150 LOC tests.
- W1-5: ~80 lines of ADR.
Total: ~500 LOC including tests. One PR or two, at your call.
---
## Changelog
- 2026-05-19: Initial. Captures Wave 1 of the post-audit hardening plan.
+7
View File
@@ -265,6 +265,13 @@ func (e *Engine) Usage() message.Usage {
}
// SetProvider swaps the active provider (for dynamic switching).
//
// Callers must pass a provider that has already been wrapped with
// security.WrapProvider — the engine's buildRequest scans inline today,
// but the boundary contract is "every Stream call routes through a
// SafeProvider." Passing a raw provider here would silently open a
// firewall bypass for any engine path that calls Provider.Stream
// without going through buildRequest.
func (e *Engine) SetProvider(p provider.Provider) {
e.mu.Lock()
e.cfg.Provider = p
+135
View File
@@ -0,0 +1,135 @@
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)
}
}
+66
View File
@@ -0,0 +1,66 @@
package security
import (
"context"
"somegit.dev/Owlibou/gnoma/internal/provider"
"somegit.dev/Owlibou/gnoma/internal/stream"
)
// SafeProvider wraps a provider.Provider with firewall redaction.
//
// On every Stream call it scans outgoing messages and the system prompt
// through the firewall referenced by fwRef before delegating to the
// inner provider. Name, Models, and DefaultModel pass through unchanged.
//
// Tool-result redaction stays in the engine because it needs per-tool
// context (origin tool name for logging) that isn't available at the
// provider boundary.
//
// SafeProvider is the single non-bypassable boundary between gnoma's
// internals and any LLM endpoint. Every provider.Provider registered
// with the router or handed to a non-engine consumer (SLM classifier,
// summarizer, hook streamer) should be wrapped.
type SafeProvider struct {
inner provider.Provider
fwRef *FirewallRef
}
// WrapProvider returns a SafeProvider that delegates to inner and
// resolves its firewall through ref. A nil ref is permitted and
// disables scanning — callers who never install a firewall get
// pass-through behaviour rather than a panic.
func WrapProvider(inner provider.Provider, ref *FirewallRef) *SafeProvider {
return &SafeProvider{inner: inner, fwRef: ref}
}
// Inner returns the wrapped provider. Useful for tests and for code
// that needs to reach through to provider-specific behaviour.
func (p *SafeProvider) Inner() provider.Provider {
return p.inner
}
func (p *SafeProvider) Stream(ctx context.Context, req provider.Request) (stream.Stream, error) {
if p.fwRef != nil {
if fw := p.fwRef.Get(); fw != nil {
req.Messages = fw.ScanOutgoingMessages(req.Messages)
req.SystemPrompt = fw.ScanSystemPrompt(req.SystemPrompt)
}
}
return p.inner.Stream(ctx, req)
}
func (p *SafeProvider) Name() string {
return p.inner.Name()
}
func (p *SafeProvider) Models(ctx context.Context) ([]provider.ModelInfo, error) {
return p.inner.Models(ctx)
}
func (p *SafeProvider) DefaultModel() string {
return p.inner.DefaultModel()
}
// Compile-time assertion that *SafeProvider satisfies provider.Provider.
var _ provider.Provider = (*SafeProvider)(nil)
+243
View File
@@ -0,0 +1,243 @@
package security
import (
"context"
"fmt"
"strings"
"sync"
"testing"
"somegit.dev/Owlibou/gnoma/internal/message"
"somegit.dev/Owlibou/gnoma/internal/provider"
"somegit.dev/Owlibou/gnoma/internal/stream"
)
// --- FirewallRef ---
func TestFirewallRef_GetBeforeSetReturnsNil(t *testing.T) {
ref := new(FirewallRef)
if fw := ref.Get(); fw != nil {
t.Errorf("Get() before Set() = %v, want nil", fw)
}
}
func TestFirewallRef_GetAfterSetReturnsValue(t *testing.T) {
ref := new(FirewallRef)
fw := NewFirewall(FirewallConfig{ScanOutgoing: true})
ref.Set(fw)
if got := ref.Get(); got != fw {
t.Errorf("Get() = %p, want %p", got, fw)
}
}
func TestFirewallRef_SetOverwritesPrevious(t *testing.T) {
ref := new(FirewallRef)
fw1 := NewFirewall(FirewallConfig{ScanOutgoing: true})
fw2 := NewFirewall(FirewallConfig{ScanOutgoing: true})
ref.Set(fw1)
ref.Set(fw2)
if got := ref.Get(); got != fw2 {
t.Errorf("Get() = %p, want %p (second Set)", got, fw2)
}
}
func TestFirewallRef_ConcurrentSetAndGetIsRaceSafe(t *testing.T) {
ref := new(FirewallRef)
fw := NewFirewall(FirewallConfig{ScanOutgoing: true})
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(2)
go func() {
defer wg.Done()
ref.Set(fw)
}()
go func() {
defer wg.Done()
_ = ref.Get()
}()
}
wg.Wait()
if got := ref.Get(); got != fw {
t.Errorf("after concurrent ops Get() = %p, want %p", got, fw)
}
}
// --- recordingProvider ---
// recordingProvider captures the last Request it saw and lets tests
// assert what reached the provider boundary.
type recordingProvider struct {
name string
lastReq provider.Request
streamErr error
}
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
if p.streamErr != nil {
return nil, p.streamErr
}
return &noopStream{}, nil
}
type noopStream struct{}
func (s *noopStream) Next() bool { return false }
func (s *noopStream) Current() stream.Event { return stream.Event{} }
func (s *noopStream) Err() error { return nil }
func (s *noopStream) Close() error { return nil }
// --- SafeProvider ---
func TestSafeProvider_NilRefDelegatesWithoutScanning(t *testing.T) {
rec := &recordingProvider{name: "rec"}
sp := WrapProvider(rec, nil)
const secret = "sk-ant-api03-abcdefghijklmnopqrstuvwxyz"
req := provider.Request{
SystemPrompt: "system contains " + secret,
Messages: []message.Message{
message.NewUserText("user contains " + secret),
},
}
if _, err := sp.Stream(context.Background(), req); err != nil {
t.Fatalf("Stream() err = %v", err)
}
if !strings.Contains(rec.lastReq.SystemPrompt, secret) {
t.Errorf("nil ref scrubbed system prompt: %q", rec.lastReq.SystemPrompt)
}
if got := rec.lastReq.Messages[0].TextContent(); !strings.Contains(got, secret) {
t.Errorf("nil ref scrubbed user message: %q", got)
}
}
func TestSafeProvider_EmptyRefDelegatesWithoutScanning(t *testing.T) {
// A *FirewallRef whose pointer is unset should behave like nil ref.
rec := &recordingProvider{name: "rec"}
sp := WrapProvider(rec, new(FirewallRef))
const secret = "sk-ant-api03-abcdefghijklmnopqrstuvwxyz"
req := provider.Request{
Messages: []message.Message{message.NewUserText(secret)},
}
if _, err := sp.Stream(context.Background(), req); err != nil {
t.Fatalf("Stream() err = %v", err)
}
if got := rec.lastReq.Messages[0].TextContent(); !strings.Contains(got, secret) {
t.Errorf("empty ref scrubbed message: %q", got)
}
}
func TestSafeProvider_RedactsOutgoingMessages(t *testing.T) {
rec := &recordingProvider{name: "rec"}
ref := new(FirewallRef)
ref.Set(NewFirewall(FirewallConfig{
ScanOutgoing: true,
EntropyThreshold: 4.5,
}))
sp := WrapProvider(rec, ref)
const secret = "sk-ant-api03-abcdefghijklmnopqrstuvwxyz"
req := provider.Request{
Messages: []message.Message{
message.NewUserText("here is my key: " + secret),
},
}
if _, err := sp.Stream(context.Background(), req); err != nil {
t.Fatalf("Stream() err = %v", err)
}
got := rec.lastReq.Messages[0].TextContent()
if strings.Contains(got, secret) {
t.Errorf("secret leaked to inner provider: %q", got)
}
if !strings.Contains(got, "[REDACTED]") {
t.Errorf("expected [REDACTED] marker, got %q", got)
}
}
func TestSafeProvider_RedactsSystemPrompt(t *testing.T) {
rec := &recordingProvider{name: "rec"}
ref := new(FirewallRef)
ref.Set(NewFirewall(FirewallConfig{
ScanOutgoing: true,
EntropyThreshold: 4.5,
}))
sp := WrapProvider(rec, ref)
const secret = "sk-ant-api03-abcdefghijklmnopqrstuvwxyz"
req := provider.Request{
SystemPrompt: "operator key " + secret,
}
if _, err := sp.Stream(context.Background(), req); err != nil {
t.Fatalf("Stream() err = %v", err)
}
if strings.Contains(rec.lastReq.SystemPrompt, secret) {
t.Errorf("secret leaked in system prompt: %q", rec.lastReq.SystemPrompt)
}
if !strings.Contains(rec.lastReq.SystemPrompt, "[REDACTED]") {
t.Errorf("expected [REDACTED] marker, got %q", rec.lastReq.SystemPrompt)
}
}
func TestSafeProvider_PassesThroughStreamError(t *testing.T) {
sentinel := fmt.Errorf("provider exploded")
rec := &recordingProvider{name: "rec", streamErr: sentinel}
sp := WrapProvider(rec, nil)
_, err := sp.Stream(context.Background(), provider.Request{})
if err != sentinel {
t.Errorf("Stream() err = %v, want %v", err, sentinel)
}
}
func TestSafeProvider_PassesThroughName(t *testing.T) {
rec := &recordingProvider{name: "anthropic"}
sp := WrapProvider(rec, nil)
if got := sp.Name(); got != "anthropic" {
t.Errorf("Name() = %q, want %q", got, "anthropic")
}
}
func TestSafeProvider_PassesThroughDefaultModel(t *testing.T) {
rec := &recordingProvider{name: "rec"}
sp := WrapProvider(rec, nil)
if got := sp.DefaultModel(); got != "rec-model" {
t.Errorf("DefaultModel() = %q, want %q", got, "rec-model")
}
}
func TestSafeProvider_PassesThroughModels(t *testing.T) {
rec := &recordingProvider{name: "rec"}
sp := WrapProvider(rec, nil)
models, err := sp.Models(context.Background())
if err != nil {
t.Fatalf("Models() err = %v", err)
}
if len(models) != 1 || models[0].ID != "rec-model" {
t.Errorf("Models() = %+v, want one model rec-model", models)
}
}
func TestSafeProvider_SatisfiesProviderInterface(t *testing.T) {
// Compile-time check that *SafeProvider implements provider.Provider.
var _ provider.Provider = (*SafeProvider)(nil)
}
+28
View File
@@ -0,0 +1,28 @@
package security
import "sync/atomic"
// FirewallRef is a late-binding holder for *Firewall.
//
// Construction order in gnoma builds provider arms before the firewall
// exists. SafeProvider takes a *FirewallRef at construction time, then
// resolves the current *Firewall on each call. This lets the wiring be
// installed before NewFirewall runs without any locking on the hot path.
//
// A nil *FirewallRef or a *FirewallRef whose pointer has not been Set
// is interpreted by SafeProvider as "no firewall installed yet" —
// requests pass through unmodified.
type FirewallRef struct {
p atomic.Pointer[Firewall]
}
// Set installs fw as the active firewall. Safe for concurrent use.
func (r *FirewallRef) Set(fw *Firewall) {
r.p.Store(fw)
}
// Get returns the currently installed firewall, or nil if none has been
// Set. Safe for concurrent use.
func (r *FirewallRef) Get() *Firewall {
return r.p.Load()
}