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
87 lines
2.4 KiB
Go
87 lines
2.4 KiB
Go
package engine
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"somegit.dev/Owlibou/gnoma/internal/message"
|
|
"somegit.dev/Owlibou/gnoma/internal/tool"
|
|
)
|
|
|
|
// isUnderAllowedPaths reports whether target is equal to or a descendant of any
|
|
// path in allowed. Both sides are cleaned before comparison. Returns false when
|
|
// allowed is empty.
|
|
//
|
|
// The trailing-separator check prevents "/tmp" from matching "/tmpx/foo".
|
|
func isUnderAllowedPaths(target string, allowed []string) bool {
|
|
target = filepath.Clean(target)
|
|
sep := string(filepath.Separator)
|
|
for _, a := range allowed {
|
|
a = filepath.Clean(a)
|
|
if target == a || strings.HasPrefix(target, a+sep) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// checkPathRestriction enforces AllowedPaths on a single tool call.
|
|
//
|
|
// Rules (in order):
|
|
// 1. If allowed is empty, everything is permitted (fast-path).
|
|
// 2. "bash" is always denied when path restrictions are active.
|
|
// 3. Tools implementing tool.PathSensitiveTool have their extracted paths
|
|
// checked against allowed. An empty extracted path is resolved to cwd.
|
|
// 4. Tools that do not implement PathSensitiveTool are permitted (they don't
|
|
// declare filesystem access).
|
|
//
|
|
// Returns (denied result, true) when blocked, or (zero, false) when allowed.
|
|
func checkPathRestriction(call message.ToolCall, t tool.Tool, args json.RawMessage, allowed []string) (message.ToolResult, bool) {
|
|
if len(allowed) == 0 {
|
|
return message.ToolResult{}, false
|
|
}
|
|
|
|
if call.Name == "bash" {
|
|
return message.ToolResult{
|
|
ToolCallID: call.ID,
|
|
Content: "bash is not permitted when skill path restrictions are active",
|
|
IsError: true,
|
|
}, true
|
|
}
|
|
|
|
pt, ok := t.(tool.PathSensitiveTool)
|
|
if !ok {
|
|
return message.ToolResult{}, false
|
|
}
|
|
|
|
for _, p := range pt.ExtractPaths(args) {
|
|
var resolved string
|
|
if p == "" {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return message.ToolResult{
|
|
ToolCallID: call.ID,
|
|
Content: fmt.Sprintf("path access denied: cannot determine current directory: %v", err),
|
|
IsError: true,
|
|
}, true
|
|
}
|
|
resolved = cwd
|
|
} else {
|
|
resolved = p
|
|
}
|
|
|
|
if !isUnderAllowedPaths(resolved, allowed) {
|
|
return message.ToolResult{
|
|
ToolCallID: call.ID,
|
|
Content: fmt.Sprintf("path access denied: %q is not in allowed paths %v", resolved, allowed),
|
|
IsError: true,
|
|
}, true
|
|
}
|
|
}
|
|
|
|
return message.ToolResult{}, false
|
|
}
|