refactor(security): seal SecureProvider via unexported marker method

The router.SecureProvider interface previously required a public
IsSecure() bool method. Any test mock — or future production type —
could satisfy it by returning true, defeating the W1 "only wrapped
providers may flow past the boundary" contract through convention
rather than at the type level.

Replaces IsSecure() bool with an unexported security.Marker interface
that has a single secured() method. Go's method-set semantics key
unexported methods by their defining package, so only types declared in
internal/security can satisfy Marker. *SafeProvider gets the lone
secured() implementation; router.SecureProvider embeds Marker.

The seal forces every test mock that previously implemented IsSecure()
to either (a) be wrapped with security.WrapProvider(mp, nil) at the use
site, or (b) drop the method entirely if the mock never flows through
SecureProvider. 93 use sites across 11 test files were updated via a
per-package secureMock helper. WrapProvider with a nil firewall ref is
a no-op pass-through, so test behavior is unchanged.

Empirically: a type from outside internal/security can declare
`secured()` but the compiler will reject assigning it to
router.SecureProvider because the unexported method belongs to the
other package's namespace. Convention → compile-time guarantee.
This commit is contained in:
2026-05-20 02:04:07 +02:00
parent 9853a522e6
commit fb42202834
16 changed files with 137 additions and 111 deletions
+15 -11
View File
@@ -27,7 +27,6 @@ type mockProvider struct {
func (m *mockProvider) Name() string { return m.name }
func (m *mockProvider) DefaultModel() string { return "mock" }
func (m *mockProvider) Models(_ context.Context) ([]provider.ModelInfo, error) { return nil, nil }
func (m *mockProvider) IsSecure() bool { return true }
func (m *mockProvider) Stream(_ context.Context, _ provider.Request) (stream.Stream, error) {
idx := m.calls.Add(1) - 1
if int(idx) >= len(m.streams) {
@@ -36,6 +35,12 @@ func (m *mockProvider) Stream(_ context.Context, _ provider.Request) (stream.Str
return m.streams[idx], nil
}
// secureMock wraps a test provider in *security.SafeProvider so it
// satisfies router.SecureProvider's sealed Marker.
func secureMock(p provider.Provider) router.SecureProvider {
return security.WrapProvider(p, nil)
}
type eventStream struct {
events []stream.Event
idx int
@@ -62,7 +67,7 @@ func TestBackgroundElf_RunsAndCompletes(t *testing.T) {
name: "test",
streams: []stream.Stream{newEventStream("Hello from elf!")},
}
eng, _ := engine.New(engine.Config{Provider: mp, Tools: tool.NewRegistry()})
eng, _ := engine.New(engine.Config{Provider: secureMock(mp), Tools: tool.NewRegistry()})
elf := SpawnBackground(eng, "say hello")
@@ -93,7 +98,7 @@ func TestBackgroundElf_Cancel(t *testing.T) {
name: "test",
streams: []stream.Stream{slowStream},
}
eng, _ := engine.New(engine.Config{Provider: mp, Tools: tool.NewRegistry()})
eng, _ := engine.New(engine.Config{Provider: secureMock(mp), Tools: tool.NewRegistry()})
elf := SpawnBackground(eng, "slow task")
@@ -111,7 +116,7 @@ func TestBackgroundElf_CollectEvents(t *testing.T) {
name: "test",
streams: []stream.Stream{newEventStream("event test")},
}
eng, _ := engine.New(engine.Config{Provider: mp, Tools: tool.NewRegistry()})
eng, _ := engine.New(engine.Config{Provider: secureMock(mp), Tools: tool.NewRegistry()})
elf := SpawnBackground(eng, "generate events")
@@ -137,7 +142,7 @@ func TestManager_SpawnAndList(t *testing.T) {
rtr := router.New(router.Config{})
rtr.RegisterArm(&router.Arm{
ID: "test/mock",
Provider: mp,
Provider: secureMock(mp),
ModelName: "mock",
Capabilities: provider.Capabilities{ToolUse: true},
})
@@ -198,7 +203,7 @@ func TestManager_WaitAll(t *testing.T) {
rtr := router.New(router.Config{})
rtr.RegisterArm(&router.Arm{
ID: "test/mock", Provider: mp, ModelName: "mock",
ID: "test/mock", Provider: secureMock(mp), ModelName: "mock",
Capabilities: provider.Capabilities{ToolUse: true},
})
@@ -229,7 +234,7 @@ func TestBackgroundElf_WaitIdempotent(t *testing.T) {
name: "test",
streams: []stream.Stream{newEventStream("hello")},
}
eng, _ := engine.New(engine.Config{Provider: mp, Tools: tool.NewRegistry()})
eng, _ := engine.New(engine.Config{Provider: secureMock(mp), Tools: tool.NewRegistry()})
elf := SpawnBackground(eng, "do something")
r1 := elf.Wait()
@@ -246,7 +251,7 @@ func TestBackgroundElf_WaitIdempotent(t *testing.T) {
func TestBackgroundElf_PanicRecovery(t *testing.T) {
// A provider that panics on Stream() — simulates an engine crash
panicProvider := &panicOnStreamProvider{}
eng, _ := engine.New(engine.Config{Provider: panicProvider, Tools: tool.NewRegistry()})
eng, _ := engine.New(engine.Config{Provider: secureMock(panicProvider), Tools: tool.NewRegistry()})
elf := SpawnBackground(eng, "do something")
result := elf.Wait() // must not hang
@@ -266,7 +271,6 @@ func (p *panicOnStreamProvider) DefaultModel() string { return "panic" }
func (p *panicOnStreamProvider) Models(_ context.Context) ([]provider.ModelInfo, error) {
return nil, nil
}
func (p *panicOnStreamProvider) IsSecure() bool { return true }
func (p *panicOnStreamProvider) Stream(_ context.Context, _ provider.Request) (stream.Stream, error) {
panic("intentional test panic")
}
@@ -279,7 +283,7 @@ func TestManager_CleanupRemovesMeta(t *testing.T) {
rtr := router.New(router.Config{})
rtr.RegisterArm(&router.Arm{
ID: "test/mock", Provider: mp, ModelName: "mock",
ID: "test/mock", Provider: secureMock(mp), ModelName: "mock",
Capabilities: provider.Capabilities{ToolUse: true},
})
@@ -325,7 +329,7 @@ func TestManager_ReportResultSuppressedWhenIncognito(t *testing.T) {
rtr := router.New(router.Config{})
armID := router.ArmID("test/mock")
rtr.RegisterArm(&router.Arm{
ID: armID, Provider: mp, ModelName: "mock",
ID: armID, Provider: secureMock(mp), ModelName: "mock",
Capabilities: provider.Capabilities{ToolUse: true},
})
+11 -11
View File
@@ -28,7 +28,7 @@ func TestForcedArmSupportsTools_ArmWithTools(t *testing.T) {
rtr := router.New(router.Config{})
rtr.RegisterArm(&router.Arm{
ID: "llamacpp/qwen3",
Provider: &mockProvider{name: "llamacpp"},
Provider: secureMock(&mockProvider{name: "llamacpp"}),
ModelName: "qwen3",
IsLocal: true,
Capabilities: provider.Capabilities{ToolUse: true},
@@ -45,7 +45,7 @@ func TestForcedArmSupportsTools_ArmWithoutTools(t *testing.T) {
rtr := router.New(router.Config{})
rtr.RegisterArm(&router.Arm{
ID: "llamacpp/gemma",
Provider: &mockProvider{name: "llamacpp"},
Provider: secureMock(&mockProvider{name: "llamacpp"}),
ModelName: "gemma",
IsLocal: true,
Capabilities: provider.Capabilities{ToolUse: false},
@@ -62,7 +62,7 @@ func TestBuildRequest_ForcedArmNoToolSupport_OmitsTools(t *testing.T) {
rtr := router.New(router.Config{})
rtr.RegisterArm(&router.Arm{
ID: "llamacpp/gemma",
Provider: &mockProvider{name: "llamacpp"},
Provider: secureMock(&mockProvider{name: "llamacpp"}),
ModelName: "gemma",
IsLocal: true,
Capabilities: provider.Capabilities{ToolUse: false},
@@ -74,7 +74,7 @@ func TestBuildRequest_ForcedArmNoToolSupport_OmitsTools(t *testing.T) {
reg.Register(&mockTool{name: "bash"})
e, err := New(Config{
Provider: &mockProvider{name: "llamacpp"},
Provider: secureMock(&mockProvider{name: "llamacpp"}),
Router: rtr,
Tools: reg,
})
@@ -92,7 +92,7 @@ func TestBuildRequest_ForcedArmWithToolSupport_IncludesTools(t *testing.T) {
rtr := router.New(router.Config{})
rtr.RegisterArm(&router.Arm{
ID: "llamacpp/qwen3",
Provider: &mockProvider{name: "llamacpp"},
Provider: secureMock(&mockProvider{name: "llamacpp"}),
ModelName: "qwen3",
IsLocal: true,
// ContextWindow > 16384 keeps two-stage routing inactive so this
@@ -106,7 +106,7 @@ func TestBuildRequest_ForcedArmWithToolSupport_IncludesTools(t *testing.T) {
reg.Register(&mockTool{name: "bash"})
e, err := New(Config{
Provider: &mockProvider{name: "llamacpp"},
Provider: secureMock(&mockProvider{name: "llamacpp"}),
Router: rtr,
Tools: reg,
})
@@ -129,7 +129,7 @@ func TestBuildRequest_AllowedToolsFilter(t *testing.T) {
reg.Register(&mockTool{name: "agent"})
e, err := New(Config{
Provider: &mockProvider{name: "llamacpp"},
Provider: secureMock(&mockProvider{name: "llamacpp"}),
Tools: reg,
})
if err != nil {
@@ -160,7 +160,7 @@ func TestBuildRequest_AllowedToolsFilter(t *testing.T) {
func TestBuildRequest_Temperature(t *testing.T) {
temp := 0.7
e, err := New(Config{
Provider: &mockProvider{name: "test"},
Provider: secureMock(&mockProvider{name: "test"}),
Tools: tool.NewRegistry(),
Temperature: &temp,
})
@@ -179,7 +179,7 @@ func TestBuildRequest_Temperature(t *testing.T) {
func TestBuildRequest_TemperatureNilWhenNotSet(t *testing.T) {
e, err := New(Config{
Provider: &mockProvider{name: "test"},
Provider: secureMock(&mockProvider{name: "test"}),
Tools: tool.NewRegistry(),
})
if err != nil {
@@ -196,7 +196,7 @@ func TestBuildRequest_MultiArmRouting_IncludesTools(t *testing.T) {
rtr := router.New(router.Config{})
rtr.RegisterArm(&router.Arm{
ID: "llamacpp/gemma",
Provider: &mockProvider{name: "llamacpp"},
Provider: secureMock(&mockProvider{name: "llamacpp"}),
ModelName: "gemma",
IsLocal: true,
Capabilities: provider.Capabilities{ToolUse: false},
@@ -207,7 +207,7 @@ func TestBuildRequest_MultiArmRouting_IncludesTools(t *testing.T) {
reg.Register(&mockTool{name: "fs.read"})
e, err := New(Config{
Provider: &mockProvider{name: "llamacpp"},
Provider: secureMock(&mockProvider{name: "llamacpp"}),
Router: rtr,
Tools: reg,
})
@@ -45,7 +45,7 @@ func TestEarlyStop_PatchSpiral_InjectsCorrection(t *testing.T) {
},
}
e, _ := New(Config{Provider: mp, Tools: reg})
e, _ := New(Config{Provider: secureMock(mp), Tools: reg})
_, err := e.Submit(context.Background(), "fix the bug", nil)
if err != nil {
t.Fatalf("Submit: %v", err)
@@ -95,7 +95,7 @@ func TestEarlyStop_PatchSpiral_PerPathIsolation(t *testing.T) {
},
}
e, _ := New(Config{Provider: mp, Tools: reg})
e, _ := New(Config{Provider: secureMock(mp), Tools: reg})
_, err := e.Submit(context.Background(), "edit two files", nil)
if err != nil {
t.Fatalf("Submit: %v", err)
@@ -138,7 +138,7 @@ func TestEarlyStop_GreetingRegression_InjectsCorrection(t *testing.T) {
},
}
e, _ := New(Config{Provider: mp, Tools: reg})
e, _ := New(Config{Provider: secureMock(mp), Tools: reg})
_, err := e.Submit(context.Background(), "inspect /x.go", nil)
if err != nil {
t.Fatalf("Submit: %v", err)
@@ -171,7 +171,7 @@ func TestEarlyStop_NoFalsePositive_GreetingOnFirstTurn(t *testing.T) {
},
}
e, _ := New(Config{Provider: mp, Tools: tool.NewRegistry()})
e, _ := New(Config{Provider: secureMock(mp), Tools: tool.NewRegistry()})
_, err := e.Submit(context.Background(), "hello", nil)
if err != nil {
t.Fatalf("Submit: %v", err)
@@ -205,7 +205,7 @@ func TestEarlyStop_Repetition_BreaksAndCorrects(t *testing.T) {
streams: []stream.Stream{round1, round2},
}
e, _ := New(Config{Provider: mp, Tools: tool.NewRegistry()})
e, _ := New(Config{Provider: secureMock(mp), Tools: tool.NewRegistry()})
_, err := e.Submit(context.Background(), "do something", nil)
if err != nil {
t.Fatalf("Submit: %v", err)
+30 -23
View File
@@ -33,7 +33,6 @@ func (m *mockProvider) Models(_ context.Context) ([]provider.ModelInfo, error) {
Capabilities: provider.Capabilities{ToolUse: true},
}}, nil
}
func (m *mockProvider) IsSecure() bool { return true }
func (m *mockProvider) Stream(_ context.Context, _ provider.Request) (stream.Stream, error) {
if m.calls >= len(m.streams) {
return nil, fmt.Errorf("mock: no more streams (called %d times)", m.calls+1)
@@ -43,6 +42,14 @@ func (m *mockProvider) Stream(_ context.Context, _ provider.Request) (stream.Str
return s, nil
}
// secureMock wraps a test provider in *security.SafeProvider so it
// satisfies router.SecureProvider's sealed Marker. Tests use this at every
// Config.Provider site; the wrapper is firewall-less (pass-through), so
// behavior is unchanged from before the seal landed.
func secureMock(p provider.Provider) router.SecureProvider {
return security.WrapProvider(p, nil)
}
// eventStream is a mock stream backed by a slice of events.
type eventStream struct {
events []stream.Event
@@ -100,7 +107,7 @@ func (m *mockTool) Execute(ctx context.Context, args json.RawMessage) (tool.Resu
func TestNew_ValidConfig(t *testing.T) {
e, err := New(Config{
Provider: &mockProvider{name: "test"},
Provider: secureMock(&mockProvider{name: "test"}),
Tools: tool.NewRegistry(),
})
if err != nil {
@@ -119,7 +126,7 @@ func TestNew_MissingProvider(t *testing.T) {
}
func TestNew_MissingTools(t *testing.T) {
_, err := New(Config{Provider: &mockProvider{name: "test"}})
_, err := New(Config{Provider: secureMock(&mockProvider{name: "test"})})
if err == nil {
t.Fatal("expected error for missing tool registry")
}
@@ -137,7 +144,7 @@ func TestSubmit_SimpleTextResponse(t *testing.T) {
},
}
e, _ := New(Config{Provider: mp, Tools: tool.NewRegistry()})
e, _ := New(Config{Provider: secureMock(mp), Tools: tool.NewRegistry()})
var events []stream.Event
turn, err := e.Submit(context.Background(), "hi", func(evt stream.Event) {
@@ -204,7 +211,7 @@ func TestSubmit_ToolCallLoop(t *testing.T) {
},
}
e, _ := New(Config{Provider: mp, Tools: reg})
e, _ := New(Config{Provider: secureMock(mp), Tools: reg})
turn, err := e.Submit(context.Background(), "list files", nil)
if err != nil {
@@ -265,7 +272,7 @@ func TestSubmit_UnknownTool(t *testing.T) {
},
}
e, _ := New(Config{Provider: mp, Tools: reg})
e, _ := New(Config{Provider: secureMock(mp), Tools: reg})
turn, err := e.Submit(context.Background(), "do something", nil)
if err != nil {
@@ -300,7 +307,7 @@ func TestSubmit_ToolExecutionError(t *testing.T) {
},
}
e, _ := New(Config{Provider: mp, Tools: reg})
e, _ := New(Config{Provider: secureMock(mp), Tools: reg})
turn, err := e.Submit(context.Background(), "do it", nil)
if err != nil {
@@ -336,7 +343,7 @@ func TestSubmit_MaxTurnsLimit(t *testing.T) {
},
}
e, _ := New(Config{Provider: mp, Tools: reg, MaxTurns: 2})
e, _ := New(Config{Provider: secureMock(mp), Tools: reg, MaxTurns: 2})
_, err := e.Submit(context.Background(), "loop forever", nil)
if err == nil {
@@ -379,7 +386,7 @@ func TestSubmit_MultipleToolCalls(t *testing.T) {
},
}
e, _ := New(Config{Provider: mp, Tools: reg})
e, _ := New(Config{Provider: secureMock(mp), Tools: reg})
turn, err := e.Submit(context.Background(), "run both", nil)
if err != nil {
@@ -407,7 +414,7 @@ func TestSubmit_NilCallback(t *testing.T) {
},
}
e, _ := New(Config{Provider: mp, Tools: tool.NewRegistry()})
e, _ := New(Config{Provider: secureMock(mp), Tools: tool.NewRegistry()})
// nil callback should not panic
turn, err := e.Submit(context.Background(), "test", nil)
@@ -430,7 +437,7 @@ func TestEngine_Reset(t *testing.T) {
},
}
e, _ := New(Config{Provider: mp, Tools: tool.NewRegistry()})
e, _ := New(Config{Provider: secureMock(mp), Tools: tool.NewRegistry()})
_, _ = e.Submit(context.Background(), "hello", nil)
if len(e.History()) == 0 {
@@ -461,7 +468,7 @@ func TestEngine_Reset_ClearsContextWindow(t *testing.T) {
},
}
e, _ := New(Config{
Provider: mp,
Provider: secureMock(mp),
Tools: tool.NewRegistry(),
Context: ctxWindow,
})
@@ -503,7 +510,7 @@ func TestSubmit_ContextWindowTracksUserAndToolMessages(t *testing.T) {
ctxWindow := gnomactx.NewWindow(gnomactx.WindowConfig{MaxTokens: 200_000})
e, _ := New(Config{
Provider: mp,
Provider: secureMock(mp),
Tools: reg,
Context: ctxWindow,
})
@@ -542,7 +549,7 @@ func TestSubmit_TrackerReflectsInputTokens(t *testing.T) {
),
},
}
e, _ := New(Config{Provider: mp, Tools: tool.NewRegistry(), Context: ctxWindow})
e, _ := New(Config{Provider: secureMock(mp), Tools: tool.NewRegistry(), Context: ctxWindow})
_, _ = e.Submit(context.Background(), "hi", nil)
@@ -568,7 +575,7 @@ func TestSubmit_CumulativeUsage(t *testing.T) {
},
}
e, _ := New(Config{Provider: mp, Tools: tool.NewRegistry()})
e, _ := New(Config{Provider: secureMock(mp), Tools: tool.NewRegistry()})
_, _ = e.Submit(context.Background(), "one", nil)
_, _ = e.Submit(context.Background(), "two", nil)
@@ -608,7 +615,7 @@ func TestSubmit_UsesInjectedClassifier(t *testing.T) {
}
rtr.RegisterArm(&router.Arm{
ID: armID,
Provider: mp,
Provider: secureMock(mp),
ModelName: "mock-model",
Capabilities: provider.Capabilities{ToolUse: true},
})
@@ -616,7 +623,7 @@ func TestSubmit_UsesInjectedClassifier(t *testing.T) {
spy := &spyClassifier{}
e, err := New(Config{
Provider: mp,
Provider: secureMock(mp),
Router: rtr,
Tools: tool.NewRegistry(),
Classifier: spy,
@@ -647,7 +654,7 @@ func TestSubmit_NilClassifierFallsBackToHeuristic(t *testing.T) {
}
rtr.RegisterArm(&router.Arm{
ID: armID,
Provider: mp,
Provider: secureMock(mp),
ModelName: "mock-model",
Capabilities: provider.Capabilities{ToolUse: true},
})
@@ -655,7 +662,7 @@ func TestSubmit_NilClassifierFallsBackToHeuristic(t *testing.T) {
// No Classifier set — should not panic, should use heuristic
e, err := New(Config{
Provider: mp,
Provider: secureMock(mp),
Router: rtr,
Tools: tool.NewRegistry(),
})
@@ -685,14 +692,14 @@ func TestSubmit_ReportsOutcomeToRouter(t *testing.T) {
}
rtr.RegisterArm(&router.Arm{
ID: armID,
Provider: mp,
Provider: secureMock(mp),
ModelName: "mock-model",
Capabilities: provider.Capabilities{ToolUse: true},
})
rtr.ForceArm(armID)
e, err := New(Config{
Provider: mp,
Provider: secureMock(mp),
Router: rtr,
Tools: tool.NewRegistry(),
})
@@ -731,7 +738,7 @@ func TestSubmit_SuppressesOutcomeWhenIncognito(t *testing.T) {
mp := &mockProvider{name: "test", streams: []stream.Stream{makeStream(), makeStream()}}
rtr.RegisterArm(&router.Arm{
ID: armID,
Provider: mp,
Provider: secureMock(mp),
ModelName: "mock-model",
Capabilities: provider.Capabilities{ToolUse: true},
})
@@ -741,7 +748,7 @@ func TestSubmit_SuppressesOutcomeWhenIncognito(t *testing.T) {
fw.Incognito().Activate()
e, err := New(Config{
Provider: mp,
Provider: secureMock(mp),
Router: rtr,
Tools: tool.NewRegistry(),
Firewall: fw,
+8 -8
View File
@@ -85,7 +85,7 @@ func TestHook_NilDispatcher_NoChange(t *testing.T) {
),
},
}
eng, err := New(Config{Provider: mp, Tools: tool.NewRegistry()})
eng, err := New(Config{Provider: secureMock(mp), Tools: tool.NewRegistry()})
if err != nil {
t.Fatal(err)
}
@@ -119,7 +119,7 @@ func TestHook_PreToolUse_Deny(t *testing.T) {
}
eng, _ := New(Config{
Provider: mp,
Provider: secureMock(mp),
Tools: reg,
Hooks: hookDispatcher(hook.PreToolUse, &blockingExecutor{}),
})
@@ -151,7 +151,7 @@ func TestHook_PreToolUse_Allow(t *testing.T) {
}
eng, _ := New(Config{
Provider: mp,
Provider: secureMock(mp),
Tools: reg,
Hooks: hookDispatcher(hook.PreToolUse, &allowingExecutor{}),
})
@@ -181,7 +181,7 @@ func TestHook_PreToolUse_DenyMessage(t *testing.T) {
}
eng, _ := New(Config{
Provider: mp,
Provider: secureMock(mp),
Tools: reg,
Hooks: hookDispatcher(hook.PreToolUse, &blockingExecutor{}),
})
@@ -221,7 +221,7 @@ func TestHook_PreToolUse_Transform(t *testing.T) {
}
eng, _ := New(Config{
Provider: mp,
Provider: secureMock(mp),
Tools: reg,
Hooks: hookDispatcher(hook.PreToolUse,
&argTransformExecutor{newArgs: json.RawMessage(`{"command":"safe-replacement"}`)}),
@@ -254,7 +254,7 @@ func TestHook_PostToolUse_Transform(t *testing.T) {
}
eng, _ := New(Config{
Provider: mp,
Provider: secureMock(mp),
Tools: reg,
Hooks: hookDispatcher(hook.PostToolUse,
&resultTransformExecutor{newOutput: "transformed output"}),
@@ -293,7 +293,7 @@ func TestHook_PostToolUse_DenyTreatedAsSkip(t *testing.T) {
}
eng, _ := New(Config{
Provider: mp,
Provider: secureMock(mp),
Tools: reg,
Hooks: hookDispatcher(hook.PostToolUse, &blockingExecutor{}),
})
@@ -334,7 +334,7 @@ func TestHook_Stop_MaxTurns(t *testing.T) {
),
})
eng, _ := New(Config{Provider: mp, Tools: reg, Hooks: d, MaxTurns: 1})
eng, _ := New(Config{Provider: secureMock(mp), Tools: reg, Hooks: d, MaxTurns: 1})
_, err := eng.Submit(context.Background(), "run", nil)
// MaxTurns exceeded returns an error
if err == nil {
+5 -5
View File
@@ -72,7 +72,7 @@ func TestSubmitWithOptions_AllowedPaths_DeniesBash(t *testing.T) {
},
}
e, _ := New(Config{Provider: mp, Tools: reg})
e, _ := New(Config{Provider: secureMock(mp), Tools: reg})
_, err := e.SubmitWithOptions(context.Background(), "run bash",
TurnOptions{AllowedPaths: []string{"/tmp"}}, nil)
@@ -111,7 +111,7 @@ func TestSubmitWithOptions_AllowedPaths_DeniesOutsidePath(t *testing.T) {
},
}
e, _ := New(Config{Provider: mp, Tools: reg})
e, _ := New(Config{Provider: secureMock(mp), Tools: reg})
_, err := e.SubmitWithOptions(context.Background(), "read file",
TurnOptions{AllowedPaths: []string{"/tmp"}}, nil)
@@ -150,7 +150,7 @@ func TestSubmitWithOptions_AllowedPaths_AllowsInsidePath(t *testing.T) {
},
}
e, _ := New(Config{Provider: mp, Tools: reg})
e, _ := New(Config{Provider: secureMock(mp), Tools: reg})
_, err := e.SubmitWithOptions(context.Background(), "read file",
TurnOptions{AllowedPaths: []string{"/tmp/allowed"}}, nil)
@@ -189,7 +189,7 @@ func TestSubmitWithOptions_NilAllowedPaths_NoRestriction(t *testing.T) {
},
}
e, _ := New(Config{Provider: mp, Tools: reg})
e, _ := New(Config{Provider: secureMock(mp), Tools: reg})
// No AllowedPaths → no restriction
_, err := e.SubmitWithOptions(context.Background(), "read file", TurnOptions{}, nil)
@@ -230,7 +230,7 @@ func TestSubmitWithOptions_AllowedPaths_NonPathSensitiveToolAllowed(t *testing.T
// Register arm with tool support
from := provider.Capabilities{ToolUse: true}
_ = from
e, _ := New(Config{Provider: mp, Tools: reg})
e, _ := New(Config{Provider: secureMock(mp), Tools: reg})
_, err := e.SubmitWithOptions(context.Background(), "get info",
TurnOptions{AllowedPaths: []string{"/tmp"}}, nil)
+1 -1
View File
@@ -53,7 +53,7 @@ func TestEngine_ConcurrentSubmitAndSetters(t *testing.T) {
name: "test",
streams: []stream.Stream{newBlockingStream(release, "mock-model")},
}
e, _ := New(Config{Provider: mp, Tools: tool.NewRegistry()})
e, _ := New(Config{Provider: secureMock(mp), Tools: tool.NewRegistry()})
var wg sync.WaitGroup
wg.Add(2)
+9 -9
View File
@@ -28,7 +28,7 @@ func (d *deferredMockTool) Execute(_ context.Context, _ json.RawMessage) (tool.R
func TestSetHistory_ReplacesHistory(t *testing.T) {
e, _ := New(Config{
Provider: &mockProvider{name: "test"},
Provider: secureMock(&mockProvider{name: "test"}),
Tools: tool.NewRegistry(),
})
@@ -59,7 +59,7 @@ func TestSetHistory_OverwritesPreviousHistory(t *testing.T) {
),
},
}
e, _ := New(Config{Provider: mp, Tools: tool.NewRegistry()})
e, _ := New(Config{Provider: secureMock(mp), Tools: tool.NewRegistry()})
_, _ = e.Submit(context.Background(), "first message", nil)
if len(e.History()) == 0 {
@@ -83,7 +83,7 @@ func TestSetHistory_OverwritesPreviousHistory(t *testing.T) {
func TestSetHistory_SyncsContextWindow(t *testing.T) {
ctxWindow := gnomactx.NewWindow(gnomactx.WindowConfig{MaxTokens: 200_000})
e, _ := New(Config{
Provider: &mockProvider{name: "test"},
Provider: secureMock(&mockProvider{name: "test"}),
Tools: tool.NewRegistry(),
Context: ctxWindow,
})
@@ -106,7 +106,7 @@ func TestSetHistory_SyncsContextWindow(t *testing.T) {
func TestSetHistory_SyncsTrackerTokenCount(t *testing.T) {
ctxWindow := gnomactx.NewWindow(gnomactx.WindowConfig{MaxTokens: 200_000})
e, _ := New(Config{
Provider: &mockProvider{name: "test"},
Provider: secureMock(&mockProvider{name: "test"}),
Tools: tool.NewRegistry(),
Context: ctxWindow,
})
@@ -130,7 +130,7 @@ func TestSetHistory_SyncsTrackerTokenCount(t *testing.T) {
func TestSetHistory_NilContextWindow_NoPanic(t *testing.T) {
e, _ := New(Config{
Provider: &mockProvider{name: "test"},
Provider: secureMock(&mockProvider{name: "test"}),
Tools: tool.NewRegistry(),
// Context intentionally nil
})
@@ -147,7 +147,7 @@ func TestSetHistory_NilContextWindow_NoPanic(t *testing.T) {
func TestSetUsage_ReplacesUsage(t *testing.T) {
e, _ := New(Config{
Provider: &mockProvider{name: "test"},
Provider: secureMock(&mockProvider{name: "test"}),
Tools: tool.NewRegistry(),
})
@@ -173,7 +173,7 @@ func TestSetUsage_OverwritesPreviousUsage(t *testing.T) {
),
},
}
e, _ := New(Config{Provider: mp, Tools: tool.NewRegistry()})
e, _ := New(Config{Provider: secureMock(mp), Tools: tool.NewRegistry()})
_, _ = e.Submit(context.Background(), "hello", nil)
if e.Usage().InputTokens == 0 {
@@ -205,7 +205,7 @@ func TestSetActivatedTools_DeferredToolIncludedInRequest(t *testing.T) {
},
}
e, _ := New(Config{Provider: mp, Tools: reg})
e, _ := New(Config{Provider: secureMock(mp), Tools: reg})
// Before activation: buildRequest should omit "bash" (deferred).
reqBefore := e.buildRequest(context.Background())
@@ -237,7 +237,7 @@ func TestSetActivatedTools_EmptyMap_DeactivatesAll(t *testing.T) {
reg.Register(&deferredMockTool{name: "bash"})
mp := &mockProvider{name: "test"}
e, _ := New(Config{Provider: mp, Tools: reg})
e, _ := New(Config{Provider: secureMock(mp), Tools: reg})
// Manually activate, then restore to empty.
e.activatedTools["bash"] = true
+2 -4
View File
@@ -31,8 +31,6 @@ func (m *recordingProvider) Models(_ context.Context) ([]provider.ModelInfo, err
Capabilities: provider.Capabilities{ToolUse: true, ContextWindow: 8192},
}}, nil
}
func (m *recordingProvider) IsSecure() bool { return true }
func (m *recordingProvider) Stream(_ context.Context, req provider.Request) (stream.Stream, error) {
m.mu.Lock()
defer m.mu.Unlock()
@@ -90,7 +88,7 @@ func TestTwoStage_FullRoundTrip(t *testing.T) {
}
e, err := New(Config{
Provider: mp,
Provider: secureMock(mp),
Tools: reg,
ForceTwoStageTools: true, // no router needed; just force the path
})
@@ -161,7 +159,7 @@ func TestTwoStage_InvalidCategoryFallsBackToRoundOne(t *testing.T) {
}
e, err := New(Config{
Provider: mp,
Provider: secureMock(mp),
Tools: reg,
ForceTwoStageTools: true,
})
+13 -13
View File
@@ -29,14 +29,14 @@ func twoStageEngine(t *testing.T, reg *tool.Registry) *Engine {
rtr := router.New(router.Config{})
rtr.RegisterArm(&router.Arm{
ID: "llamacpp/qwen3-1b",
Provider: &mockProvider{name: "llamacpp"},
Provider: secureMock(&mockProvider{name: "llamacpp"}),
ModelName: "qwen3-1b",
IsLocal: true,
Capabilities: provider.Capabilities{ToolUse: true, ContextWindow: 8192},
})
rtr.ForceArm("llamacpp/qwen3-1b")
e, err := New(Config{
Provider: &mockProvider{name: "llamacpp"},
Provider: secureMock(&mockProvider{name: "llamacpp"}),
Router: rtr,
Tools: reg,
})
@@ -49,28 +49,28 @@ func twoStageEngine(t *testing.T, reg *tool.Registry) *Engine {
func TestUseTwoStageTools(t *testing.T) {
smallLocal := &router.Arm{
ID: "llamacpp/qwen3-1b",
Provider: &mockProvider{name: "llamacpp"},
Provider: secureMock(&mockProvider{name: "llamacpp"}),
ModelName: "qwen3-1b",
IsLocal: true,
Capabilities: provider.Capabilities{ToolUse: true, ContextWindow: 8192},
}
bigLocal := &router.Arm{
ID: "llamacpp/qwen3-30b",
Provider: &mockProvider{name: "llamacpp"},
Provider: secureMock(&mockProvider{name: "llamacpp"}),
ModelName: "qwen3-30b",
IsLocal: true,
Capabilities: provider.Capabilities{ToolUse: true, ContextWindow: 32768},
}
cloud := &router.Arm{
ID: "anthropic/sonnet",
Provider: &mockProvider{name: "anthropic"},
Provider: secureMock(&mockProvider{name: "anthropic"}),
ModelName: "sonnet",
IsLocal: false,
Capabilities: provider.Capabilities{ToolUse: true, ContextWindow: 200000},
}
localUnknownCtx := &router.Arm{
ID: "ollama/mystery",
Provider: &mockProvider{name: "ollama"},
Provider: secureMock(&mockProvider{name: "ollama"}),
ModelName: "mystery",
IsLocal: true,
Capabilities: provider.Capabilities{ToolUse: true, ContextWindow: 0},
@@ -118,7 +118,7 @@ func TestUseTwoStageTools(t *testing.T) {
rtr.ForceArm(tc.arm.ID)
e, err := New(Config{
Provider: &mockProvider{name: string(tc.arm.ID.Provider())},
Provider: secureMock(&mockProvider{name: string(tc.arm.ID.Provider())}),
Router: rtr,
Tools: tool.NewRegistry(),
ForceTwoStageTools: tc.forced,
@@ -136,7 +136,7 @@ func TestUseTwoStageTools(t *testing.T) {
func TestUseTwoStageTools_NoRouter(t *testing.T) {
e, err := New(Config{
Provider: &mockProvider{name: "anthropic"},
Provider: secureMock(&mockProvider{name: "anthropic"}),
Tools: tool.NewRegistry(),
})
if err != nil {
@@ -149,7 +149,7 @@ func TestUseTwoStageTools_NoRouter(t *testing.T) {
func TestUseTwoStageTools_NoRouter_ForcedOverride(t *testing.T) {
e, err := New(Config{
Provider: &mockProvider{name: "anthropic"},
Provider: secureMock(&mockProvider{name: "anthropic"}),
Tools: tool.NewRegistry(),
ForceTwoStageTools: true,
})
@@ -165,14 +165,14 @@ func TestUseTwoStageTools_NoForcedArm(t *testing.T) {
rtr := router.New(router.Config{})
rtr.RegisterArm(&router.Arm{
ID: "llamacpp/qwen3-1b",
Provider: &mockProvider{name: "llamacpp"},
Provider: secureMock(&mockProvider{name: "llamacpp"}),
ModelName: "qwen3-1b",
IsLocal: true,
Capabilities: provider.Capabilities{ToolUse: true, ContextWindow: 8192},
})
// No ForceArm called — multi-arm routing
e, err := New(Config{
Provider: &mockProvider{name: "llamacpp"},
Provider: secureMock(&mockProvider{name: "llamacpp"}),
Router: rtr,
Tools: tool.NewRegistry(),
})
@@ -286,7 +286,7 @@ func TestBuildRequest_NonTwoStage_UnchangedBehavior(t *testing.T) {
rtr := router.New(router.Config{})
rtr.RegisterArm(&router.Arm{
ID: "llamacpp/qwen3-30b",
Provider: &mockProvider{name: "llamacpp"},
Provider: secureMock(&mockProvider{name: "llamacpp"}),
ModelName: "qwen3-30b",
IsLocal: true,
Capabilities: provider.Capabilities{ToolUse: true, ContextWindow: 32768},
@@ -298,7 +298,7 @@ func TestBuildRequest_NonTwoStage_UnchangedBehavior(t *testing.T) {
reg.Register(&categorizedMockTool{mockTool: mockTool{name: "fs.write"}, cat: tool.CategoryWrite})
e, err := New(Config{
Provider: &mockProvider{name: "llamacpp"},
Provider: secureMock(&mockProvider{name: "llamacpp"}),
Router: rtr,
Tools: reg,
})
@@ -44,8 +44,6 @@ func (p *recordingProvider) Stream(_ context.Context, req provider.Request) (str
},
}, nil
}
func (p *recordingProvider) IsSecure() bool { return true }
type finalEventStream struct {
events []stream.Event
idx int
+7 -4
View File
@@ -6,17 +6,20 @@ import (
"time"
"somegit.dev/Owlibou/gnoma/internal/provider"
"somegit.dev/Owlibou/gnoma/internal/security"
)
// ArmID uniquely identifies a model+provider pair.
type ArmID string
// SecureProvider is the interface that all router arms must satisfy.
// It ensures that the provider has been wrapped with security controls
// (e.g. security.SafeProvider).
// SecureProvider is the interface that all router arms must satisfy. It
// embeds security.Marker — a sealed trait whose unexported marker method
// can only be satisfied by types defined in internal/security. That makes
// "the provider passed in has been wrapped" a compile-time guarantee, not
// a convention enforced by reviewers.
type SecureProvider interface {
provider.Provider
IsSecure() bool
security.Marker
}
// Arm represents a provider+model pair available for routing.
+2 -2
View File
@@ -10,6 +10,7 @@ import (
"testing"
"somegit.dev/Owlibou/gnoma/internal/provider"
"somegit.dev/Owlibou/gnoma/internal/security"
"somegit.dev/Owlibou/gnoma/internal/stream"
)
@@ -144,7 +145,7 @@ func TestReconcileArms_NoForcedArm(t *testing.T) {
}
factory := func(name, model string) SecureProvider {
return &stubProvider{name: name, model: model}
return security.WrapProvider(&stubProvider{name: name, model: model}, nil)
}
reconcileArms(r, discovered, factory, slog.Default(), nil)
@@ -216,7 +217,6 @@ func (s *stubProvider) Models(_ context.Context) ([]provider.ModelInfo, error) {
func (s *stubProvider) Stream(_ context.Context, _ provider.Request) (stream.Stream, error) {
return nil, nil
}
func (s *stubProvider) IsSecure() bool { return true }
// --- DiscoverOllama / cache + default context size ---
+13 -3
View File
@@ -40,11 +40,21 @@ func (p *SafeProvider) Inner() provider.Provider {
return p.inner
}
// IsSecure returns true. Satisfies the router's SecureProvider interface.
func (p *SafeProvider) IsSecure() bool {
return true
// Marker is a sealed-trait interface only types defined in this package
// can satisfy. Higher layers (e.g. router.SecureProvider) embed Marker so
// the compiler — not convention — enforces that any provider flowing
// through them has been wrapped here. Adding `func (x *Foo) secured() {}`
// to a non-wrapped type in another package would not satisfy this
// interface, because Go method sets distinguish unexported methods by
// their defining package.
type Marker interface {
secured()
}
// secured is the marker method that seals SecureProvider's embedded
// Marker interface. Intentionally no-op.
func (p *SafeProvider) secured() {}
func (p *SafeProvider) Stream(ctx context.Context, req provider.Request) (stream.Stream, error) {
if p.fwRef != nil {
if fw := p.fwRef.Get(); fw != nil {
+16 -9
View File
@@ -12,6 +12,8 @@ import (
"somegit.dev/Owlibou/gnoma/internal/engine"
"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"
"somegit.dev/Owlibou/gnoma/internal/tool"
)
@@ -26,7 +28,6 @@ type mockProvider struct {
func (m *mockProvider) Name() string { return m.name }
func (m *mockProvider) DefaultModel() string { return "mock-model" }
func (m *mockProvider) IsSecure() bool { return true }
func (m *mockProvider) Models(_ context.Context) ([]provider.ModelInfo, error) {
return nil, nil
}
@@ -39,6 +40,12 @@ func (m *mockProvider) Stream(_ context.Context, _ provider.Request) (stream.Str
return s, nil
}
// secureMock wraps a test provider in *security.SafeProvider so it
// satisfies router.SecureProvider's sealed Marker.
func secureMock(p provider.Provider) router.SecureProvider {
return security.WrapProvider(p, nil)
}
type eventStream struct {
events []stream.Event
idx int
@@ -67,7 +74,7 @@ func TestLocal_SendAndReceive(t *testing.T) {
},
}
eng, _ := engine.New(engine.Config{Provider: mp, Tools: tool.NewRegistry()})
eng, _ := engine.New(engine.Config{Provider: secureMock(mp), Tools: tool.NewRegistry()})
sess := NewLocal(LocalConfig{Engine: eng, Provider: "test", Model: "mock-model"})
// Initial state
@@ -122,7 +129,7 @@ func TestLocal_SendWhileBusy(t *testing.T) {
},
}
eng, _ := engine.New(engine.Config{Provider: mp, Tools: tool.NewRegistry()})
eng, _ := engine.New(engine.Config{Provider: secureMock(mp), Tools: tool.NewRegistry()})
sess := NewLocal(LocalConfig{Engine: eng, Provider: "test", Model: "model"})
_ = sess.Send("first")
@@ -149,7 +156,7 @@ func TestLocal_Cancel(t *testing.T) {
streams: []stream.Stream{&slowStream{events: events}},
}
eng, _ := engine.New(engine.Config{Provider: mp, Tools: tool.NewRegistry()})
eng, _ := engine.New(engine.Config{Provider: secureMock(mp), Tools: tool.NewRegistry()})
sess := NewLocal(LocalConfig{Engine: eng, Provider: "test", Model: "model"})
_ = sess.Send("slow task")
@@ -172,7 +179,7 @@ func TestLocal_Cancel(t *testing.T) {
func TestLocal_Close(t *testing.T) {
mp := &mockProvider{name: "test"}
eng, _ := engine.New(engine.Config{Provider: mp, Tools: tool.NewRegistry()})
eng, _ := engine.New(engine.Config{Provider: secureMock(mp), Tools: tool.NewRegistry()})
sess := NewLocal(LocalConfig{Engine: eng, Provider: "test", Model: "model"})
if err := sess.Close(); err != nil {
@@ -200,7 +207,7 @@ func TestLocal_StatusTracking(t *testing.T) {
},
}
eng, _ := engine.New(engine.Config{Provider: mp, Tools: tool.NewRegistry()})
eng, _ := engine.New(engine.Config{Provider: secureMock(mp), Tools: tool.NewRegistry()})
sess := NewLocal(LocalConfig{Engine: eng, Provider: "test", Model: "mock-model"})
// Turn 1
@@ -259,7 +266,7 @@ func TestLocal_AutoSave(t *testing.T) {
},
}
eng, _ := engine.New(engine.Config{Provider: mp, Tools: tool.NewRegistry()})
eng, _ := engine.New(engine.Config{Provider: secureMock(mp), Tools: tool.NewRegistry()})
store := NewSessionStore(t.TempDir(), 10, slog.Default())
sess := NewLocal(LocalConfig{
Engine: eng,
@@ -303,7 +310,7 @@ func TestLocal_AutoSave_SkipsWhenNoStore(t *testing.T) {
},
}
eng, _ := engine.New(engine.Config{Provider: mp, Tools: tool.NewRegistry()})
eng, _ := engine.New(engine.Config{Provider: secureMock(mp), Tools: tool.NewRegistry()})
// No store — must not panic
sess := NewLocal(LocalConfig{Engine: eng, Provider: "test", Model: "mock-model"})
@@ -321,7 +328,7 @@ func TestLocal_AutoSave_SkipsWhenNoStore(t *testing.T) {
func TestLocal_SessionID(t *testing.T) {
mp := &mockProvider{name: "test"}
eng, _ := engine.New(engine.Config{Provider: mp, Tools: tool.NewRegistry()})
eng, _ := engine.New(engine.Config{Provider: secureMock(mp), Tools: tool.NewRegistry()})
sess := NewLocal(LocalConfig{Engine: eng, Provider: "test", Model: "m", SessionID: "my-id"})
if sess.SessionID() != "my-id" {
t.Errorf("SessionID() = %q, want %q", sess.SessionID(), "my-id")
-1
View File
@@ -24,7 +24,6 @@ func (m *mockProvider) DefaultModel() string { return "default" }
func (m *mockProvider) Models(_ context.Context) ([]provider.ModelInfo, error) {
return nil, nil
}
func (m *mockProvider) IsSecure() bool { return true }
func (m *mockProvider) Stream(ctx context.Context, _ provider.Request) (stream.Stream, error) {
if m.delay > 0 {
select {