Files
gnoma/internal/engine/paths.go
T
vikingowl 995b08dc0f feat(engine): M8 cleanup — Wave B skill enforcement
- 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
2026-05-07 15:29:33 +02:00

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
}