P1: add docker compose (logs|ps|top|config|images|port|cp) to read-only allowlist. docker compose logs was classified as config_mutation, causing individual approval cards for read-only inspection commands. P2: remove approval entries from activityLog. They were always status=running and never transitioned to done (the derived store builds from tool-call text, not execution status), causing AgentIndicator to latch onto a stale 'Approval: ...' entry and never clear — even after the session completed. P3: remove InlineApproval from Chat.svelte. The green 'Completed in 1s on lxc:...' boxes were noise in the chat stream. Approval UX belongs in the Operations page (already has it via Ops.svelte), not inline in the chat. P4: stale execution cleanup. Startup sweep (mark >1hr non-terminal as cancelled) + 5-min periodic sweep (mark >10min non-terminal as cancelled). 98 orphaned executions accumulated from eval testing (39 running from apt_upgrade:audit timeouts, 19 pending_approval, 3 approved). P5: refuse second config_mutation run when an approval is already pending for the session. Without this, the agent queues N individual approvals before the operator can respond — confirmed in session 20757eb9 (two approval cards for what should have been one plan-level approval). VERSION 0.7.0 → 0.7.1
175 lines
7.2 KiB
Go
175 lines
7.2 KiB
Go
package policy
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// Risk class names, in escalation order (index = severity). A command's final
|
|
// risk class is the MAX of what the rules compute and what the caller
|
|
// declared — classification can only escalate, never de-escalate, mirroring
|
|
// the signal classifier's "policy can only lower autonomy, never raise it."
|
|
const (
|
|
RiskReadOnly = "read_only"
|
|
RiskReversibleLow = "reversible_low"
|
|
RiskConfigMutation = "config_mutation"
|
|
RiskDestructive = "destructive"
|
|
)
|
|
|
|
var riskOrder = map[string]int{
|
|
RiskReadOnly: 0,
|
|
RiskReversibleLow: 1,
|
|
RiskConfigMutation: 2,
|
|
RiskDestructive: 3,
|
|
}
|
|
|
|
func riskRank(r string) int {
|
|
if n, ok := riskOrder[r]; ok {
|
|
return n
|
|
}
|
|
return riskOrder[RiskConfigMutation] // unknown declared risk: assume the safer-to-gate default
|
|
}
|
|
|
|
// destructivePatterns match commands that must always be treated as
|
|
// destructive, regardless of what the caller declares. Irreversible,
|
|
// data-loss, or fleet-wide-impact operations. Matched against the raw
|
|
// command text, case-insensitive.
|
|
var destructivePatterns = []*regexp.Regexp{
|
|
regexp.MustCompile(`(?i)\brm\s+.*-[a-zA-Z]*r[a-zA-Z]*f|\brm\s+.*-[a-zA-Z]*f[a-zA-Z]*r`), // rm -rf / rm -fr (any flag order)
|
|
regexp.MustCompile(`(?i)\bdd\s+.*of=`),
|
|
regexp.MustCompile(`(?i)\bmkfs(\.\w+)?\b`),
|
|
regexp.MustCompile(`(?i)\bwipefs\b`),
|
|
regexp.MustCompile(`(?i)\bshred\b`),
|
|
regexp.MustCompile(`(?i)\bpct\s+destroy\b`),
|
|
regexp.MustCompile(`(?i)\bqm\s+destroy\b`),
|
|
regexp.MustCompile(`(?i)\bzpool\s+destroy\b`),
|
|
regexp.MustCompile(`(?i)\blvremove\b|\bvgremove\b|\bpvremove\b`),
|
|
regexp.MustCompile(`(?i)\bdrop\s+(table|database|schema)\b`),
|
|
regexp.MustCompile(`(?i)\btruncate\s+table\b`),
|
|
regexp.MustCompile(`(?i)>\s*/dev/(sd|nvme|vd|hd)`),
|
|
regexp.MustCompile(`(?i)\bshutdown\b|\breboot\b|\bhalt\b|\bpoweroff\b`),
|
|
regexp.MustCompile(`(?i)\bformat\b.*\b(disk|partition|volume)\b`),
|
|
regexp.MustCompile(`:\(\)\s*\{.*:\|:.*\}\s*;\s*:`), // fork bomb
|
|
regexp.MustCompile(`(?i)\bchmod\s+-R\s+000\b|\bchmod\s+000\s+/`),
|
|
regexp.MustCompile(`(?i)\biptables\s+-F\b|\bufw\s+disable\b`), // wipes firewall
|
|
// secret/credential exfiltration — reading private keys, shadow, or age
|
|
// keys is always destructive. (Piping a remote script into a shell via
|
|
// curl|sh was previously here too, but that pattern is common for
|
|
// legitimate installs — get.docker.com, convenience scripts — and
|
|
// demoting it to config_mutation means loose assent can grant it without
|
|
// a typed confirmation. The assent window covers the deploy case.)
|
|
regexp.MustCompile(`(?i)\bcat\s+.*(id_rsa|id_ed25519|\.pem|shadow|\.age)\b`),
|
|
}
|
|
|
|
// readOnlyLeadPattern matches the leading command word (after env-var
|
|
// prefixes and a leading sudo) against a small allowlist of verbs that are
|
|
// safe to auto-run unattended: they inspect state and cannot mutate it.
|
|
// Compound commands (&&, ;, |, $(), backticks) are excluded from this fast
|
|
// path below — only a single simple command can qualify.
|
|
var readOnlyLeadPattern = regexp.MustCompile(
|
|
`^(cat|less|head|tail|ls|stat|file|du|df|free|uptime|uname|hostname|whoami|id|ip|ss|netstat|ping|` +
|
|
`journalctl|dmesg|ps|top|htop|env|printenv|echo|which|whereis|` +
|
|
`grep|egrep|fgrep|rg|wc|sort|uniq|cut|tr|tee|find|tree|locate|` +
|
|
`dpkg\s+(-l|-s|--list|--status)\b|apt\s+(list|search|show)\b|` +
|
|
`systemctl\s+(status|is-active|is-enabled|is-failed|list-units|list-unit-files|list-timers|show)\b|` +
|
|
`timedatectl|hostnamectl|systemd-analyze|` +
|
|
`docker\s+(ps|images|inspect|logs|version|info|stats)|` +
|
|
`docker\s+compose\s+(logs|ps|top|config|images|port|cp)\b|` +
|
|
`pct\s+(status|config|list)|qm\s+(status|config|list)|pvesh\s+get|` +
|
|
`rclone\s+(ls|lsl|md5sum|check|cryptcheck)\b|` +
|
|
`git\s+(status|log|diff|show|branch|remote)|` +
|
|
`curl\s+-.*-I\b|curl\s+.*--head\b)\b`)
|
|
|
|
// compoundSplitRe splits a command on shell chaining operators (;, &&, ||, |)
|
|
// so each segment can be individually classified. A piped or chained command
|
|
// where EVERY segment is a recognized read-only inspection verb is safe to
|
|
// auto-run — e.g. "systemctl status caddy; journalctl -u caddy -n 5" or
|
|
// "docker ps | grep caddy".
|
|
var compoundSplitRe = regexp.MustCompile(`\s*(?:&&|\|\||;|\|)\s*`)
|
|
|
|
// subshellRe matches command substitution ($() or backticks) that can hide
|
|
// arbitrary execution. A command using these never qualifies for the read-only
|
|
// fast path — the substituted content could do anything.
|
|
var subshellRe = regexp.MustCompile("\\$\\(|`")
|
|
|
|
// compoundOpPattern is retained for compatibility — matches any compound
|
|
// operator. (Previously used to block ALL compound commands from the read-only
|
|
// path; now the per-segment check is more precise.)
|
|
var compoundOpPattern = regexp.MustCompile("[;&|`]|\\$\\(")
|
|
|
|
// ClassifyCommand scores an arbitrary shell command for the general `run`
|
|
// primitive. It combines a rule-based verdict (destructive denylist first,
|
|
// then a read-only allowlist for simple inspection commands) with the
|
|
// caller's declared risk, and returns the more severe of the two — the
|
|
// classifier may only escalate, never de-escalate, so a model that
|
|
// under-declares risk (or an adversarial prompt) cannot talk its way past a
|
|
// genuinely dangerous command. Anything not matched by either rule defaults
|
|
// to config_mutation (escalate), per "when in doubt, escalate."
|
|
func ClassifyCommand(command, declaredRisk string) string {
|
|
computed := computeCommandRisk(command)
|
|
if declaredRisk == "" {
|
|
return computed // no declaration to escalate with; computed's own escalate-by-default already applies
|
|
}
|
|
declared := normalizeRisk(declaredRisk)
|
|
if riskRank(declared) > riskRank(computed) {
|
|
return declared
|
|
}
|
|
return computed
|
|
}
|
|
|
|
func normalizeRisk(r string) string {
|
|
if _, ok := riskOrder[r]; ok {
|
|
return r
|
|
}
|
|
return RiskConfigMutation
|
|
}
|
|
|
|
func computeCommandRisk(command string) string {
|
|
cmd := strings.TrimSpace(command)
|
|
if cmd == "" {
|
|
return RiskConfigMutation
|
|
}
|
|
|
|
for _, p := range destructivePatterns {
|
|
if p.MatchString(cmd) {
|
|
return RiskDestructive
|
|
}
|
|
}
|
|
|
|
// Subshell substitution ($(), backticks) can hide arbitrary execution —
|
|
// never auto-run, even if the visible verbs look read-only.
|
|
if !subshellRe.MatchString(cmd) {
|
|
if allSegmentsReadOnly(cmd) {
|
|
return RiskReadOnly
|
|
}
|
|
}
|
|
|
|
// Not obviously destructive, not a recognized read-only inspection —
|
|
// default to the gated tier rather than guessing it's safe.
|
|
return RiskConfigMutation
|
|
}
|
|
|
|
// allSegmentsReadOnly splits a compound command on chaining operators
|
|
// (;, &&, ||, |) and checks whether EVERY segment is a recognized read-only
|
|
// inspection verb. If so, the whole command is safe to auto-run. Any segment
|
|
// that isn't a recognized read-only verb disqualifies the whole command —
|
|
// the classifier errs toward gating, not guessing.
|
|
func allSegmentsReadOnly(cmd string) bool {
|
|
segments := compoundSplitRe.Split(cmd, -1)
|
|
for _, seg := range segments {
|
|
seg = strings.TrimSpace(seg)
|
|
if seg == "" {
|
|
continue
|
|
}
|
|
// Strip a leading sudo/env assignment so "sudo cat /x" still matches.
|
|
probe := seg
|
|
probe = regexp.MustCompile(`^sudo\s+`).ReplaceAllString(probe, "")
|
|
probe = regexp.MustCompile(`^(\w+=\S+\s+)+`).ReplaceAllString(probe, "")
|
|
probe = strings.TrimSpace(probe)
|
|
if !readOnlyLeadPattern.MatchString(probe) {
|
|
return false
|
|
}
|
|
}
|
|
return len(segments) > 0
|
|
}
|