995b08dc0f
- Add tool.PathSensitiveTool interface (ExtractPaths); implement on all 6 fs tools - Add engine.TurnOptions.AllowedPaths: restricts tool filesystem access per skill invocation - Bash is denied outright when AllowedPaths is active (unparseable command args) - fs tools with empty path (cwd default) resolved via os.Getwd() and validated - Add engine.TurnOptions.AllowedTools + AllowedPaths wiring in pipe mode (main.go) and TUI skill dispatch (tui/app.go) - Remove TODO(M8.3) from skill.Frontmatter — enforcement is now complete
244 lines
7.3 KiB
Go
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: 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: 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: 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: 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: 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")
|
|
}
|
|
}
|