mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
Add file-backed command prefix grants to the sandbox store and load them through the engine so approved prefixes skip future prompts while still running through sandbox policy checks. Tighten reusable prefix validation for runner-style launchers and script paths, and classify find -delete as destructive. Surface persistent prefix choices in the TUI and include approved prefixes in the run prompt context and sandbox grants list. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/sandbox ./internal/tui ./internal/cli Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check
201 lines
4.3 KiB
Go
201 lines
4.3 KiB
Go
package sandbox
|
|
|
|
import (
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
type commandPrefixGrantSet struct {
|
|
mu sync.Mutex
|
|
grants []CommandPrefixGrant
|
|
}
|
|
|
|
type CommandPrefixGrant struct {
|
|
ToolName string `json:"toolName"`
|
|
Prefix []string `json:"prefix"`
|
|
ApprovedAt string `json:"approvedAt,omitempty"`
|
|
Reason string `json:"reason,omitempty"`
|
|
Session bool `json:"session,omitempty"`
|
|
}
|
|
|
|
type CommandPrefixInput struct {
|
|
ToolName string
|
|
Prefix []string
|
|
Reason string
|
|
}
|
|
|
|
func newCommandPrefixGrantSet() *commandPrefixGrantSet {
|
|
return &commandPrefixGrantSet{}
|
|
}
|
|
|
|
func (set *commandPrefixGrantSet) add(grant CommandPrefixGrant) {
|
|
if set == nil || grant.ToolName == "" || len(grant.Prefix) == 0 {
|
|
return
|
|
}
|
|
set.mu.Lock()
|
|
defer set.mu.Unlock()
|
|
for _, existing := range set.grants {
|
|
if existing.ToolName == grant.ToolName && sameStringSlice(existing.Prefix, grant.Prefix) {
|
|
return
|
|
}
|
|
}
|
|
grant.Prefix = append([]string(nil), grant.Prefix...)
|
|
set.grants = append(set.grants, grant)
|
|
}
|
|
|
|
func (set *commandPrefixGrantSet) match(toolName string, command []string) (CommandPrefixGrant, bool) {
|
|
if set == nil || toolName == "" || len(command) == 0 {
|
|
return CommandPrefixGrant{}, false
|
|
}
|
|
set.mu.Lock()
|
|
defer set.mu.Unlock()
|
|
for _, grant := range set.grants {
|
|
if grant.ToolName == toolName && hasStringPrefix(command, grant.Prefix) {
|
|
grant.Prefix = append([]string(nil), grant.Prefix...)
|
|
return grant, true
|
|
}
|
|
}
|
|
return CommandPrefixGrant{}, false
|
|
}
|
|
|
|
func hasStringPrefix(values []string, prefix []string) bool {
|
|
if len(prefix) == 0 || len(prefix) > len(values) {
|
|
return false
|
|
}
|
|
for index := range prefix {
|
|
if values[index] != prefix[index] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func NormalizeCommandPrefix(prefix []string) ([]string, bool) {
|
|
cleaned := make([]string, 0, len(prefix))
|
|
for _, part := range prefix {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
return nil, false
|
|
}
|
|
cleaned = append(cleaned, part)
|
|
}
|
|
if unsafeCommandPrefix(cleaned) {
|
|
return nil, false
|
|
}
|
|
return cleaned, true
|
|
}
|
|
|
|
func ValidCommandPrefix(prefix []string) bool {
|
|
_, ok := NormalizeCommandPrefix(prefix)
|
|
return ok
|
|
}
|
|
|
|
var bannedCommandPrefixSuggestions = [][]string{
|
|
{"python3"},
|
|
{"python3", "-"},
|
|
{"python3", "-c"},
|
|
{"python"},
|
|
{"python", "-"},
|
|
{"python", "-c"},
|
|
{"py"},
|
|
{"py", "-3"},
|
|
{"pythonw"},
|
|
{"pyw"},
|
|
{"pypy"},
|
|
{"pypy3"},
|
|
{"git"},
|
|
{"bash"},
|
|
{"bash", "-lc"},
|
|
{"sh"},
|
|
{"sh", "-c"},
|
|
{"sh", "-lc"},
|
|
{"zsh"},
|
|
{"zsh", "-lc"},
|
|
{"/bin/zsh"},
|
|
{"/bin/zsh", "-lc"},
|
|
{"/bin/bash"},
|
|
{"/bin/bash", "-lc"},
|
|
{"pwsh"},
|
|
{"pwsh", "-Command"},
|
|
{"pwsh", "-c"},
|
|
{"powershell"},
|
|
{"powershell", "-Command"},
|
|
{"powershell", "-c"},
|
|
{"powershell.exe"},
|
|
{"powershell.exe", "-Command"},
|
|
{"powershell.exe", "-c"},
|
|
{"env"},
|
|
{"sudo"},
|
|
{"node"},
|
|
{"node", "-e"},
|
|
{"perl"},
|
|
{"perl", "-e"},
|
|
{"ruby"},
|
|
{"ruby", "-e"},
|
|
{"php"},
|
|
{"php", "-r"},
|
|
{"lua"},
|
|
{"lua", "-e"},
|
|
{"osascript"},
|
|
}
|
|
|
|
func unsafeCommandPrefix(prefix []string) bool {
|
|
if len(prefix) == 0 {
|
|
return true
|
|
}
|
|
for _, part := range prefix {
|
|
if unsafeCommandPrefixPart(part) {
|
|
return true
|
|
}
|
|
}
|
|
for _, banned := range bannedCommandPrefixSuggestions {
|
|
if sameStringSlice(prefix, banned) {
|
|
return true
|
|
}
|
|
}
|
|
if unsafeCommandPrefixLauncher(prefix[0]) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func unsafeCommandPrefixPart(part string) bool {
|
|
part = strings.TrimSpace(part)
|
|
return part == "" ||
|
|
strings.ContainsAny(part, "\x00\r\n*?[]{}") ||
|
|
strings.Contains(part, "$(") ||
|
|
strings.Contains(part, "`")
|
|
}
|
|
|
|
func unsafeCommandPrefixLauncher(program string) bool {
|
|
program = strings.ToLower(strings.TrimSpace(program))
|
|
if strings.ContainsAny(program, `/\`) {
|
|
return true
|
|
}
|
|
switch program {
|
|
case "bash", "sh", "zsh", "/bin/bash", "/bin/zsh",
|
|
"pwsh", "powershell", "powershell.exe",
|
|
"env", "sudo", "doas", "su", "run0", "osascript",
|
|
"command", "eval", "exec", "time",
|
|
"find", "xargs", "timeout", "nice", "nohup", "watch", "setsid", "stdbuf", "ionice",
|
|
"ssh", "make", "npm", "npx",
|
|
"python", "python3", "py", "pythonw", "pyw", "pypy", "pypy3",
|
|
"node", "perl", "ruby", "php", "lua", "deno", "bun":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func sameStringSlice(left []string, right []string) bool {
|
|
if len(left) != len(right) {
|
|
return false
|
|
}
|
|
for index := range left {
|
|
if left[index] != right[index] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|