3c875276c9
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.
134 lines
3.4 KiB
Go
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
|
|
}
|