Files
gnoma/internal/engine/paths_test.go
T
vikingowl fb42202834 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.
2026-05-20 02:04:07 +02:00

244 lines
7.3 KiB
Go

package engine
import (
"context"
"encoding/json"
"testing"
"somegit.dev/Owlibou/gnoma/internal/message"
"somegit.dev/Owlibou/gnoma/internal/provider"
"somegit.dev/Owlibou/gnoma/internal/stream"
"somegit.dev/Owlibou/gnoma/internal/tool"
)
func TestIsUnderAllowedPaths(t *testing.T) {
tests := []struct {
name string
target string
allowed []string
want bool
}{
{"exact match", "/tmp/foo", []string{"/tmp/foo"}, true},
{"under allowed dir", "/tmp/foo/bar.go", []string{"/tmp"}, true},
{"not under allowed", "/etc/passwd", []string{"/tmp"}, false},
{"prevents prefix bypass", "/tmpx/foo", []string{"/tmp"}, false},
{"matches second path", "/home/user/file", []string{"/tmp", "/home/user"}, true},
{"empty allowed slice", "/tmp/foo", []string{}, false},
{"nested dir", "/project/src/main.go", []string{"/project"}, true},
{"sibling dir denied", "/project-evil/foo", []string{"/project"}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := isUnderAllowedPaths(tc.target, tc.allowed)
if got != tc.want {
t.Errorf("isUnderAllowedPaths(%q, %v) = %v, want %v",
tc.target, tc.allowed, got, tc.want)
}
})
}
}
// mockPathSensitiveTool is a mock that implements both Tool and PathSensitiveTool.
type mockPathSensitiveTool struct {
mockTool
extractedPaths []string
}
func (m *mockPathSensitiveTool) ExtractPaths(_ json.RawMessage) []string {
return m.extractedPaths
}
func TestSubmitWithOptions_AllowedPaths_DeniesBash(t *testing.T) {
called := false
reg := tool.NewRegistry()
reg.Register(&mockTool{
name: "bash",
execFn: func(_ context.Context, _ json.RawMessage) (tool.Result, error) {
called = true
return tool.Result{Output: "should not reach here"}, nil
},
})
mp := &mockProvider{
name: "test",
streams: []stream.Stream{
newEventStream(message.StopToolUse, "",
stream.Event{Type: stream.EventToolCallStart, ToolCallID: "tc1", ToolCallName: "bash"},
stream.Event{Type: stream.EventToolCallDone, ToolCallID: "tc1", Args: json.RawMessage(`{"command":"cat /etc/passwd"}`)},
),
newEventStream(message.StopEndTurn, "",
stream.Event{Type: stream.EventTextDelta, Text: "blocked"},
),
},
}
e, _ := New(Config{Provider: secureMock(mp), Tools: reg})
_, err := e.SubmitWithOptions(context.Background(), "run bash",
TurnOptions{AllowedPaths: []string{"/tmp"}}, nil)
if err != nil {
t.Fatalf("SubmitWithOptions: %v", err)
}
if called {
t.Error("bash tool should not be executed when AllowedPaths is set")
}
}
func TestSubmitWithOptions_AllowedPaths_DeniesOutsidePath(t *testing.T) {
called := false
reg := tool.NewRegistry()
reg.Register(&mockPathSensitiveTool{
mockTool: mockTool{
name: "fs.read",
execFn: func(_ context.Context, _ json.RawMessage) (tool.Result, error) {
called = true
return tool.Result{Output: "secret content"}, nil
},
},
extractedPaths: []string{"/etc/passwd"},
})
mp := &mockProvider{
name: "test",
streams: []stream.Stream{
newEventStream(message.StopToolUse, "",
stream.Event{Type: stream.EventToolCallStart, ToolCallID: "tc1", ToolCallName: "fs.read"},
stream.Event{Type: stream.EventToolCallDone, ToolCallID: "tc1", Args: json.RawMessage(`{"path":"/etc/passwd"}`)},
),
newEventStream(message.StopEndTurn, "",
stream.Event{Type: stream.EventTextDelta, Text: "blocked"},
),
},
}
e, _ := New(Config{Provider: secureMock(mp), Tools: reg})
_, err := e.SubmitWithOptions(context.Background(), "read file",
TurnOptions{AllowedPaths: []string{"/tmp"}}, nil)
if err != nil {
t.Fatalf("SubmitWithOptions: %v", err)
}
if called {
t.Error("fs.read should not be executed when path is outside AllowedPaths")
}
}
func TestSubmitWithOptions_AllowedPaths_AllowsInsidePath(t *testing.T) {
called := false
reg := tool.NewRegistry()
reg.Register(&mockPathSensitiveTool{
mockTool: mockTool{
name: "fs.read",
execFn: func(_ context.Context, _ json.RawMessage) (tool.Result, error) {
called = true
return tool.Result{Output: "ok"}, nil
},
},
extractedPaths: []string{"/tmp/allowed/file.txt"},
})
mp := &mockProvider{
name: "test",
streams: []stream.Stream{
newEventStream(message.StopToolUse, "",
stream.Event{Type: stream.EventToolCallStart, ToolCallID: "tc1", ToolCallName: "fs.read"},
stream.Event{Type: stream.EventToolCallDone, ToolCallID: "tc1", Args: json.RawMessage(`{"path":"/tmp/allowed/file.txt"}`)},
),
newEventStream(message.StopEndTurn, "",
stream.Event{Type: stream.EventTextDelta, Text: "done"},
),
},
}
e, _ := New(Config{Provider: secureMock(mp), Tools: reg})
_, err := e.SubmitWithOptions(context.Background(), "read file",
TurnOptions{AllowedPaths: []string{"/tmp/allowed"}}, nil)
if err != nil {
t.Fatalf("SubmitWithOptions: %v", err)
}
if !called {
t.Error("fs.read should be executed when path is inside AllowedPaths")
}
}
func TestSubmitWithOptions_NilAllowedPaths_NoRestriction(t *testing.T) {
called := false
reg := tool.NewRegistry()
reg.Register(&mockPathSensitiveTool{
mockTool: mockTool{
name: "fs.read",
execFn: func(_ context.Context, _ json.RawMessage) (tool.Result, error) {
called = true
return tool.Result{Output: "ok"}, nil
},
},
extractedPaths: []string{"/etc/passwd"},
})
mp := &mockProvider{
name: "test",
streams: []stream.Stream{
newEventStream(message.StopToolUse, "",
stream.Event{Type: stream.EventToolCallStart, ToolCallID: "tc1", ToolCallName: "fs.read"},
stream.Event{Type: stream.EventToolCallDone, ToolCallID: "tc1", Args: json.RawMessage(`{"path":"/etc/passwd"}`)},
),
newEventStream(message.StopEndTurn, "",
stream.Event{Type: stream.EventTextDelta, Text: "done"},
),
},
}
e, _ := New(Config{Provider: secureMock(mp), Tools: reg})
// No AllowedPaths → no restriction
_, err := e.SubmitWithOptions(context.Background(), "read file", TurnOptions{}, nil)
if err != nil {
t.Fatalf("SubmitWithOptions: %v", err)
}
if !called {
t.Error("fs.read should be executed when AllowedPaths is not set")
}
}
func TestSubmitWithOptions_AllowedPaths_NonPathSensitiveToolAllowed(t *testing.T) {
// A tool that doesn't implement PathSensitiveTool should be permitted even
// when AllowedPaths is set — it doesn't access the filesystem directly.
called := false
reg := tool.NewRegistry()
reg.Register(&mockTool{ // plain mockTool, not PathSensitiveTool
name: "sysinfo",
execFn: func(_ context.Context, _ json.RawMessage) (tool.Result, error) {
called = true
return tool.Result{Output: "linux"}, nil
},
})
mp := &mockProvider{
name: "test",
streams: []stream.Stream{
newEventStream(message.StopToolUse, "",
stream.Event{Type: stream.EventToolCallStart, ToolCallID: "tc1", ToolCallName: "sysinfo"},
stream.Event{Type: stream.EventToolCallDone, ToolCallID: "tc1", Args: json.RawMessage(`{}`)},
),
newEventStream(message.StopEndTurn, "",
stream.Event{Type: stream.EventTextDelta, Text: "done"},
),
},
}
// Register arm with tool support
from := provider.Capabilities{ToolUse: true}
_ = from
e, _ := New(Config{Provider: secureMock(mp), Tools: reg})
_, err := e.SubmitWithOptions(context.Background(), "get info",
TurnOptions{AllowedPaths: []string{"/tmp"}}, nil)
if err != nil {
t.Fatalf("SubmitWithOptions: %v", err)
}
if !called {
t.Error("non-path-sensitive tool should be permitted regardless of AllowedPaths")
}
}