Files
gnoma/internal/engine/paths.go
T
vikingowl 9853a522e6 refactor(security): consolidate TOCTOU-safe path canonicalization
3c87527 added engine/paths.go:resolveCanonical, duplicating the
ancestor-walk + EvalSymlinks algorithm that already lived in
fs/guard.go:ResolveWrite. Two implementations of the same TOCTOU defense
is exactly the wrong shape for security code — a bug fix in one would
silently miss the other.

Extracts the shared algorithm to security.CanonicalizePath. Both call
sites become thin wrappers that pre-anchor relative paths against the
appropriate root (cwd for engine, workspace root for guard). The
"hit-root" defensive branch in engine's version (commented "highly
unlikely") is tightened to match guard's error behavior.

Adds focused unit tests for the helper covering existing path,
non-existent leaf, non-existent mid-component, symlinked ancestor, and
relative-path rejection.
2026-05-20 01:50:38 +02:00

111 lines
3.0 KiB
Go

package engine
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"somegit.dev/Owlibou/gnoma/internal/message"
"somegit.dev/Owlibou/gnoma/internal/security"
"somegit.dev/Owlibou/gnoma/internal/tool"
)
// isUnderAllowedPaths reports whether target is equal to or a descendant of any
// path in allowed. Both sides are canonicalized (symlink-evaluated, with the
// non-existent tail preserved) 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 {
if len(allowed) == 0 {
return false
}
canonicalTarget, err := resolveCanonical(target)
if err != nil {
return false
}
sep := string(filepath.Separator)
for _, a := range allowed {
canonicalAllowed, err := resolveCanonical(a)
if err != nil {
continue
}
if canonicalTarget == canonicalAllowed || strings.HasPrefix(canonicalTarget, canonicalAllowed+sep) {
return true
}
}
return false
}
// resolveCanonical absolutises against the process cwd and delegates the
// symlink-aware ancestor walk to security.CanonicalizePath. Kept as a thin
// wrapper so callers in this package can pass relative paths.
func resolveCanonical(path string) (string, error) {
abs, err := filepath.Abs(path)
if err != nil {
return "", err
}
return security.CanonicalizePath(abs)
}
// 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
}