Pulse/internal/agentexec/policy.go
rcourtman 8b077f69ce feat: AI security and policy improvements for 5.0
- Add DOMPurify sanitization for AI chat markdown rendering (XSS fix)
- Configure DOMPurify to add target=_blank and rel=noopener to links
- Update system prompt to align with command approval policy
- Clarify safe vs destructive commands in prompt
- Improve patrol auto-fix mode guidance with safe operation list
- Add verification requirements for auto-fix actions
- Update observe-only mode to be clearer about read-only restrictions
2025-12-12 17:38:55 +00:00

261 lines
5.9 KiB
Go

package agentexec
import (
"regexp"
"strings"
)
// CommandPolicy defines what commands are allowed, blocked, or require approval
type CommandPolicy struct {
// AutoApprove patterns - commands matching these are automatically allowed
AutoApprove []string
// RequireApproval patterns - commands matching these need user approval
RequireApproval []string
// Blocked patterns - commands matching these are never allowed
Blocked []string
// compiled regex patterns
autoApproveRe []*regexp.Regexp
requireApprovalRe []*regexp.Regexp
blockedRe []*regexp.Regexp
}
// DefaultPolicy returns a sensible default command policy
func DefaultPolicy() *CommandPolicy {
p := &CommandPolicy{
AutoApprove: []string{
// System inspection
`^ps(\s|$)`,
`^top\s+-bn`,
`^df(\s|$)`,
`^free(\s|$)`,
`^uptime$`,
`^hostname$`,
`^uname(\s|$)`,
`^cat\s+/proc/`,
`^cat\s+/etc/os-release`,
`^lsof(\s|$)`,
`^netstat(\s|$)`,
`^ss(\s|$)`,
`^ip\s+(addr|route|link)`,
`^ifconfig`,
`^w$`,
`^who$`,
`^last(\s|$)`,
// Log reading (read-only)
`^cat\s+/var/log/`,
`^tail\s+.*(/var/log/|/log/)`,
`^head\s+.*(/var/log/|/log/)`,
`^grep\s+.*(/var/log/|/log/)`,
`^journalctl\s`,
// Service status (read-only)
`^systemctl\s+status\s`,
`^systemctl\s+is-active\s`,
`^systemctl\s+is-enabled\s`,
`^systemctl\s+list-units`,
`^service\s+\S+\s+status`,
// Docker inspection (read-only)
`^docker\s+ps`,
`^docker\s+logs\s`,
`^docker\s+inspect\s`,
`^docker\s+stats\s+--no-stream`,
`^docker\s+top\s`,
`^docker\s+images`,
`^docker\s+network\s+ls`,
`^docker\s+volume\s+ls`,
// Proxmox inspection (read-only)
`^pct\s+list`,
`^pct\s+config\s`,
`^pct\s+status\s`,
`^qm\s+list`,
`^qm\s+config\s`,
`^qm\s+status\s`,
`^pvesh\s+get\s`,
// Disk/storage info
`^lsblk`,
`^blkid`,
`^fdisk\s+-l`,
`^du(\s|$)`,
`^ls(\s|$)`,
`^stat(\s|$)`,
`^file(\s|$)`,
`^find\s+/.*-size`, // Find large files
`^find\s+/.*-mtime`, // Find by modification time
`^find\s+/.*-type`, // Find by type
// Memory inspection
`^vmstat`,
`^sar\s`,
`^iostat`,
`^mpstat`,
// Docker inspection (read-only)
`^docker\s+system\s+df`,
// APT inspection (read-only)
`^apt\s+list`,
`^apt-cache\s`,
`^dpkg\s+-l`,
`^dpkg\s+--list`,
},
RequireApproval: []string{
// Service control
`^systemctl\s+(restart|stop|start|reload)\s`,
`^service\s+\S+\s+(restart|stop|start|reload)`,
// Docker control
`^docker\s+(restart|stop|start|kill)\s`,
`^docker\s+exec\s`,
`^docker\s+rm\s`,
// Process control
`^kill\s`,
`^pkill\s`,
`^killall\s`,
// Package management
`^apt\s`,
`^apt-get\s`,
`^yum\s`,
`^dnf\s`,
`^pacman\s`,
// Proxmox control
`^pct\s+(start|stop|shutdown|reboot|resize|set)\s`,
`^qm\s+(start|stop|shutdown|reboot|reset|resize|set)\s`,
// journalctl maintenance (modifies logs / persistent state)
`^journalctl\s+--vacuum`,
`^journalctl\s+--rotate`,
},
Blocked: []string{
// Destructive filesystem operations
`rm\s+-rf\s+/`,
`rm\s+--no-preserve-root`,
`mkfs`,
`dd\s+.*of=/dev/`,
`>\s*/dev/sd`,
`>\s*/dev/nvme`,
// System destruction
`shutdown`,
`reboot`,
`init\s+0`,
`poweroff`,
`halt`,
// Dangerous permissions
`chmod\s+777`,
`chmod\s+-R\s+777`,
`chown\s+-R\s+.*:.*\s+/`,
// Remote code execution
`curl.*\|\s*(ba)?sh`,
`wget.*\|\s*(ba)?sh`,
`bash\s+-c\s+.*curl`,
`bash\s+-c\s+.*wget`,
// Crypto mining indicators
`xmrig`,
`minerd`,
`cpuminer`,
// Fork bomb patterns
`:\(\)\s*{\s*:\s*\|\s*:`,
// Clear system logs
`>\s*/var/log/`,
`truncate.*--size.*0.*/var/log/`,
},
}
p.compile()
return p
}
func (p *CommandPolicy) compile() {
p.autoApproveRe = compilePatterns(p.AutoApprove)
p.requireApprovalRe = compilePatterns(p.RequireApproval)
p.blockedRe = compilePatterns(p.Blocked)
}
func compilePatterns(patterns []string) []*regexp.Regexp {
result := make([]*regexp.Regexp, 0, len(patterns))
for _, pattern := range patterns {
re, err := regexp.Compile(pattern)
if err == nil {
result = append(result, re)
}
}
return result
}
// PolicyDecision represents the policy decision for a command
type PolicyDecision string
const (
PolicyAllow PolicyDecision = "allow"
PolicyRequireApproval PolicyDecision = "require_approval"
PolicyBlock PolicyDecision = "block"
)
// Evaluate checks a command against the policy
func (p *CommandPolicy) Evaluate(command string) PolicyDecision {
command = strings.TrimSpace(command)
// Normalize simple sudo prefix so policy applies consistently.
// For complex sudo invocations (sudo flags), keep the command as-is (conservative).
if strings.HasPrefix(command, "sudo ") {
parts := strings.Fields(command)
if len(parts) >= 2 && !strings.HasPrefix(parts[1], "-") {
command = strings.Join(parts[1:], " ")
}
}
// Check blocked first (highest priority)
for _, re := range p.blockedRe {
if re.MatchString(command) {
return PolicyBlock
}
}
// Check require approval
for _, re := range p.requireApprovalRe {
if re.MatchString(command) {
return PolicyRequireApproval
}
}
// Check auto-approve
for _, re := range p.autoApproveRe {
if re.MatchString(command) {
return PolicyAllow
}
}
// Default: require approval for unknown commands
return PolicyRequireApproval
}
// IsBlocked returns true if a command is blocked
func (p *CommandPolicy) IsBlocked(command string) bool {
return p.Evaluate(command) == PolicyBlock
}
// NeedsApproval returns true if a command needs user approval
func (p *CommandPolicy) NeedsApproval(command string) bool {
return p.Evaluate(command) == PolicyRequireApproval
}
// IsAutoApproved returns true if a command can run without approval
func (p *CommandPolicy) IsAutoApproved(command string) bool {
return p.Evaluate(command) == PolicyAllow
}