Files
gnoma/internal/engine/paths.go
T
vikingowl 3c875276c9 feat(security): implement multi-wave audit remediation and agy provider support
Implemented full security remediation following Universal Security Pilot protocol:
- W1: Enforced SecureProvider at router and engine boundaries to prevent bypasses.
- W1: Implemented path-sensitive policy for MCP tools.
- W2: Added SHA256 hash verification for SLM downloads (llamafile).
- W3: Enhanced secret redaction for private keys (full body) and high-entropy strings.
- W4: Fixed symlink-based filesystem sandbox escapes in paths and grep.
- W4: Documented CLI agent trust boundaries.

Also added 'agy' (Antigravity) as a subprocess CLI provider with plain-text JSON schema support.
2026-05-20 01:13:13 +02:00

134 lines
3.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 {
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 returns the absolute, symlink-evaluated path.
// If the path doesn't exist, it resolves the deepest existing ancestor and
// appends the remaining tail.
func resolveCanonical(path string) (string, error) {
abs, err := filepath.Abs(path)
if err != nil {
return "", err
}
ancestor := abs
var tail []string
for {
if _, err := os.Lstat(ancestor); err == nil {
break
}
parent := filepath.Dir(ancestor)
if parent == ancestor {
// Hit root, nothing exists? highly unlikely for Abs() but handle it.
break
}
tail = append([]string{filepath.Base(ancestor)}, tail...)
ancestor = parent
}
canonicalAncestor, err := filepath.EvalSymlinks(ancestor)
if err != nil {
return "", err
}
resolved := canonicalAncestor
if len(tail) > 0 {
resolved = filepath.Join(append([]string{canonicalAncestor}, tail...)...)
}
return filepath.Clean(resolved), nil
}
// 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
}