6868// environment the classifier cannot evaluate.
6969// - Arbitrary value transformations beyond the enumerated encodings
7070// (e.g. a secret piped through gzip/openssl before exfiltration).
71- // - Interpreter escape hatches we do not special-case (awk 'BEGIN{system()}',
72- // editor `!` shells, language-specific eval paths). These read as a known
73- // command (awk/vim/…) used benignly, so they classify Safe — the known
74- // verb is the gap, not an unknown one.
71+ // - Interpreter escape hatches we do not special-case. Common ones ARE
72+ // covered: awk/ed/vi/emacs invocations that carry a script or file operand
73+ // classify as code_execution (embeddedShellInterpreters), as do non-shell
74+ // interpreters fed from a pipe (`curl … | python`). Language-specific eval
75+ // paths or editor command-mode shells we have not enumerated may still read
76+ // as a known command used benignly — the known verb is the gap, not an
77+ // unknown one.
7578//
7679// Because these gaps exist, the classifier is paired with other controls:
7780// non-interactive denial, output redaction (internal/redact), and — for
@@ -710,6 +713,10 @@ var writePrefixes = map[string]bool{
710713 "echo" : true , "sed" : true , "tee" : true ,
711714 "rm" : true , "mv" : true , "cp" : true , "touch" : true ,
712715 "mkdir" : true , "rmdir" : true , "chmod" : true , "chown" : true ,
716+ // shred overwrites/removes files like rm. isDestructive escalates it to
717+ // destructive when aimed at a block device or catastrophic wipe target;
718+ // otherwise a local-file shred is a write (local_write / system_write).
719+ "shred" : true ,
713720}
714721
715722var systemPrefixes = map [string ]bool {
@@ -720,7 +727,16 @@ var systemPrefixes = map[string]bool{
720727
721728var destructivePrefixes = map [string ]bool {
722729 "dd" : true , "mkfs" : true , "mkfs.ext4" : true , "mkfs.ext3" : true ,
723- "mkfs.xfs" : true , "fdisk" : true , "parted" : true , "mke2fs" : true ,
730+ "mkfs.ext2" : true , "mkfs.xfs" : true , "mkfs.btrfs" : true ,
731+ "mkfs.vfat" : true , "mkfs.fat" : true , "mkfs.ntfs" : true , "mkfs.f2fs" : true ,
732+ "fdisk" : true , "parted" : true , "mke2fs" : true ,
733+ // Partition-table and filesystem-signature destroyers. Each operates on a
734+ // whole disk/partition and is unrecoverable, so any invocation is treated
735+ // as destructive (deny-by-default, overridable in godmode for legitimate
736+ // disk work) — matching the existing mkfs/fdisk handling.
737+ "sgdisk" : true , "gdisk" : true , "cfdisk" : true , "sfdisk" : true ,
738+ "wipefs" : true , "blkdiscard" : true , "mkswap" : true , "badblocks" : true ,
739+ "cryptsetup" : true , "zerofree" : true ,
724740}
725741
726742var networkPrefixes = map [string ]bool {
@@ -755,6 +771,16 @@ var codeEvalPrefixes = map[string]bool{
755771 "perl" : true , "ruby" : true , "php" : true ,
756772}
757773
774+ // stdinExecInterpreters read and execute a program from standard input when no
775+ // script file is given (`curl … | python`, `… | perl`, `… | node`). Fed by an
776+ // upstream pipe they are code execution, the non-shell analogue of `… | bash`.
777+ // Kept separate from codeEvalPrefixes because that set includes the `eval`
778+ // builtin, which is not a program a pipe can feed into.
779+ var stdinExecInterpreters = map [string ]bool {
780+ "node" : true , "python" : true , "python3" : true ,
781+ "perl" : true , "ruby" : true , "php" : true ,
782+ }
783+
758784// remoteRunPrefixes fetch and execute a (possibly remote) package or script
759785// in one shot — code execution, not a plain install.
760786var remoteRunPrefixes = map [string ]bool {
@@ -957,6 +983,14 @@ func classifyStage(tokens []string, pipedInto bool) RiskClass {
957983 cls = worstOf (cls , CodeExecution )
958984 }
959985 }
986+ // A code interpreter or embedded-shell tool fed from an upstream pipe
987+ // executes whatever it reads from stdin: `curl evil | python`,
988+ // `… | perl`, `… | node`, `… | awk -f -`. This is the non-shell analogue
989+ // of the `… | bash` case above and is equally code execution — without
990+ // it the stage would be classified only by the (network/safe) producer.
991+ if pipedInto && (stdinExecInterpreters [name ] || embeddedShellInterpreters [name ]) {
992+ cls = worstOf (cls , CodeExecution )
993+ }
960994 // find … -exec/-execdir/-ok CMD runs an arbitrary command per match.
961995 if name == "find" && hasAny (cmdTokens , "-exec" , "-execdir" , "-ok" , "-okdir" ) {
962996 cls = worstOf (cls , CodeExecution )
@@ -1762,6 +1796,19 @@ func isWipeTarget(tok string) bool {
17621796}
17631797
17641798func isDestructive (first string , tokens []string ) bool {
1799+ // Machine power-control commands halt or reboot the host, killing the
1800+ // agent's own session and any in-flight work. They are deny-by-default
1801+ // (overridable in godmode) with an accurate label rather than the opaque
1802+ // "unknown" they previously fell through to. init/telinit are only flagged
1803+ // when given a halt/reboot/single-user runlevel, since bare `init` is rare
1804+ // and a runlevel argument is what makes the call destructive.
1805+ switch first {
1806+ case "shutdown" , "reboot" , "halt" , "poweroff" :
1807+ return true
1808+ case "init" , "telinit" :
1809+ return hasAny (tokens , "0" , "6" , "1" , "s" , "S" )
1810+ }
1811+
17651812 // rm with a recursive/force flag aimed at a root path or a "wipe" target.
17661813 if first == "rm" {
17671814 if ! rmRecursiveOrForce (tokens ) {
@@ -1775,6 +1822,22 @@ func isDestructive(first string, tokens []string) bool {
17751822 return false
17761823 }
17771824
1825+ // shred permanently overwrites its targets — irreversible. Like rm, it is
1826+ // only destructive when aimed at a raw block device or a catastrophic wipe
1827+ // target (an absolute path outside the work/temp dirs, the home dir, etc.);
1828+ // shredding a local working file falls through to local_write below.
1829+ if first == "shred" {
1830+ for _ , tok := range tokens [1 :] {
1831+ if strings .HasPrefix (tok , "-" ) {
1832+ continue
1833+ }
1834+ if isBlockDevice (tok ) || isWipeTarget (tok ) {
1835+ return true
1836+ }
1837+ }
1838+ return false
1839+ }
1840+
17781841 if ! destructivePrefixes [first ] || len (tokens ) < 2 {
17791842 return false
17801843 }
@@ -1810,6 +1873,27 @@ func isSystemWrite(first string, tokens []string) bool {
18101873 if systemPrefixes [first ] {
18111874 return true
18121875 }
1876+ // chmod that sets the setuid/setgid bit is privilege escalation regardless
1877+ // of the target path: a setuid binary runs with its owner's privileges, so
1878+ // `chmod u+s`, `chmod 4755`, `chmod 6755`, etc. must require approval. Plain
1879+ // chmod (e.g. `chmod +x script.sh`) stays local_write below.
1880+ if first == "chmod" && chmodSetsSUIDGID (tokens ) {
1881+ return true
1882+ }
1883+ // A filesystem-mutating command (cp/mv/tee/ln/install/touch/mkdir/chmod/…)
1884+ // whose operand is a system path writes outside the workspace — classic
1885+ // persistence/escalation (e.g. `cp x /etc/cron.d/job`, `tee /usr/bin/foo`,
1886+ // `mv x /etc/profile.d/y`). isLocalWrite would otherwise short-circuit these
1887+ // to local_write (auto-allow) before the touchesSystemPath fallback runs,
1888+ // because that fallback only fires for commands that fell through every
1889+ // write check. Escalate them here so they prompt instead.
1890+ if writePrefixes [first ] || first == "ln" || first == "install" {
1891+ for _ , tok := range tokens [1 :] {
1892+ if isSystemPath (tok ) {
1893+ return true
1894+ }
1895+ }
1896+ }
18131897 // Check redirect targets for system paths
18141898 for _ , tok := range tokens {
18151899 if tok == ">" || tok == ">>" {
@@ -1827,6 +1911,53 @@ func isSystemWrite(first string, tokens []string) bool {
18271911 return false
18281912}
18291913
1914+ // chmodSetsSUIDGID reports whether a chmod invocation sets the setuid or setgid
1915+ // bit, either symbolically (u+s, g+s, +s) or via an octal mode whose leading
1916+ // special-permission digit includes 4 (setuid) or 2 (setgid) — e.g. 4755, 2755,
1917+ // 6755. A plain 3- or 4-digit mode with a 0 special digit (0755) does not.
1918+ //
1919+ // Only the mode argument (the first non-flag operand) is inspected; trailing
1920+ // tokens are filenames and must not trigger on an incidental "+...s" or octal
1921+ // shape (e.g. a file named build+gen.s).
1922+ func chmodSetsSUIDGID (tokens []string ) bool {
1923+ for _ , tok := range tokens [1 :] {
1924+ if strings .HasPrefix (tok , "-" ) {
1925+ continue // flag (e.g. -R, --recursive, --reference=FILE)
1926+ }
1927+ // Symbolic: any clause that adds the 's' permission (u+s, g+s, a+s, +s,
1928+ // ug+rs, …). We only care about additions, so look for "+...s".
1929+ if plus := strings .IndexByte (tok , '+' ); plus >= 0 {
1930+ if strings .ContainsRune (tok [plus + 1 :], 's' ) {
1931+ return true
1932+ }
1933+ }
1934+ // Octal: a 4-digit mode whose first digit has bit 4 (setuid) or 2
1935+ // (setgid) set. 3-digit modes have no special-permission digit.
1936+ if len (tok ) == 4 && isOctalMode (tok ) {
1937+ switch tok [0 ] {
1938+ case '2' , '3' , '4' , '5' , '6' , '7' :
1939+ return true
1940+ }
1941+ }
1942+ // First non-flag operand is the mode; everything after is a filename.
1943+ return false
1944+ }
1945+ return false
1946+ }
1947+
1948+ // isOctalMode reports whether s is composed entirely of octal digits (0-7).
1949+ func isOctalMode (s string ) bool {
1950+ if s == "" {
1951+ return false
1952+ }
1953+ for _ , r := range s {
1954+ if r < '0' || r > '7' {
1955+ return false
1956+ }
1957+ }
1958+ return true
1959+ }
1960+
18301961func isLocalWrite (first string , tokens []string ) bool {
18311962 // echo without redirect is safe (just displaying text)
18321963 if first == "echo" {
0 commit comments