mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-10 06:10:46 +00:00
d56813d726
Wire AgentToolPolicy from agent spec through loop creation and resolver, passing it to PolicyEngine.FilterTools for per-agent tool restrictions (nil = no restrictions). Set RestrictToWorkspace=true for default agent in managed mode seeding. Clean up thinking/placeholder messages in Discord and Telegram when agent suppresses empty/NO_REPLY responses by publishing empty outbound and deleting placeholders without sending messages.
281 lines
7.7 KiB
Go
281 lines
7.7 KiB
Go
package tools
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// ExecSecurity determines the overall security mode for command execution.
|
|
type ExecSecurity string
|
|
|
|
const (
|
|
// ExecSecurityDeny blocks all commands (no exec tool available).
|
|
ExecSecurityDeny ExecSecurity = "deny"
|
|
|
|
// ExecSecurityAllowlist only allows commands matching the allowlist.
|
|
ExecSecurityAllowlist ExecSecurity = "allowlist"
|
|
|
|
// ExecSecurityFull allows all commands (ask mode still applies).
|
|
ExecSecurityFull ExecSecurity = "full"
|
|
)
|
|
|
|
// ExecAskMode determines when to prompt for user approval.
|
|
type ExecAskMode string
|
|
|
|
const (
|
|
// ExecAskOff never asks — commands are auto-approved.
|
|
ExecAskOff ExecAskMode = "off"
|
|
|
|
// ExecAskOnMiss asks only when a command is not in the allowlist.
|
|
ExecAskOnMiss ExecAskMode = "on-miss"
|
|
|
|
// ExecAskAlways asks for every command execution.
|
|
ExecAskAlways ExecAskMode = "always"
|
|
)
|
|
|
|
// ExecApprovalConfig configures command execution approval.
|
|
type ExecApprovalConfig struct {
|
|
Security ExecSecurity `json:"security"` // "deny", "allowlist", "full" (default "full")
|
|
Ask ExecAskMode `json:"ask"` // "off", "on-miss", "always" (default "off")
|
|
Allowlist []string `json:"allowlist"` // glob patterns for allowed commands
|
|
}
|
|
|
|
// DefaultExecApprovalConfig returns the default (permissive) config.
|
|
func DefaultExecApprovalConfig() ExecApprovalConfig {
|
|
return ExecApprovalConfig{
|
|
Security: ExecSecurityFull,
|
|
Ask: ExecAskOff,
|
|
}
|
|
}
|
|
|
|
// safeBins are command names that are always considered safe.
|
|
// Only includes read-only, text processing, and dev tools.
|
|
// Infrastructure/network tools (docker, kubectl, terraform, ansible,
|
|
// curl, wget, ssh, scp, rsync) are excluded — they require approval
|
|
// when ask mode is "on-miss".
|
|
var safeBins = map[string]bool{
|
|
// Read-only / info tools
|
|
"cat": true, "echo": true, "ls": true, "pwd": true, "head": true,
|
|
"tail": true, "wc": true, "sort": true, "uniq": true, "grep": true,
|
|
"find": true, "which": true, "whoami": true, "date": true,
|
|
"uname": true, "hostname": true,
|
|
"df": true, "du": true, "free": true, "uptime": true, "file": true,
|
|
"stat": true, "dirname": true, "basename": true, "realpath": true,
|
|
// Text processing
|
|
"jq": true, "yq": true, "sed": true, "awk": true, "tr": true,
|
|
"cut": true, "diff": true, "patch": true, "tee": true, "xargs": true,
|
|
// Dev tools (core purpose of a coding agent)
|
|
"git": true, "node": true, "npm": true, "npx": true, "yarn": true,
|
|
"pnpm": true, "bun": true, "deno": true, "python": true, "python3": true,
|
|
"pip": true, "pip3": true, "go": true, "cargo": true, "rustc": true,
|
|
"make": true, "cmake": true, "gcc": true, "g++": true, "clang": true,
|
|
"java": true, "javac": true, "mvn": true, "gradle": true,
|
|
}
|
|
|
|
// ApprovalDecision is the user's response to an approval request.
|
|
type ApprovalDecision string
|
|
|
|
const (
|
|
ApprovalAllowOnce ApprovalDecision = "allow-once"
|
|
ApprovalAllowAlways ApprovalDecision = "allow-always"
|
|
ApprovalDeny ApprovalDecision = "deny"
|
|
)
|
|
|
|
// PendingApproval is an in-flight approval request.
|
|
type PendingApproval struct {
|
|
ID string `json:"id"`
|
|
Command string `json:"command"`
|
|
AgentID string `json:"agentId"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
resultCh chan ApprovalDecision
|
|
}
|
|
|
|
// ExecApprovalManager manages pending approval requests and the dynamic allowlist.
|
|
type ExecApprovalManager struct {
|
|
config ExecApprovalConfig
|
|
pending map[string]*PendingApproval
|
|
alwaysAllow map[string]bool // patterns added via "allow-always" decisions
|
|
mu sync.Mutex
|
|
nextID int
|
|
}
|
|
|
|
// NewExecApprovalManager creates an approval manager with the given config.
|
|
func NewExecApprovalManager(cfg ExecApprovalConfig) *ExecApprovalManager {
|
|
return &ExecApprovalManager{
|
|
config: cfg,
|
|
pending: make(map[string]*PendingApproval),
|
|
alwaysAllow: make(map[string]bool),
|
|
}
|
|
}
|
|
|
|
// CheckCommand evaluates whether a command should be executed, blocked, or needs approval.
|
|
// Returns: "allow", "deny", or "ask".
|
|
func (m *ExecApprovalManager) CheckCommand(command string) string {
|
|
switch m.config.Security {
|
|
case ExecSecurityDeny:
|
|
return "deny"
|
|
|
|
case ExecSecurityAllowlist:
|
|
if m.matchesAllowlist(command) {
|
|
if m.config.Ask == ExecAskAlways {
|
|
return "ask"
|
|
}
|
|
return "allow"
|
|
}
|
|
if m.config.Ask == ExecAskOff {
|
|
return "deny" // not in allowlist, no asking
|
|
}
|
|
return "ask"
|
|
|
|
case ExecSecurityFull:
|
|
switch m.config.Ask {
|
|
case ExecAskOff:
|
|
return "allow"
|
|
case ExecAskAlways:
|
|
return "ask"
|
|
case ExecAskOnMiss:
|
|
if m.matchesAllowlist(command) || m.isSafeBin(command) {
|
|
return "allow"
|
|
}
|
|
return "ask"
|
|
}
|
|
}
|
|
|
|
return "allow"
|
|
}
|
|
|
|
// RequestApproval creates a pending approval and blocks until resolved or timeout.
|
|
func (m *ExecApprovalManager) RequestApproval(command, agentID string, timeout time.Duration) (ApprovalDecision, error) {
|
|
m.mu.Lock()
|
|
m.nextID++
|
|
id := fmt.Sprintf("exec-%d", m.nextID)
|
|
pa := &PendingApproval{
|
|
ID: id,
|
|
Command: command,
|
|
AgentID: agentID,
|
|
CreatedAt: time.Now(),
|
|
resultCh: make(chan ApprovalDecision, 1),
|
|
}
|
|
m.pending[id] = pa
|
|
m.mu.Unlock()
|
|
|
|
slog.Info("exec approval requested", "id", id, "command", truncateCmd(command, 100))
|
|
|
|
// Wait for resolution or timeout
|
|
select {
|
|
case decision := <-pa.resultCh:
|
|
m.mu.Lock()
|
|
delete(m.pending, id)
|
|
m.mu.Unlock()
|
|
|
|
// If allow-always, add the command's base binary to the dynamic allowlist
|
|
if decision == ApprovalAllowAlways {
|
|
bin := extractBin(command)
|
|
if bin != "" {
|
|
m.mu.Lock()
|
|
m.alwaysAllow[bin] = true
|
|
m.mu.Unlock()
|
|
slog.Info("exec approval: added to always-allow", "bin", bin)
|
|
}
|
|
}
|
|
|
|
return decision, nil
|
|
|
|
case <-time.After(timeout):
|
|
m.mu.Lock()
|
|
delete(m.pending, id)
|
|
m.mu.Unlock()
|
|
return ApprovalDeny, fmt.Errorf("approval timed out after %s", timeout)
|
|
}
|
|
}
|
|
|
|
// Resolve resolves a pending approval request.
|
|
func (m *ExecApprovalManager) Resolve(id string, decision ApprovalDecision) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
pa, ok := m.pending[id]
|
|
if !ok {
|
|
return fmt.Errorf("approval %q not found or already resolved", id)
|
|
}
|
|
|
|
pa.resultCh <- decision
|
|
return nil
|
|
}
|
|
|
|
// ListPending returns all pending approval requests.
|
|
func (m *ExecApprovalManager) ListPending() []*PendingApproval {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
result := make([]*PendingApproval, 0, len(m.pending))
|
|
for _, pa := range m.pending {
|
|
result = append(result, pa)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// matchesAllowlist checks if a command matches any allowlist pattern or dynamic always-allow.
|
|
func (m *ExecApprovalManager) matchesAllowlist(command string) bool {
|
|
bin := extractBin(command)
|
|
|
|
// Check dynamic always-allow
|
|
m.mu.Lock()
|
|
if m.alwaysAllow[bin] {
|
|
m.mu.Unlock()
|
|
return true
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
// Check static allowlist patterns
|
|
for _, pattern := range m.config.Allowlist {
|
|
if matched, _ := filepath.Match(pattern, bin); matched {
|
|
return true
|
|
}
|
|
// Also match against full command
|
|
if matched, _ := filepath.Match(pattern, command); matched {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// isSafeBin checks if the command's base binary is in the safe list.
|
|
func (m *ExecApprovalManager) isSafeBin(command string) bool {
|
|
return safeBins[extractBin(command)]
|
|
}
|
|
|
|
// extractBin returns the first word of a command (the binary name).
|
|
func extractBin(command string) string {
|
|
command = strings.TrimSpace(command)
|
|
// Skip env var assignments like FOO=bar cmd
|
|
for strings.Contains(command, "=") {
|
|
parts := strings.SplitN(command, " ", 2)
|
|
if !strings.Contains(parts[0], "=") {
|
|
break
|
|
}
|
|
if len(parts) < 2 {
|
|
return ""
|
|
}
|
|
command = strings.TrimSpace(parts[1])
|
|
}
|
|
|
|
fields := strings.Fields(command)
|
|
if len(fields) == 0 {
|
|
return ""
|
|
}
|
|
return filepath.Base(fields[0])
|
|
}
|
|
|
|
func truncateCmd(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n] + "..."
|
|
}
|