feat: general gated run primitive + chat-assent approval (Layer 0)
Implements the first slice of plans/2026-07-10-general-gated-execution.md:
Nomos gets one general execution tool instead of only a fixed action enum,
gated by an automatic risk classifier, and approval can be granted by the
operator just replying in chat instead of clicking a button.
- internal/policy/command.go: ClassifyCommand(cmd, declaredRisk) — rule-based
read-only allowlist + destructive denylist, default-escalate to
config_mutation for anything else. Classification can only ESCALATE the
caller's declared risk, never de-escalate it (destructive always wins even
if declared read_only). Compound commands (&&, ;, |, $()) never qualify for
the read-only fast path. Full test corpus.
- internal/mcp/server.go: new `run` MCP tool — target (host:/lxc:), command,
purpose, optional declared_risk. Read-only commands execute immediately;
everything else queues an approval exactly like pct_create today, executed
via httpapi's existing executeApprovedAction. Also fixes a real latent bug:
pct_exec resolved an LXC's host attribute without the "host:" prefix, so it
could never find the Proxmox host — new resolveExecTarget/resolveRunTarget
helpers (mcp + httpapi) fix this for both the new `run` action and existing
actions that route through the same execution path.
- internal/httpapi/phase3.go: "run" case in executeApprovedAction; fixes two
bugs found while wiring this up — (1) DecideApproval hardcoded risk_class to
'config_mutation' on every approve, silently corrupting the audit ledger for
every other risk class; (2) denying/revoking an approval never updated the
linked execution's status, so it stayed 'pending_approval' forever instead
of reflecting the decision.
- cmd/nomos/assent.go: deterministic (not LLM-judged) chat-assent detection.
Scoped to the immediately-preceding assistant turn's pending approvals only
— an old "yes" can't retroactively approve something new. Destructive-risk
actions are excluded from loose assent. Approves via the same HTTP decision
endpoint the UI button calls, so both paths share one audit trail.
- web/.../InlineApproval.svelte: self-healing poll — a pending approval card
now picks up being decided via ANY path (chat assent, Ops page, Matrix), not
just its own button. Previously the banner stayed stuck showing
Approve/Deny even after the action had already run elsewhere.
- nomos/SOUL.md: `run` is now the general capability ("no fixed menu, only a
risk gate"); documents chat-assent behavior and the destructive exception.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
133
internal/policy/command.go
Normal file
133
internal/policy/command.go
Normal file
@@ -0,0 +1,133 @@
|
||||
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 or piping a remote script straight into a root shell
|
||||
regexp.MustCompile(`(?i)\bcurl\b.*\|\s*(sudo\s+)?(ba)?sh\b`),
|
||||
regexp.MustCompile(`(?i)\bwget\b.*\|\s*(sudo\s+)?(ba)?sh\b`),
|
||||
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|` +
|
||||
`systemctl\s+(status|is-active|is-enabled|is-failed|list-units)|` +
|
||||
`docker\s+(ps|images|inspect|logs|version|info)|` +
|
||||
`pct\s+(status|config|list)|qm\s+(status|config|list)|pvesh\s+get|` +
|
||||
`git\s+(status|log|diff|show|branch|remote)|` +
|
||||
`curl\s+-.*-I\b|curl\s+.*--head\b)\b`)
|
||||
|
||||
// compoundOpPattern matches shell operators that chain or substitute
|
||||
// commands. A "read-only lead verb" only qualifies a command for the
|
||||
// read_only fast path when the WHOLE command is simple — otherwise a
|
||||
// compound like "cat file && rm -rf /" would slip through on its first verb.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
if !compoundOpPattern.MatchString(cmd) {
|
||||
// Strip a leading sudo/env assignment so "sudo cat /x" still matches.
|
||||
probe := cmd
|
||||
probe = regexp.MustCompile(`^sudo\s+`).ReplaceAllString(probe, "")
|
||||
probe = regexp.MustCompile(`^(\w+=\S+\s+)+`).ReplaceAllString(probe, "")
|
||||
if readOnlyLeadPattern.MatchString(probe) {
|
||||
return RiskReadOnly
|
||||
}
|
||||
}
|
||||
|
||||
// Not obviously destructive, not a recognized read-only inspection —
|
||||
// default to the gated tier rather than guessing it's safe.
|
||||
return RiskConfigMutation
|
||||
}
|
||||
Reference in New Issue
Block a user