mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 05:29:45 +00:00
Judge Assistant read commands by their arguments, not first word (#3032)
pulse_read runs commands on monitored hosts, usually as root, without approval at every control level, as long as the classifier calls them read-only. The classifier approved a command from its first word and checked arguments only for find, curl and timeout. So date -s, sort -o, journalctl --vacuum-time/--rotate, fuser -k, dmesg -C, sar -o, ss -K, hostname NAME, ifconfig/arp/ip setters, smartctl SCT setters and many other state-changing forms passed as known read-only, and route add was blocked only because "add " matched the dd substring rule. Every allowlisted program with mutating flags or subcommands now has an explicit safe-forms rule, and unknown options fail closed. Commands are parsed as literal shell words. Expansions, redirects other than the fixed fd ones, command chaining and globs on validated programs are refused, and every pipeline stage must itself be read-only. route is judged on purpose. Ordinary diagnostics (journalctl -u ... --since, dmesg -T, ss -tlnp, ip addr, sar 1 1, docker ps/logs, smartctl -a, systemctl status) stay read-only. Plain blkid now needs -p, because without it blkid updates its cache.
This commit is contained in:
@@ -9025,3 +9025,26 @@ paths keep the buckets, the final delta does not double them),
|
||||
`patrol_cost_projection_test.go` and `cost_persistence_test.go`. This repairs
|
||||
the lost accounting data from Pulse #2350; it is not a measured provider bill
|
||||
or a claim of savings across six-hourly runs.
|
||||
|
||||
### pulse_read admits only argument-validated read forms
|
||||
|
||||
`pulse_read` runs commands on monitored hosts, usually as root, without an
|
||||
approval prompt at any control level, so `ClassifyExecutionIntent` is the only
|
||||
gate between a model-proposed command and the agent. The classifier used to
|
||||
approve a command from its first word and checked arguments only for `find`,
|
||||
`curl` and `timeout`, which admitted state-changing forms such as `date -s`,
|
||||
`sort -o`, `journalctl --vacuum-time`/`--rotate`, `fuser -k`, `dmesg -C`,
|
||||
`sar -o`, `ss -K`, `hostname NAME` and interface, ARP and route setters.
|
||||
`internal/ai/tools/tools_query_readonly.go` now owns the read-only forms:
|
||||
commands are parsed as literal shell words (expansions, chaining, redirects
|
||||
other than the fixed fd ones, and globs on validated programs are refused),
|
||||
every pipeline stage must itself be read-only, intrinsically read-only
|
||||
programs are listed by name, and every other allowlisted program accepts only
|
||||
explicitly listed options and subcommands, failing closed on anything unknown.
|
||||
`route` is judged on purpose rather than through the `dd` substring rule, and
|
||||
plain `blkid` needs `-p` because without it blkid updates its cache. Ordinary
|
||||
diagnostics (`journalctl -u ... --since`, `dmesg -T`, `ss -tlnp`, `ip addr`,
|
||||
`sar 1 1`, `docker ps`/`logs`, `smartctl -a`, `systemctl status`) stay
|
||||
read-only. Proof: `TestExecutionIntent_ArgumentAwareReadCommands` and
|
||||
`TestExecutionIntent_RouteOperationBoundary` in
|
||||
`internal/ai/tools/tools_query_readonly_test.go`.
|
||||
|
||||
@@ -199,13 +199,8 @@ type ContentInspector interface {
|
||||
type sqlContentInspector struct{}
|
||||
|
||||
func (s *sqlContentInspector) Applies(cmdLower string) bool {
|
||||
sqlCLIs := []string{"sqlite3 ", "mysql ", "mariadb ", "psql ", "mycli ", "pgcli ", "litecli "}
|
||||
for _, cli := range sqlCLIs {
|
||||
if strings.Contains(cmdLower, cli) || strings.HasPrefix(cmdLower, strings.TrimSuffix(cli, " ")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
fields, _, literal := readCommandWords(stripReadRedirects(cmdLower))
|
||||
return literal && wordIn(literalCommandBase(fields[0]), "sqlite3 mysql mariadb psql mycli pgcli litecli")
|
||||
}
|
||||
|
||||
func (s *sqlContentInspector) IsReadOnly(cmdLower string) (bool, string) {
|
||||
@@ -280,6 +275,13 @@ func classifySingleCommandWithOptions(command string, opts executionClassificati
|
||||
if reason := checkMutationCapabilityGuards(command, cmdLower); reason != "" {
|
||||
return IntentResult{Intent: IntentWriteOrUnknown, Reason: reason}
|
||||
}
|
||||
if result, handled := classifyReadPipeline(command, opts); handled {
|
||||
return result
|
||||
}
|
||||
literalWords, _, literal := readCommandWords(stripReadRedirects(command))
|
||||
if !literal {
|
||||
return IntentResult{Intent: IntentWriteOrUnknown, Reason: "command requires literal, inspectable shell arguments"}
|
||||
}
|
||||
|
||||
if result, handled := classifyTimeoutWrapper(command); handled {
|
||||
return result
|
||||
@@ -289,7 +291,7 @@ func classifySingleCommandWithOptions(command string, opts executionClassificati
|
||||
// MUST be checked before Phase 3 (read-only by construction) because even
|
||||
// read-only commands like `tail -f` and `journalctl -f` can hang indefinitely.
|
||||
// pulse_read requires commands that terminate deterministically.
|
||||
if niBlock := checkNonInteractiveGuardrails(command, cmdLower, opts); niBlock != nil {
|
||||
if niBlock := checkNonInteractiveGuardrails(command, strings.ToLower(strings.Join(literalWords, " ")), opts); niBlock != nil {
|
||||
return IntentResult{
|
||||
Intent: IntentWriteOrUnknown,
|
||||
Reason: niBlock.Message,
|
||||
@@ -318,9 +320,9 @@ func classifySingleCommandWithOptions(command string, opts executionClassificati
|
||||
}
|
||||
|
||||
// === PHASE 3: Known read-only by construction ===
|
||||
// Commands that cannot mutate regardless of arguments.
|
||||
// Commands with intrinsically read-only behaviour or explicitly safe arguments.
|
||||
// Only reached if Phase 2 didn't match any write patterns.
|
||||
if isReadOnlyByConstruction(cmdLower) {
|
||||
if isReadOnlyByConstruction(command) {
|
||||
return IntentResult{Intent: IntentReadOnlyCertain, Reason: "known read-only command"}
|
||||
}
|
||||
|
||||
@@ -349,10 +351,11 @@ func classifySingleCommandWithOptions(command string, opts executionClassificati
|
||||
}
|
||||
|
||||
func classifyTimeoutWrapper(command string) (IntentResult, bool) {
|
||||
fields := strings.Fields(command)
|
||||
if len(fields) == 0 || strings.ToLower(commandTokenBase(fields[0])) != "timeout" {
|
||||
words, _, literal := readCommandWords(stripReadRedirects(command))
|
||||
if !literal || strings.ToLower(literalCommandBase(words[0])) != "timeout" {
|
||||
return IntentResult{}, false
|
||||
}
|
||||
fields := strings.Fields(command)
|
||||
|
||||
innerCommand, ok := timeoutInnerCommand(fields)
|
||||
if !ok {
|
||||
@@ -438,11 +441,11 @@ func isTimeoutDurationToken(token string) bool {
|
||||
}
|
||||
|
||||
func classifyEnvCommand(command string) (IntentResult, bool) {
|
||||
fields := strings.Fields(strings.ToLower(command))
|
||||
if len(fields) == 0 || fields[0] != "env" {
|
||||
fields, glob, literal := readCommandWords(stripReadRedirects(command))
|
||||
if len(fields) == 0 || strings.ToLower(fields[0]) != "env" {
|
||||
return IntentResult{}, false
|
||||
}
|
||||
if envCommandHasUtility(fields) {
|
||||
if !literal || glob || envCommandHasUtility(fields) {
|
||||
return IntentResult{Intent: IntentWriteOrUnknown, Reason: "env can execute wrapped commands and is not read-only by construction"}, true
|
||||
}
|
||||
return IntentResult{Intent: IntentReadOnlyCertain, Reason: "bare env command prints environment only"}, true
|
||||
@@ -475,11 +478,11 @@ func envCommandHasUtility(fields []string) bool {
|
||||
}
|
||||
|
||||
func classifyFindCommand(command string) (IntentResult, bool) {
|
||||
fields := strings.Fields(strings.ToLower(command))
|
||||
fields, glob, literal := readCommandWords(stripReadRedirects(command))
|
||||
if len(fields) == 0 || fields[0] != "find" {
|
||||
return IntentResult{}, false
|
||||
}
|
||||
if findHasWriteOrExecAction(fields) {
|
||||
if !literal || glob || findHasWriteOrExecAction(fields) {
|
||||
return IntentResult{Intent: IntentWriteOrUnknown, Reason: "find action can execute commands or write files"}, true
|
||||
}
|
||||
return IntentResult{Intent: IntentReadOnlyConditional, Reason: "find command without write or exec actions"}, true
|
||||
@@ -500,13 +503,16 @@ func findHasWriteOrExecAction(fields []string) bool {
|
||||
}
|
||||
|
||||
func classifyCurlCommand(command string) (IntentResult, bool) {
|
||||
fields := strings.Fields(command)
|
||||
if len(fields) == 0 || strings.ToLower(commandTokenBase(fields[0])) != "curl" {
|
||||
fields, glob, literal := readCommandWords(stripReadRedirects(command))
|
||||
if len(fields) == 0 || strings.ToLower(literalCommandBase(fields[0])) != "curl" {
|
||||
return IntentResult{}, false
|
||||
}
|
||||
if isCurlMutationOrFileOutput(command) {
|
||||
return IntentResult{Intent: IntentWriteOrUnknown, Reason: "curl option can mutate requests or write files"}, true
|
||||
}
|
||||
if !literal || glob || !isSafeCurlRead(fields[1:]) {
|
||||
return IntentResult{Intent: IntentWriteOrUnknown, Reason: "curl options are not a supported read-only form"}, true
|
||||
}
|
||||
if curlHasHTTPURL(fields[1:]) {
|
||||
return IntentResult{Intent: IntentReadOnlyConditional, Reason: "curl HTTP(S) request without mutation or file-output options"}, true
|
||||
}
|
||||
@@ -536,8 +542,8 @@ func curlHasHTTPURL(fields []string) bool {
|
||||
}
|
||||
|
||||
func classifyWgetCommand(command string) (IntentResult, bool) {
|
||||
fields := strings.Fields(command)
|
||||
if len(fields) == 0 || strings.ToLower(commandTokenBase(fields[0])) != "wget" {
|
||||
fields, glob, literal := readCommandWords(stripReadRedirects(command))
|
||||
if len(fields) == 0 || strings.ToLower(literalCommandBase(fields[0])) != "wget" {
|
||||
return IntentResult{}, false
|
||||
}
|
||||
hasSpider := false
|
||||
@@ -553,6 +559,9 @@ func classifyWgetCommand(command string) (IntentResult, bool) {
|
||||
}
|
||||
}
|
||||
if hasSpider {
|
||||
if !literal || glob || !isSafeWgetSpiderRead(fields[1:]) {
|
||||
return IntentResult{Intent: IntentWriteOrUnknown, Reason: "wget options are not a supported read-only form"}, true
|
||||
}
|
||||
return IntentResult{Intent: IntentReadOnlyConditional, Reason: "wget spider check is read-only"}, true
|
||||
}
|
||||
return IntentResult{Intent: IntentWriteOrUnknown, Reason: "wget can write downloaded content and is not on the read-only allowlist unless --spider is used"}, true
|
||||
@@ -1085,140 +1094,6 @@ func checkSelfContainedGuardrails(command, cmdLower string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// isReadOnlyByConstruction returns true for commands that cannot mutate by design.
|
||||
// Only matches patterns at the START of the command to avoid false positives
|
||||
// (e.g., "date " inside "UPDATE" SQL statements).
|
||||
func isReadOnlyByConstruction(cmdLower string) bool {
|
||||
// Note: Pager tools (less, more) and live monitors (top, htop) are excluded here
|
||||
// because they're blocked by NonInteractiveOnly guardrails in Phase 1.
|
||||
readOnlyCommands := []string{
|
||||
"cat", "head", "tail",
|
||||
"ls", "ll", "dir",
|
||||
"ps", "free", "df", "du", "iostat", "vmstat", "mpstat", "sar",
|
||||
"grep", "locate", "which", "whereis",
|
||||
"journalctl", "dmesg",
|
||||
"uname", "hostname", "whoami", "id", "groups",
|
||||
"echo", "printf",
|
||||
"date", "uptime", "printenv", "locale",
|
||||
"netstat", "ss", "ifconfig", "route", "arp",
|
||||
"ping", "traceroute", "tracepath", "nslookup", "dig", "host",
|
||||
"file", "stat", "wc", "sort", "uniq", "cut", "tr",
|
||||
"lsof", "fuser",
|
||||
"getent", "nproc", "lscpu", "lsmem", "lsblk", "blkid",
|
||||
"lxc-ls", "lxc-info",
|
||||
"zcat", "zgrep", "bzcat", "xzcat",
|
||||
"md5sum", "sha256sum", "sha1sum",
|
||||
"test",
|
||||
// Process inspection
|
||||
"pgrep", "pidof", "pstree",
|
||||
// Login/session info
|
||||
"last", "lastlog", "who", "w",
|
||||
// Hardware inspection
|
||||
"lspci", "lsusb", "dmidecode", "hwinfo", "inxi",
|
||||
"sensors", "hddtemp", "smartctl", "nvme",
|
||||
// Media inspection tools
|
||||
"ffprobe", "mediainfo", "exiftool",
|
||||
// Proxmox version
|
||||
"pveversion",
|
||||
}
|
||||
|
||||
// Multi-word patterns that must appear at the start
|
||||
multiWordPatterns := []string{
|
||||
// Curl read-only variants (various flag combinations)
|
||||
"curl -s", "curl --silent", "curl -i", "curl --head",
|
||||
"curl -k", "curl --insecure",
|
||||
"curl -sk", "curl -ks", "curl -ki", "curl -ik",
|
||||
"curl http", "curl https",
|
||||
// Docker read-only
|
||||
"docker ps", "docker logs", "docker inspect", "docker stats",
|
||||
"docker images", "docker info", "docker version",
|
||||
"docker top", "docker port",
|
||||
"docker network ls", "docker network inspect",
|
||||
"docker volume ls", "docker volume inspect",
|
||||
"docker-compose ps", "docker compose ps",
|
||||
// Systemd read-only
|
||||
"systemctl status", "systemctl is-active", "systemctl is-enabled",
|
||||
"systemctl list", "systemctl show",
|
||||
"service status", "service --status-all",
|
||||
// Proxmox read-only
|
||||
"pct list", "pct status", "pct config", "pct df",
|
||||
"qm list", "qm status", "qm config", "qm guest cmd",
|
||||
"pvesh get",
|
||||
"pvecm status", "pvecm nodes",
|
||||
"pvesm status", "pvesm list",
|
||||
// ZFS/ZPool read-only
|
||||
"zpool status", "zpool list", "zpool get",
|
||||
"zfs list", "zfs get",
|
||||
// RAID inspection
|
||||
"mdadm --detail", "mdadm -D",
|
||||
// Network: ip with optional protocol flags (-4, -6)
|
||||
"ip addr", "ip route", "ip link", "ip neigh", "ip neighbor",
|
||||
"ip -4 addr", "ip -4 route", "ip -4 link", "ip -4 neigh", "ip -4 neighbor",
|
||||
"ip -6 addr", "ip -6 route", "ip -6 link", "ip -6 neigh", "ip -6 neighbor",
|
||||
"ip a", "ip r", "ip n",
|
||||
// Package info (read-only)
|
||||
"apt list", "apt show", "apt-cache",
|
||||
"dpkg -l", "dpkg --list", "dpkg -s",
|
||||
"rpm -q", "rpm -qa",
|
||||
"yum list", "dnf list",
|
||||
// Kubectl read-only commands
|
||||
"kubectl get", "kubectl describe", "kubectl logs", "kubectl top", "kubectl cluster-info",
|
||||
"kubectl api-resources", "kubectl api-versions", "kubectl version", "kubectl config view",
|
||||
// Network connectivity checks (zero-I/O port scan, read-only)
|
||||
"nc -z", "nc -vz", "nc -zv", "nc -zw", "nc -zvw",
|
||||
"netcat -z", "netcat -zv",
|
||||
}
|
||||
|
||||
// Extract first word of command
|
||||
firstWord := cmdLower
|
||||
if spaceIdx := strings.Index(cmdLower, " "); spaceIdx > 0 {
|
||||
firstWord = cmdLower[:spaceIdx]
|
||||
}
|
||||
|
||||
// Check single-word commands
|
||||
for _, cmd := range readOnlyCommands {
|
||||
if firstWord == cmd {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check multi-word patterns at start
|
||||
for _, pattern := range multiWordPatterns {
|
||||
if strings.HasPrefix(cmdLower, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Special case: nc/netcat with -z flag anywhere (zero-I/O mode is read-only regardless of flag order)
|
||||
if (firstWord == "nc" || firstWord == "netcat") && containsFlag(cmdLower, "-z") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Special case: [ (test shorthand)
|
||||
if strings.HasPrefix(cmdLower, "[ ") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// containsFlag checks whether a flag (e.g. "-z") appears as a standalone
|
||||
// token or embedded in a combined short-flag group (e.g. "-zv", "-zvw") in cmd.
|
||||
func containsFlag(cmd, flag string) bool {
|
||||
// flag is expected to be like "-z"
|
||||
char := strings.TrimPrefix(flag, "-")
|
||||
for _, field := range strings.Fields(cmd) {
|
||||
if field == flag {
|
||||
return true
|
||||
}
|
||||
// Check combined flags like "-zv", "-zvw", "-wzv"
|
||||
if strings.HasPrefix(field, "-") && !strings.HasPrefix(field, "--") && strings.Contains(field[1:], char) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchesWritePatterns checks for known write-capable command patterns.
|
||||
// Returns reason if a write pattern matches.
|
||||
func matchesWritePatterns(command, cmdLower string) string {
|
||||
@@ -1236,7 +1111,7 @@ func matchesWritePatterns(command, cmdLower string) string {
|
||||
"docker rm": "container removal", "docker stop": "container stop", "docker kill": "container kill",
|
||||
"docker restart": "container restart", "docker exec": "container exec", "kubectl exec": "container exec",
|
||||
"kill ": "process termination", "killall ": "process termination", "pkill ": "process termination",
|
||||
"dd ": "disk write", "mkfs": "filesystem creation", "fdisk": "disk partition", "parted": "disk partition", "mkswap": "swap creation",
|
||||
"mkfs": "filesystem creation", "fdisk": "disk partition", "parted": "disk partition", "mkswap": "swap creation",
|
||||
"iptables": "firewall modification", "firewall-cmd": "firewall modification", "ufw ": "firewall modification",
|
||||
"truncate": "file truncation",
|
||||
"chmod ": "permission change", "chown ": "ownership change", "chgrp ": "group change",
|
||||
@@ -1258,6 +1133,9 @@ func matchesWritePatterns(command, cmdLower string) string {
|
||||
// Command-start-only patterns: these must be the first word to avoid matching
|
||||
// substrings in arguments (e.g., "pve-daily-utils.service" contains "service",
|
||||
// "grep /etc/passwd" contains "passwd").
|
||||
if fields := strings.Fields(cmdLower); len(fields) > 0 && commandTokenBase(fields[0]) == "dd" {
|
||||
return "disk write"
|
||||
}
|
||||
if strings.HasPrefix(cmdLower, "service ") {
|
||||
return "service control"
|
||||
}
|
||||
@@ -1281,7 +1159,7 @@ func matchesWritePatterns(command, cmdLower string) string {
|
||||
return reason
|
||||
}
|
||||
}
|
||||
if containsCommandToken(cmdLower, "ln") || containsCommandToken(cmdLower, "link") {
|
||||
if fields := strings.Fields(cmdLower); len(fields) > 0 && wordIn(commandTokenBase(fields[0]), "ln link") {
|
||||
return "link creation"
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// literalCommandBase receives a decoded word, so quote characters are part of
|
||||
// the executable name and must not be trimmed as shell syntax.
|
||||
func literalCommandBase(word string) string {
|
||||
if i := strings.LastIndex(word, "/"); i >= 0 {
|
||||
return word[i+1:]
|
||||
}
|
||||
return word
|
||||
}
|
||||
|
||||
// readCommandWords decodes the literal shell words we can inspect. Expansion is
|
||||
// deliberately unsupported: its result could introduce options or extra operands.
|
||||
// Glob expansion is retained only for binaries whose operands cannot enable writes.
|
||||
func readCommandWords(command string) (words []string, glob bool, ok bool) {
|
||||
var word strings.Builder
|
||||
quote := byte(0)
|
||||
started := false
|
||||
flush := func() {
|
||||
if started {
|
||||
words = append(words, word.String())
|
||||
word.Reset()
|
||||
started = false
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(command); i++ {
|
||||
c := command[i]
|
||||
if quote == '\'' {
|
||||
if c == quote {
|
||||
quote = 0
|
||||
} else {
|
||||
word.WriteByte(c)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if c == '\\' {
|
||||
if i+1 == len(command) || command[i+1] == '\n' {
|
||||
return nil, false, false
|
||||
}
|
||||
i++
|
||||
// Inside double quotes, backslashes only escape these characters.
|
||||
if quote == '"' && !strings.ContainsRune("$`\"\\", rune(command[i])) {
|
||||
word.WriteByte('\\')
|
||||
}
|
||||
word.WriteByte(command[i])
|
||||
started = true
|
||||
continue
|
||||
}
|
||||
if c == '$' || c == '`' || c == 0 {
|
||||
return nil, false, false
|
||||
}
|
||||
if quote == '"' {
|
||||
if c == quote {
|
||||
quote = 0
|
||||
} else {
|
||||
word.WriteByte(c)
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch c {
|
||||
case '\'', '"':
|
||||
quote = c
|
||||
started = true
|
||||
case ' ', '\t':
|
||||
flush()
|
||||
case '\n', '\r', '|', '&', ';', '<', '>', '(', ')', '{', '}', '#':
|
||||
return nil, false, false
|
||||
case '*', '?', '[':
|
||||
glob = true
|
||||
fallthrough
|
||||
default:
|
||||
word.WriteByte(c)
|
||||
started = true
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return words, glob, quote == 0 && len(words) > 0
|
||||
}
|
||||
|
||||
// classifyReadPipeline checks every stage, rather than inheriting the first
|
||||
// binary's permissions. Keep the original quoting when recursing into a stage.
|
||||
func classifyReadPipeline(command string, opts executionClassificationOptions) (IntentResult, bool) {
|
||||
var stages []string
|
||||
quote := byte(0)
|
||||
start := 0
|
||||
for i := 0; i < len(command); i++ {
|
||||
c := command[i]
|
||||
if c == '\\' && quote != '\'' {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if quote != 0 {
|
||||
if c == quote {
|
||||
quote = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
if c == '\'' || c == '"' {
|
||||
quote = c
|
||||
} else if c == '|' {
|
||||
stages = append(stages, strings.TrimSpace(command[start:i]))
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if len(stages) == 0 {
|
||||
return IntentResult{}, false
|
||||
}
|
||||
stages = append(stages, strings.TrimSpace(command[start:]))
|
||||
for _, stage := range stages {
|
||||
if stage == "" {
|
||||
return IntentResult{Intent: IntentWriteOrUnknown, Reason: "empty pipeline stage"}, true
|
||||
}
|
||||
result := classifySingleCommandWithOptions(stage, opts)
|
||||
if result.Intent == IntentWriteOrUnknown {
|
||||
result.Reason = "pipeline stage not read-only: " + result.Reason
|
||||
return result, true
|
||||
}
|
||||
}
|
||||
return IntentResult{Intent: IntentReadOnlyCertain, Reason: "all pipeline stages are read-only"}, true
|
||||
}
|
||||
|
||||
// safeReadOptions accepts exact options and short clusters, preserving case.
|
||||
// A trailing '=' means one required literal argument. '?' allows an optional
|
||||
// attached argument, for tools such as lsof whose bare -i is also useful.
|
||||
// Unknown options, abbreviations and missing arguments fail closed.
|
||||
func safeReadOptions(args []string, specification string, maxOperands int) bool {
|
||||
_, ok := readOptionOperands(args, specification, maxOperands)
|
||||
return ok
|
||||
}
|
||||
|
||||
func readOptionOperands(args []string, specification string, maxOperands int, validators ...func(string, string) bool) ([]string, bool) {
|
||||
options := make(map[string]byte)
|
||||
for _, option := range strings.Fields(specification) {
|
||||
mode := byte(0)
|
||||
if strings.HasSuffix(option, "=") || strings.HasSuffix(option, "?") {
|
||||
mode = option[len(option)-1]
|
||||
option = option[:len(option)-1]
|
||||
}
|
||||
options[option] = mode
|
||||
}
|
||||
var operands []string
|
||||
validate := func(name, value string) bool {
|
||||
for _, validator := range validators {
|
||||
if !validator(name, value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
end := false
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if arg == "--" && !end {
|
||||
end = true
|
||||
continue
|
||||
}
|
||||
if end || arg == "-" || !strings.HasPrefix(arg, "-") {
|
||||
operands = append(operands, arg)
|
||||
continue
|
||||
}
|
||||
name, value, attached := strings.Cut(arg, "=")
|
||||
if mode, found := options[name]; found {
|
||||
if attached && mode == 0 {
|
||||
return nil, false
|
||||
}
|
||||
if mode == '=' && !attached {
|
||||
i++
|
||||
if i >= len(args) || !isLiteralReadOptionValue(args[i]) {
|
||||
return nil, false
|
||||
}
|
||||
value = args[i]
|
||||
}
|
||||
if !validate(name, value) {
|
||||
return nil, false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(arg, "--") || attached {
|
||||
return nil, false
|
||||
}
|
||||
for j := 1; j < len(arg); j++ {
|
||||
mode, found := options["-"+arg[j:j+1]]
|
||||
if !found {
|
||||
return nil, false
|
||||
}
|
||||
if mode != 0 {
|
||||
value := arg[j+1:]
|
||||
if mode == '=' && j == len(arg)-1 {
|
||||
i++
|
||||
if i >= len(args) || !isLiteralReadOptionValue(args[i]) {
|
||||
return nil, false
|
||||
}
|
||||
value = args[i]
|
||||
}
|
||||
if !validate("-"+arg[j:j+1], value) {
|
||||
return nil, false
|
||||
}
|
||||
break
|
||||
}
|
||||
if !validate("-"+arg[j:j+1], "") {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
}
|
||||
return operands, maxOperands < 0 || len(operands) <= maxOperands
|
||||
}
|
||||
|
||||
func isLiteralReadOptionValue(value string) bool {
|
||||
return !strings.HasPrefix(value, "-") || (len(value) > 1 && value[1] >= '0' && value[1] <= '9')
|
||||
}
|
||||
|
||||
func wordIn(word, set string) bool {
|
||||
for _, candidate := range strings.Fields(set) {
|
||||
if word == candidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isReadOnlyByConstruction(command string) bool {
|
||||
// Only these fixed fd redirects are admitted by the shell guard.
|
||||
words, glob, ok := readCommandWords(stripReadRedirects(command))
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// These programs only inspect data. Their options do not select write or
|
||||
// execution modes. Other allowlisted programs require argument validation.
|
||||
if wordIn(words[0], "cat head tail ls ll dir ps free du iostat vmstat mpstat grep locate which whereis whoami id groups echo printf uptime printenv locale netstat ping traceroute tracepath dig host stat wc cut tr getent nproc lscpu lsmem lsblk lxc-ls lxc-info zcat zgrep bzcat xzcat md5sum sha256sum sha1sum test pgrep pidof pstree last who w lspci lsusb pveversion") {
|
||||
return true
|
||||
}
|
||||
if words[0] == "[" {
|
||||
return words[len(words)-1] == "]"
|
||||
}
|
||||
if glob {
|
||||
return false
|
||||
}
|
||||
args := words[1:]
|
||||
switch words[0] {
|
||||
case "uname":
|
||||
return safeReadOptions(args, "-a -s -n -r -v -m -p -i -o --all --kernel-name --nodename --kernel-release --kernel-version --machine --processor --hardware-platform --operating-system --help --version", 0)
|
||||
case "nslookup":
|
||||
// Interactive variants interpret stdin and may support output files.
|
||||
operands, valid := readOptionOperands(args, "-type= -query= -querytype= -class= -domain= -timeout= -retry= -port= -debug -d2 -nodebug -recurse -norecurse -vc -novc -search -nosearch -defname -nodefname -fail -nofail", 2)
|
||||
return valid && len(operands) > 0 && !wordIn(strings.ToLower(operands[0]), "- ls view finger set server lserver root exit help")
|
||||
case "df":
|
||||
return safeReadOptions(args, "-a -B= -h -H -i -k -l -P -t= -T -x= --all --block-size= --human-readable --si --inodes --local --no-sync --portability --type= --print-type --exclude-type= --total --output? --help --version", -1)
|
||||
case "date":
|
||||
operands, valid := readOptionOperands(args, "-d= --date= -f= --file= -I? --iso-8601? -R --rfc-email --rfc-3339= -r= --reference= -u --utc --universal --help --version", 1)
|
||||
return valid && (len(operands) == 0 || strings.HasPrefix(operands[0], "+"))
|
||||
case "hostname":
|
||||
return safeReadOptions(args, "-a --alias -A --all-fqdns -d --domain -f --fqdn --long -i --ip-address -I --all-ip-addresses -s --short -y --yp --nis -v --verbose -h --help -V --version", 0)
|
||||
case "sort":
|
||||
return safeReadOptions(args, "-b -d -f -g -h -i -M -n -R -r -s -u -V -z -c -C -m -k= -t= -S= --ignore-leading-blanks --dictionary-order --ignore-case --general-numeric-sort --human-numeric-sort --ignore-nonprinting --month-sort --numeric-sort --random-sort --reverse --stable --unique --version-sort --zero-terminated --check? --merge --key= --field-separator= --buffer-size= --parallel= --batch-size= --help --version", -1)
|
||||
case "uniq":
|
||||
return safeReadOptions(args, "-c -d -D -u -i -z -f= -s= -w= --count --repeated --all-repeated? --unique --ignore-case --zero-terminated --skip-fields= --skip-chars= --check-chars= --help --version", 1)
|
||||
case "journalctl":
|
||||
// journalctl also accepts a negative boot offset as a separate word.
|
||||
args = append([]string(nil), args...)
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if wordIn(args[i], "-b --boot") && strings.HasPrefix(args[i+1], "-") {
|
||||
if _, err := strconv.ParseInt(args[i+1], 10, 64); err == nil {
|
||||
args[i] += "=" + args[i+1]
|
||||
args = append(args[:i+1], args[i+2:]...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return safeReadOptions(args, "-a -b? -k -f -e -r -q -x -l -m -n? -u= -t= -p= -o= -S= -U= -D= -M= --all --boot? --dmesg --follow --pager-end --reverse --quiet --catalog --full --merge --lines? --unit= --user-unit= --identifier= --priority= --output= --output-fields= --since= --until= --directory= --file= --machine= --namespace= --cursor= --after-cursor= --grep= --case-sensitive= --no-pager --no-hostname --no-full --no-tail --utc --system --user --list-boots --disk-usage --list-invocations --verify --header --field= -F= --fields --facility= --help --version", -1)
|
||||
case "dmesg":
|
||||
return safeReadOptions(args, "-d -H -k -L? -P -r -S -T -t -u -w -x -J -l= -f= -s= --show-delta --human --kernel --color? --nopager --raw --syslog --ctime --notime --userspace --follow --decode --json --level= --facility= --buffer-size= --since= --until= --time-format= --help --version", 0)
|
||||
case "sar":
|
||||
return safeReadOptions(args, "-A -B -b -C -d -F -H -h -p -r -S -t -u -v -W -w -y -z -q -R -n= -I= -P= -f= -s= -e= -i= --human --help -V", -1)
|
||||
case "fuser":
|
||||
return safeReadOptions(args, "-a -c -f -m -M -n= -s -u -v -4 -6 --all --mount --ismountpoint --namespace= --silent --user --verbose --ipv4 --ipv6 --help --version", -1)
|
||||
case "ss":
|
||||
return safeReadOptions(args, "-4 -6 -0 -a -d -e -f= -F= -H -i -l -m -n -N= -o -p -r -s -t -u -w -x -A= -O -T -b --ipv4 --ipv6 --all --dccp --extended --family= --filter= --no-header --info --listening --memory --numeric --net= --options --processes --query= --resolve --summary --tcp --udp --raw --unix --oneline --threads --bpf --help --version", -1)
|
||||
case "ifconfig":
|
||||
return safeReadOptions(args, "-a -s -v --help --version", 1)
|
||||
case "route":
|
||||
return safeReadOptions(args, "-n -e -v -A= -4 -6 -F -C --numeric --extend --verbose --family= --help --version", 0)
|
||||
case "arp":
|
||||
return safeReadOptions(args, "-a -e -n -v -D -i= -A= -H= --all --numeric --verbose --device= --protocol= --hw-type= --help --version", 1)
|
||||
case "ip":
|
||||
return isReadOnlyIP(args)
|
||||
case "lsof":
|
||||
// -D selects cache build/update modes on platforms with a device cache.
|
||||
return safeReadOptions(args, "-a -b -c= -d= -e= -f -g? -i? -l -n -N -o? -p= -P -R -s? -t -u= -U -v -V -w -X -F? -h", -1)
|
||||
case "blkid":
|
||||
// Low-level probing does not update blkid's persistent cache.
|
||||
return len(args) > 0 && wordIn(args[0], "-p --probe") && safeReadOptions(args, "-p --probe -i --info -o= --output= -s= --match-tag= -n= --match-types= -u= --usages= -O= --offset= -S= --size= --help --version", -1)
|
||||
case "file":
|
||||
return safeReadOptions(args, "-b -c -E -i -k -L -l -N -n -r -s -z -Z -0 -e= -f= -F= -m= -P= --brief --checking-printout --mime --mime-type --mime-encoding --keep-going --dereference --list --no-buffer --no-pad --raw --special-files --uncompress --uncompress-noreport --print0 --exclude= --files-from= --separator= --magic-file= --parameter= --help --version", -1)
|
||||
case "lastlog":
|
||||
return safeReadOptions(args, "-b= -t= -u= --before= --time= --user= --help --version", 0)
|
||||
case "dmidecode":
|
||||
return safeReadOptions(args, "-d= -s= -t= -H= -u -q --dev-mem= --string= --type= --handle= --dump --quiet --from-dump= --no-sysfs --help --version", 0)
|
||||
case "hwinfo":
|
||||
return safeReadOptions(args, "--all --short --listmd --only= --cpu --bios --memory --disk --partition --block --network --netcard --wlan --usb --pci --storage-ctrl --gfxcard --monitor --sound --bridge --system --help --version", 0)
|
||||
case "inxi":
|
||||
_, valid := readOptionOperands(args, "-A -b -B -c= --color= -C -d -D -E -f -F -G -h -i -I -j -J -l -L -m -M -n -N -o -p -P -r -R -s -S -t= -u -v= -V -w -W= -x -y= -z --admin --basic --full --extra --filter --no-host --no-color --help --version", 0, func(name, value string) bool {
|
||||
if wordIn(name, "-c --color") {
|
||||
color, err := strconv.Atoi(value)
|
||||
return err == nil && color >= 0 && color <= 42
|
||||
}
|
||||
return true
|
||||
})
|
||||
return valid
|
||||
case "sensors":
|
||||
return safeReadOptions(args, "-A -u -j -f -c= --no-adapter --fahrenheit --config-file= --help --version", -1)
|
||||
case "hddtemp":
|
||||
return safeReadOptions(args, "-n -q -u= --numeric --quiet --unit= --help --version", -1)
|
||||
case "smartctl":
|
||||
// Some --log values set SCT parameters or reset device counters.
|
||||
_, valid := readOptionOperands(args, "-a -x -i -H -A -c -d= -l= -v= -j --all --xall --info --health --attributes --capabilities --device= --log= --vendorattribute= --json --scan --scan-open --help --version", -1, func(name, value string) bool {
|
||||
return !wordIn(name, "-l --log") || wordIn(value, "error selftest selective directory scttempsts scttemphist scterc devstat sataphy")
|
||||
})
|
||||
return valid
|
||||
case "nvme":
|
||||
return len(args) > 0 && wordIn(args[0], "list list-subsys id-ctrl id-ns smart-log error-log fw-log effects-log self-test-log endurance-log") && safeReadOptions(args[1:], "-o= --output-format= -v --verbose -H --human-readable -b --raw-binary -n= --namespace-id= -e= --log-entries= --help", 1)
|
||||
case "ffprobe":
|
||||
return safeReadOptions(args, "-v= -loglevel= -hide_banner -show_format -show_streams -show_programs -show_chapters -show_packets -show_frames -show_error -show_entries= -show_data -show_data_hash= -select_streams= -of= -print_format= -read_intervals= -count_frames -count_packets -pretty -sexagesimal -unit -prefix -byte_binary_prefix -i= -h -help -version", 1)
|
||||
case "mediainfo":
|
||||
return safeReadOptions(args, "--Output= --Inform= --Full --Details= --Language= --ParseSpeed= --BOM --Help --Version -f -h", -1)
|
||||
case "exiftool":
|
||||
return safeReadOptions(args, "-a -u -g -G -s -S -n -j -json -csv -t -T -b -e -fast -fast2 -r -ext= -ver -h", -1)
|
||||
case "nc", "netcat":
|
||||
zeroIO := false
|
||||
_, valid := readOptionOperands(args, "-z -v -n -4 -6 -w= -G= -i= -p= -s=", 2, func(name, _ string) bool {
|
||||
zeroIO = zeroIO || name == "-z"
|
||||
return true
|
||||
})
|
||||
return valid && zeroIO
|
||||
}
|
||||
return isReadOnlySubcommand(words)
|
||||
}
|
||||
|
||||
func stripReadRedirects(command string) string {
|
||||
// Match complete, unquoted redirect tokens. A prefix match would also
|
||||
// accept destinations such as /dev/null.log, which create real files.
|
||||
var out strings.Builder
|
||||
quote := byte(0)
|
||||
for i := 0; i < len(command); i++ {
|
||||
c := command[i]
|
||||
if c == '\\' && quote != '\'' && i+1 < len(command) {
|
||||
out.WriteByte(c)
|
||||
i++
|
||||
out.WriteByte(command[i])
|
||||
continue
|
||||
}
|
||||
if quote != 0 {
|
||||
out.WriteByte(c)
|
||||
if c == quote {
|
||||
quote = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
if c == '\'' || c == '"' {
|
||||
quote = c
|
||||
} else if i == 0 || command[i-1] == ' ' || command[i-1] == '\t' {
|
||||
matched := false
|
||||
for _, redirect := range []string{"2>/dev/null", "2>&1"} {
|
||||
end := i + len(redirect)
|
||||
if strings.HasPrefix(command[i:], redirect) && (end == len(command) || command[end] == ' ' || command[end] == '\t') {
|
||||
i = end - 1
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out.WriteByte(c)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func isSafeCurlRead(args []string) bool {
|
||||
operands, valid := readOptionOperands(args, "-s -S -f -L -k -i -I -v -4 -6 --silent --show-error --fail --fail-with-body --location --insecure --include --head --verbose --ipv4 --ipv6 --compressed --connect-timeout= --max-time= --retry= --retry-delay= -m= -H= --header= -u= --user= -A= --user-agent= -b= --cookie= --cacert= --cert= --key= --url= -X= --request=", -1, func(name, value string) bool {
|
||||
if name == "-X" || name == "--request" {
|
||||
return wordIn(strings.ToUpper(value), "GET HEAD OPTIONS")
|
||||
}
|
||||
return name != "--url" || isHTTPReadURL(value)
|
||||
})
|
||||
if !valid {
|
||||
return false
|
||||
}
|
||||
for _, operand := range operands {
|
||||
if !isHTTPReadURL(operand) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isHTTPReadURL(value string) bool {
|
||||
value = strings.ToLower(value)
|
||||
return strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://")
|
||||
}
|
||||
|
||||
func isSafeWgetSpiderRead(args []string) bool {
|
||||
spider := false
|
||||
operands, valid := readOptionOperands(args, "--spider -q --quiet -S --server-response --no-check-certificate -T= --timeout= -t= --tries= -U= --user-agent= --header= --user= --password= --max-redirect=", -1, func(name, _ string) bool {
|
||||
spider = spider || name == "--spider"
|
||||
return true
|
||||
})
|
||||
if !valid || !spider {
|
||||
return false
|
||||
}
|
||||
for _, operand := range operands {
|
||||
if !isHTTPReadURL(operand) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isReadOnlyIP(args []string) bool {
|
||||
for len(args) > 0 && strings.HasPrefix(args[0], "-") {
|
||||
if !wordIn(args[0], "-4 -6 -0 -s -stats -statistics -d -details -h -human -r -resolve -o -oneline -j -json -p -pretty -br -brief") {
|
||||
return false
|
||||
}
|
||||
args = args[1:]
|
||||
}
|
||||
if len(args) == 0 || !wordIn(args[0], "a addr address r route l link n neigh neighbor neighbour") {
|
||||
return false
|
||||
}
|
||||
if len(args) == 1 {
|
||||
return true
|
||||
}
|
||||
verb := args[1]
|
||||
if !wordIn(verb, "show list lst get") || (verb == "get" && !wordIn(args[0], "r route")) {
|
||||
return false
|
||||
}
|
||||
// Remaining words are filters for an explicitly selected read operation.
|
||||
return safeReadOptions(args[2:], "", -1)
|
||||
}
|
||||
|
||||
func isReadOnlySubcommand(words []string) bool {
|
||||
if len(words) < 2 {
|
||||
return false
|
||||
}
|
||||
args := words[2:]
|
||||
verb := words[1]
|
||||
switch words[0] {
|
||||
case "systemctl":
|
||||
return wordIn(verb, "status is-active is-enabled is-failed is-system-running list-units list-unit-files list-jobs list-sockets list-timers list-dependencies show show-environment cat") && safeReadOptions(args, "-a -l -q -p= -n= -o= -t= --all --full --quiet --property= --lines= --output= --type= --state= --no-pager --no-legend --plain --failed --user --system --value --after --before --reverse --recursive --with-dependencies", -1)
|
||||
case "service":
|
||||
return (verb == "--status-all" && len(args) == 0) || (len(args) == 1 && args[0] == "status" && !strings.HasPrefix(verb, "-"))
|
||||
case "docker", "docker-compose":
|
||||
if words[0] == "docker-compose" && verb != "ps" {
|
||||
return false
|
||||
}
|
||||
if verb == "compose" {
|
||||
if len(args) == 0 || args[0] != "ps" {
|
||||
return false
|
||||
}
|
||||
args = args[1:]
|
||||
} else if wordIn(verb, "network volume") {
|
||||
if len(args) == 0 || !wordIn(args[0], "ls inspect") {
|
||||
return false
|
||||
}
|
||||
args = args[1:]
|
||||
} else if !wordIn(verb, "ps logs inspect stats images info version top port") {
|
||||
return false
|
||||
}
|
||||
options := "-a -q -s -f= -n= --all --quiet --size --filter= --format= --last= --latest --no-trunc --no-stream --no-cache --details --follow --since= --until= --tail= --timestamps --type="
|
||||
if verb == "logs" {
|
||||
options = strings.ReplaceAll(options, "-f=", "-f -t")
|
||||
}
|
||||
return safeReadOptions(args, options, -1)
|
||||
case "pct", "qm":
|
||||
if words[0] == "qm" && verb == "guest" {
|
||||
return len(args) == 3 && args[0] == "cmd" && wordIn(args[2], "ping get-host-name get-osinfo get-users get-time get-fsinfo get-memory-block-info get-memory-blocks get-vcpus network-get-interfaces")
|
||||
}
|
||||
return (wordIn(verb, "list status config") || (words[0] == "pct" && verb == "df")) && safeReadOptions(args, "--current --snapshot= --verbose --full", 1)
|
||||
case "pvesh":
|
||||
return verb == "get" && safeReadOptions(args, "--output-format= --human-readable= --noborder= --noheader= --verbose", 1)
|
||||
case "pvecm":
|
||||
return wordIn(verb, "status nodes") && len(args) == 0
|
||||
case "pvesm":
|
||||
return wordIn(verb, "status list") && safeReadOptions(args, "--content= --storage= --vmid= --enabled= --target=", 1)
|
||||
case "zpool", "zfs":
|
||||
return (wordIn(verb, "list get") || (words[0] == "zpool" && verb == "status")) && safeReadOptions(args, "-H -p -v -P -L -r -x -t= -o= -s= -S= -d= -T=", -1)
|
||||
case "mdadm":
|
||||
return wordIn(verb, "--detail -D") && safeReadOptions(args, "--brief --export --scan --test -b -s -t", -1)
|
||||
case "apt", "yum", "dnf":
|
||||
return (verb == "list" || (words[0] == "apt" && verb == "show")) && safeReadOptions(args, "--installed --upgradable --all-versions -a", -1)
|
||||
case "apt-cache":
|
||||
return wordIn(verb, "show showpkg depends rdepends policy search pkgnames stats madison") && safeReadOptions(args, "--installed --names-only --recurse --no-recurse --all-versions -a -n", -1)
|
||||
case "dpkg":
|
||||
return wordIn(verb, "-l --list -s --status -L --listfiles -S --search") && safeReadOptions(args, "", -1)
|
||||
case "rpm":
|
||||
return wordIn(verb, "-q -qa --query") && safeReadOptions(args, "-a -i -l -R -s -f= -p= --all --info --list --requires --state --file= --package=", -1)
|
||||
case "kubectl":
|
||||
if verb == "config" {
|
||||
return len(args) == 1 && args[0] == "view"
|
||||
}
|
||||
if verb == "cluster-info" {
|
||||
return len(args) == 0
|
||||
}
|
||||
return wordIn(verb, "get describe logs top api-resources api-versions version") && safeReadOptions(args, "-A -a -c= -f -l= -n= -o= -p --all-namespaces --all-containers --container= --selector= --namespace= --output= --previous --follow --tail= --since= --since-time= --timestamps --prefix --limit-bytes= --show-labels --sort-by= --field-selector= --no-headers --containers --use-protocol-buffers --client --short", -1)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package tools
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExecutionIntent_ArgumentAwareReadCommands(t *testing.T) {
|
||||
tests := []struct {
|
||||
command string
|
||||
readOnly bool
|
||||
}{
|
||||
{`date -s @1000000000`, false},
|
||||
{`date --set=@1000000000`, false},
|
||||
{`date 101612002026`, false},
|
||||
{`sort -o /tmp/x /etc/hostname`, false},
|
||||
{`sort -no/tmp/x /etc/hostname`, false},
|
||||
{`sort --output=/tmp/x /etc/hostname`, false},
|
||||
{`sort --compress-program=sh /etc/hostname`, false},
|
||||
{`sort -o /tmp/x --random-source="mysql unused" /etc/hostname`, false},
|
||||
{`uniq /etc/hostname /tmp/x`, false},
|
||||
{`journalctl --vacuum-time=1s`, false},
|
||||
{`journalctl --rotate`, false},
|
||||
{`journalctl --flush`, false},
|
||||
{`journalctl --sync`, false},
|
||||
{`journalctl --relinquish-var`, false},
|
||||
{`journalctl --setup-keys`, false},
|
||||
{`journalctl --smart-relinquish-var`, false},
|
||||
{`journalctl --synchronize-on-exit=yes -n 10`, false},
|
||||
{`journalctl --cursor-file=/tmp/x -n 10`, false},
|
||||
{`journalctl --update-catalog`, false},
|
||||
{`fuser -k /tmp/x`, false},
|
||||
{`fuser -mk /tmp/x`, false},
|
||||
{`fuser -k "/tmp/mysql target"`, false},
|
||||
{`dmesg -C`, false},
|
||||
{`dmesg -c`, false},
|
||||
{`dmesg -n 1`, false},
|
||||
{`dmesg -D`, false},
|
||||
{`dmesg -E`, false},
|
||||
{`sar -o /tmp/x.bin 1 1`, false},
|
||||
{`sar -A -o/tmp/x.bin 1 1`, false},
|
||||
{`hostname changed`, false},
|
||||
{`hostname -F /tmp/name`, false},
|
||||
{`uname -S changed`, false},
|
||||
{`ifconfig eth0 down`, false},
|
||||
{`ifconfig eth0 192.0.2.10`, false},
|
||||
{`route add default gw 192.0.2.1`, false},
|
||||
{`route del default`, false},
|
||||
{`arp -s 192.0.2.10 00:11:22:33:44:55`, false},
|
||||
{`ip addr add 192.0.2.10/24 dev eth0`, false},
|
||||
{`ip -4 route del default`, false},
|
||||
{`ip link set eth0 down`, false},
|
||||
{`ip neigh flush all`, false},
|
||||
{`ip route restore`, false},
|
||||
{`ip route save`, false},
|
||||
{`ip addresslabel add prefix ::1`, false},
|
||||
{`ss -K dst 192.0.2.10`, false},
|
||||
{`ss -tK dst 192.0.2.10`, false},
|
||||
{`ss -D /tmp/x`, false},
|
||||
{`ss --diag /tmp/x`, false},
|
||||
{`ss --diag=/tmp/x`, false},
|
||||
{`ss --diag "/tmp/mysql capture"`, false},
|
||||
{`lastlog -C -u root`, false},
|
||||
{`lastlog -S -u root`, false},
|
||||
{`file -C -m /tmp/magic`, false},
|
||||
{`file --preserve-date /etc/hostname`, false},
|
||||
{`df --sync`, false},
|
||||
{`dmidecode --dump-bin /tmp/x`, false},
|
||||
{`hwinfo --log /tmp/x`, false},
|
||||
{`inxi --output json --output-file /tmp/x`, false},
|
||||
{`inxi -U`, false},
|
||||
{`inxi -c94`, false},
|
||||
{`inxi --color=99`, false},
|
||||
{`hddtemp -d /dev/sda`, false},
|
||||
{`sensors -s`, false},
|
||||
{`smartctl -s on /dev/sda`, false},
|
||||
{`smartctl -t short /dev/sda`, false},
|
||||
{`smartctl -X /dev/sda`, false},
|
||||
{`smartctl -l sataphy,reset /dev/sda`, false},
|
||||
{`smartctl --log=scterc,70,70 /dev/sda`, false},
|
||||
{`smartctl -al sataphy,reset /dev/sda`, false},
|
||||
{`nvme format /dev/nvme0n1`, false},
|
||||
{`nvme set-feature /dev/nvme0 -f 1 -v 1`, false},
|
||||
{`nvme get-log /dev/nvme0 --raw-binary --log-len=16 --log-id=1 --output-file=/tmp/x`, false},
|
||||
{`ffprobe -o /tmp/x video.mp4`, false},
|
||||
{`ffprobe -report video.mp4`, false},
|
||||
{`mediainfo --LogFile=/tmp/x video.mp4`, false},
|
||||
{`exiftool -Artist=changed image.jpg`, false},
|
||||
{`exiftool -o /tmp/x image.jpg`, false},
|
||||
{`exiftool -w txt image.jpg`, false},
|
||||
{`exiftool -config /tmp/config image.jpg`, false},
|
||||
{`exiftool -config "/tmp/mysql config" image.jpg`, false},
|
||||
{`exiftool -if 'system("id")' image.jpg`, false},
|
||||
{`lsof -Db/tmp/x`, false},
|
||||
{`lsof -Du/tmp/x`, false},
|
||||
{`blkid -w /tmp/x`, false},
|
||||
{`systemctl status --job-mode=flush nginx`, false},
|
||||
{`systemctl list-jobs --after`, true},
|
||||
{`systemctl restart nginx`, false},
|
||||
{`systemctl list-unit-files --now`, false},
|
||||
{`systemctl showevil nginx`, false},
|
||||
{`qm guest cmd 100 fsfreeze-freeze`, false},
|
||||
{`pvesh get /nodes --output-format json --output-file /tmp/x`, false},
|
||||
{`apt-cache gencaches`, false},
|
||||
{`rpm -q --eval '%{lua:os.execute("id")}'`, false},
|
||||
{`zpool status -c /tmp/script`, false},
|
||||
{`kubectl get pods --raw=/api --output-watch-events --kubeconfig=/tmp/config`, false},
|
||||
{`kubectl cluster-info dump --output-directory=/tmp/x`, false},
|
||||
{`nc -zv -o /tmp/x example.com 22`, false},
|
||||
{`nc -z -e /bin/sh example.com 22`, false},
|
||||
{`nc -- -z 22`, false},
|
||||
{`nc -s-z example.com 22`, false},
|
||||
{`nslookup`, false},
|
||||
{`nslookup - 192.0.2.1`, false},
|
||||
{`printf 'exit\n' | nslookup`, false},
|
||||
{`sysctl -w kernel.hostname=changed`, false},
|
||||
{`sysctl kernel.hostname=changed`, false},
|
||||
{`timedatectl set-time 2026-01-01`, false},
|
||||
{`tee /tmp/x`, false},
|
||||
{`touch /tmp/x`, false},
|
||||
{`rm -rf /tmp/x`, false},
|
||||
{`sed e id /etc/hostname`, false},
|
||||
{`sed e /etc/hostname "mysql file"`, false},
|
||||
{`unknown-command "mysql SELECT 1"`, false},
|
||||
{`"mysql fake" -e "SELECT 1"`, false},
|
||||
{`'"mysql"' "SELECT 1"`, false},
|
||||
{`'"curl"' https://example.com`, false},
|
||||
{`'"wget"' --spider https://example.com`, false},
|
||||
{`'"timeout"' 5s ls`, false},
|
||||
{`cat f | sed e`, false},
|
||||
{`echo x > f`, false},
|
||||
{`cat /etc/hostname 2>/dev/null.tmp`, false},
|
||||
{`id && touch f`, false},
|
||||
{`curl -T f url`, false},
|
||||
{`curl --trace /tmp/x https://example.com`, false},
|
||||
{`curl --trace-ascii /tmp/x https://example.com`, false},
|
||||
{`curl --libcurl /tmp/x https://example.com`, false},
|
||||
{`curl -D /tmp/x https://example.com`, false},
|
||||
{`curl -\T f https://example.com`, false},
|
||||
{`wget --spider --save-cookies=/tmp/x https://example.com`, false},
|
||||
{`wget --spider --execute='output_document=/tmp/x' https://example.com`, false},
|
||||
{`wget -- --spider https://example.com`, false},
|
||||
{`find / -delete`, false},
|
||||
{`find / -de\lete`, false},
|
||||
{`awk 'BEGIN{system("id")}'`, false},
|
||||
{`cat f | date -s @1000000000`, false},
|
||||
{`cat f | sort -o /tmp/x | head`, false},
|
||||
{`cat f|uniq /tmp/x /tmp/y`, false},
|
||||
{`timeout 5s journalctl --rotate`, false},
|
||||
{`date '-s' @1000000000`, false},
|
||||
{`date -\s @1000000000`, false},
|
||||
{`sort file*`, false},
|
||||
{`uniq $FILES`, false},
|
||||
{`env FOO=$PAYLOAD`, false},
|
||||
{`env -u *`, false},
|
||||
{`'journalctl' -f`, false},
|
||||
{`dme\sg -w`, false},
|
||||
{`date --unknown-option`, false},
|
||||
{`journalctl --rot`, false},
|
||||
{`sort --out=/tmp/x file`, false},
|
||||
{`sort -k`, false},
|
||||
{`date -d '-1 day' +%s`, true},
|
||||
{`date`, true},
|
||||
{`date -u +%FT%TZ`, true},
|
||||
{`date -d 'yesterday' +%s`, true},
|
||||
{`date --iso-8601=seconds`, true},
|
||||
{`sort file`, true},
|
||||
{`sort -nr -k 2 file`, true},
|
||||
{`uniq -c file`, true},
|
||||
{`journalctl -u x --since "10 min ago" -n 100 --no-pager`, true},
|
||||
{`journalctl -o json --output-fields=MESSAGE -n10`, true},
|
||||
{`journalctl -b --since '-1 hour'`, true},
|
||||
{`journalctl -b -1 -n 50`, true},
|
||||
{`dmesg -T`, true},
|
||||
{`dmesg --level=err,warn`, true},
|
||||
{`hostname -f`, true},
|
||||
{`uname -a`, true},
|
||||
{`ip addr`, true},
|
||||
{`ip -4 route show table all`, true},
|
||||
{`ip -6 neigh show`, true},
|
||||
{`ip link show dev eth0`, true},
|
||||
{`ss -tlnp`, true},
|
||||
{`ss -t state established`, true},
|
||||
{`sar 1 1`, true},
|
||||
{`sar -n DEV 1 1`, true},
|
||||
{`iostat -o JSON 1 1`, true},
|
||||
{`mpstat -o JSON 1 1`, true},
|
||||
{`lsblk -o NAME,SIZE,TYPE`, true},
|
||||
{`ifconfig -a`, true},
|
||||
{`ifconfig eth0`, true},
|
||||
{`route -n`, true},
|
||||
{`arp -an`, true},
|
||||
{`lsof -nP -iTCP:443`, true},
|
||||
{`lsof -i`, true},
|
||||
{`fuser -v /tmp/x`, true},
|
||||
{`nslookup example.com`, true},
|
||||
{`nslookup -type=AAAA example.com 192.0.2.1`, true},
|
||||
{`dig +short example.com`, true},
|
||||
{`hddtemp -n /dev/sda`, true},
|
||||
{`hwinfo --short`, true},
|
||||
{`inxi -Fz`, true},
|
||||
{`inxi -c 0 -F`, true},
|
||||
{`blkid -p /dev/sda`, true},
|
||||
{`file /etc/hostname`, true},
|
||||
{`df -hT`, true},
|
||||
{`lastlog -u root`, true},
|
||||
{`dmidecode -t memory`, true},
|
||||
{`sensors -j`, true},
|
||||
{`smartctl -a /dev/sda`, true},
|
||||
{`smartctl -al selftest /dev/sda`, true},
|
||||
{`nvme smart-log /dev/nvme0 -o json`, true},
|
||||
{`ffprobe -v error -show_format -show_streams -of json video.mp4`, true},
|
||||
{`mediainfo --Output=JSON video.mp4`, true},
|
||||
{`exiftool -json image.jpg`, true},
|
||||
{`systemctl status nginx --no-pager`, true},
|
||||
{`systemctl list-units --all`, true},
|
||||
{`docker ps -a`, true},
|
||||
{`docker logs --tail 50 app`, true},
|
||||
{`docker logs -f --tail 50 app`, true},
|
||||
{`docker network inspect bridge`, true},
|
||||
{`pct config 100`, true},
|
||||
{`qm guest cmd 100 network-get-interfaces`, true},
|
||||
{`pvesh get /nodes --output-format json`, true},
|
||||
{`zpool status -v`, true},
|
||||
{`zpool status -x`, true},
|
||||
{`zfs get all rpool`, true},
|
||||
{`dpkg -l`, true},
|
||||
{`rpm -qa`, true},
|
||||
{`kubectl get pods -A -o wide`, true},
|
||||
{`kubectl logs pod --tail=100`, true},
|
||||
{`nc -w 3 -zv example.com 22`, true},
|
||||
{`curl -fsSI --connect-timeout 5 https://example.com`, true},
|
||||
{`wget -q --spider https://example.com`, true},
|
||||
{`cat /var/log/*.log | grep error | sort -u | head -20`, true},
|
||||
{`cat /etc/hostname 2>/dev/null`, true},
|
||||
{`cat /etc/hostname 2>&1`, true},
|
||||
{`sort '2>/dev/null'`, true},
|
||||
{`ss -tuln | grep -E '3000|9090|22'`, true},
|
||||
{`timeout 5s journalctl -f`, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.command, func(t *testing.T) {
|
||||
got := ClassifyExecutionIntent(tt.command)
|
||||
if readOnly := got.Intent != IntentWriteOrUnknown; readOnly != tt.readOnly {
|
||||
t.Errorf("ClassifyExecutionIntent(%q) = %v (%s), want readOnly=%t", tt.command, got.Intent, got.Reason, tt.readOnly)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutionIntent_RouteOperationBoundary(t *testing.T) {
|
||||
command := "route add default gw 192.0.2.1"
|
||||
if reason := matchesWritePatterns(command, command); reason != "" {
|
||||
t.Fatalf("route operation matched an unrelated write pattern: %s", reason)
|
||||
}
|
||||
if isReadOnlyByConstruction(command) {
|
||||
t.Fatal("route add must fail argument validation")
|
||||
}
|
||||
if reason := matchesWritePatterns("dd if=/dev/zero of=/tmp/x", "dd if=/dev/zero of=/tmp/x"); reason != "disk write" {
|
||||
t.Fatalf("dd classification = %q, want disk write", reason)
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,11 @@
|
||||
"incomplete_commit": "dc2057a08c9a9a30340a6cc2bb367d502d48bbda",
|
||||
"completion_commit": "78301b91ed21e1d32c34ce4dedbdea40ddcb6ad7",
|
||||
"reason": "The instance-administration split shipped its route, authorization and contract changes in the first commit; the following commit adds the dependent agent-lifecycle and security-privacy contract updates and teaches the route inventory proof about RequireOrgAdmin. No source identity or required contract/proof gate is rewritten or waived."
|
||||
},
|
||||
{
|
||||
"incomplete_commit": "8196a5534f7eafc9905df07c961176468d13807e",
|
||||
"completion_commit": "0f3d3e8756da77adc8e035dd226f39a4ae7fe245",
|
||||
"reason": "The argument-aware read-only classifier shipped its runtime change and its proving tests in the first commit; the following commit records the pulse_read read-only contract in ai-runtime. No source identity or required contract/proof gate is rewritten or waived."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user