Files
Viet Tran d56813d726 Add per-agent tool policy support with NO_REPLY placeholder cleanup across channels
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.
2026-02-22 22:49:22 +07:00

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] + "..."
}