Files
hermes-agent/tools/approval_detection.py
teknium1 e383c28d2f fix(approval): judge shell quoting on the raw command, not the escape-stripped one
The hardline floor tracked quote state on text that
_normalize_command_for_detection had already rewritten (`\"` -> `"`).
That flips quote parity and broke both ways:

- false positive: a shell-valid `grep -o "[^\"]*" f` lexed as an
  unterminated quote and hit the unconditional "malformed executable
  payload" block. 118 of the 125 hardline blocks in one week of real
  agent use on this install were this shape, every one a benign grep,
  and the block cannot be bypassed by --yolo or approvals.mode=off.
- bypass: `cat "f\"n.txt"; rm -rf --no-preserve-root /` put the `; rm`
  start "inside" a phantom quote, so no command start was marked and the
  floor let the root wipe through with approved=True.

Fix: the malformed-quoting verdict reads the raw command (only quoted
newlines masked, which keeps quoting intact), and
_command_detection_variants adds a variant whose command starts were
marked on the raw command before normalization. The marker is " \n" so a
preceding literal backslash cannot eat it as a line continuation.
_iter_shell_command_starts no longer treats the `{` of `${...}` as a
brace-group opener, so the new variant does not split `${IFS}` and defeat
the IFS collapse.

Direction from #85922 by @Soju06, re-implemented onto the decomposed
tools/approval_detection.py; the parameter-expansion scanner from that PR
is replaced by the one-character `${` check above.

Co-authored-by: Soju06 <qlskssk@gmail.com>
2026-09-14 09:19:25 -07:00

1457 lines
82 KiB
Python

"""Dangerous-command detection: normalization, tokenizing, and pattern tables.
Pure command classification for :mod:`tools.approval` — no approval state, config reads, or
prompting live here.
"""
import functools
import logging
import os
import re
import shlex
import tempfile
import unicodedata
logger = logging.getLogger("tools.approval")
# Sensitive write targets, matched via ~ / $HOME / $HERMES_HOME spellings. The resolved absolute
# home is folded into these forms at detection time by _normalize_command_for_detection(), so no
# import-time path snapshot (stale once HERMES_HOME is set after import) lives in the patterns.
_SSH_SENSITIVE_PATH = r'(?:~|\$home|\$\{home\})/\.ssh(?:/|$)'
_HERMES_ENV_PATH = (
r'(?:~\/\.hermes/|(?:\$home|\$\{home\})/\.hermes/|(?:\$hermes_home|\$\{hermes_home\})/)' r'\.env\b'
)
# ~/.hermes/config.yaml IS the security policy (approvals.mode, yolo, allowlist) and the config cache is mtime-keyed,
# so a write takes effect mid-session. Terminal-side coverage (sed -i, tee, >, cp) pairs the file_tools deny.
_HERMES_CONFIG_PATH = (
r'(?:~\/\.hermes/|(?:\$home|\$\{home\})/\.hermes/|(?:\$hermes_home|\$\{hermes_home\})/)' r'config\.yaml\b'
)
_PROJECT_ENV_PATH = r'(?:(?:/|\.{1,2}/)?(?:[^\s/"\'`]+/)*\.env(?:\.[^/\s"\'`]+)*)'
_PROJECT_CONFIG_PATH = r'(?:(?:/|\.{1,2}/)?(?:[^\s/"\'`]+/)*config\.yaml)'
_SHELL_RC_FILES = r'(?:~|\$home|\$\{home\})/\.' r'(?:bashrc|zshrc|profile|bash_profile|zprofile)\b'
_CREDENTIAL_FILES = r'(?:~|\$home|\$\{home\})/\.' r'(?:netrc|pgpass|npmrc|pypirc)\b'
# macOS: /etc, /var, /tmp, /home are symlinks to /private/*, so /private/etc/sudoers would bypass a plain
# "/etc/" check. Match both forms.
_MACOS_PRIVATE_SYSTEM_PATH = r'/private/(?:etc|var|tmp|home)/'
_SYSTEM_CONFIG_PATH = rf'(?:/etc/|{_MACOS_PRIVATE_SYSTEM_PATH})'
_SENSITIVE_WRITE_TARGET = (
rf'(?:{_SYSTEM_CONFIG_PATH}|/dev/sd|{_SSH_SENSITIVE_PATH}|{_HERMES_ENV_PATH}|{_HERMES_CONFIG_PATH}|'
rf'{_SHELL_RC_FILES}|{_CREDENTIAL_FILES})'
)
_USER_SENSITIVE_WRITE_TARGET = rf'(?:{_SSH_SENSITIVE_PATH}|{_SHELL_RC_FILES}|{_CREDENTIAL_FILES})'
_PROJECT_SENSITIVE_WRITE_TARGET = rf'(?:{_PROJECT_ENV_PATH}|{_PROJECT_CONFIG_PATH})'
# cp/mv/install: the sensitive path is a write target only as the LAST argument (destination), so
# `cp config.yaml backup.yaml` (config.yaml as SOURCE) stays out.
_COMMAND_TAIL = r'(?:\s*(?:&&|\|\||;).*)?$'
# `>`/`>>`/tee: the path is ALWAYS a write target regardless of what follows, so only require a
# shell word boundary (_COMMAND_TAIL let `echo x > .env extra` / `echo x > .env # note` slip past).
# `#` is deliberately NOT a boundary: a glued `#` is part of the filename (`.env#backup`).
_WRITE_TARGET_BOUNDARY = r'(?=[\s;&|<>"\']|$)'
# ---- Hardline (unconditional) blocklist ---------------------------------------------------
# Commands that NEVER run via the agent, regardless of --yolo, approvals.mode=off, or cron approve
# mode — a floor below yolo. Applies only to environments that can damage the host (local, ssh,
# container-host cron); containerized backends already bypass the dangerous-command layer.
# Deliberately tiny: only things with no recovery path (root wipe, raw block device writes,
# shutdown, DoS). Recoverable operations (git reset --hard, chmod -R 777, curl|sh) stay in
# DANGEROUS_PATTERNS.
# Start-of-command position: start of string, newline, subshell opener ($( or backtick), optionally consuming
# sudo/env/exec/nohup/setsid/time wrappers. Keeps shutdown/reboot rules from firing on "echo reboot" / "grep
# 'shutdown' log". Real ;/&/| separators are converted to newlines by the quote-aware _mark_command_starts pass;
# keeping them here mistakes quoted data (grep '(safe|rm -rf /)') for commands.
_CMDPOS = (
r'(?:^|[\n`]|\$\()' r'\s*' # start position, optional whitespace
r'(?:sudo\s+(?:-[^\s]+\s+)*)?' r'(?:env\s+(?:\w+=\S*\s+)*)?' # optional sudo with flags, env VAR=VAL pairs
r'(?:(?:exec|nohup|setsid|time)\s+)*' r'\s*' # optional wrapper commands
)
# Destructive-path matcher for the rm hardline rules: accept the path fully wrapped in a matching
# quote pair (`rm -rf "/"`, `rm -rf "$HOME"`) OR bare with a terminator (whitespace, end, or
# `) ` ; | &` so `$(rm -rf /)` is caught). A bare-token-only anchor lets quoted spellings slip past the floor.
def _hardline_rm_path(path_alt: str, tail: str = r'(?:\s|$|[)`;|&])') -> str:
return rf'(?:["\'](?:{path_alt})["\']|(?:{path_alt}){tail})'
# Protected system roots whose recursive deletion has no recovery path.
_HARDLINE_SYSTEM_DIRS = (r'/home|/home/\*|/root|/root/\*|/etc|/etc/\*|/usr|/usr/\*|'
r'/var|/var/\*|/bin|/bin/\*|/sbin|/sbin/\*|/boot|/boot/\*|/lib|/lib/\*')
# `rm` plus flag group, shared by the rm hardline rules (plain concatenation, not f-string:
# backslashes in replacement fields are unsupported on the 3.11 floor). _CMDPOS-anchored so `rm`
# must be an actual command word — "rm -rf /" as DATA in `git commit -m "…rm -rf /…"` must not trip the floor.
_RM_FLAG_PREFIX = _CMDPOS + r'rm\s+(-[^\s]*\s+)*'
# Package-manager global options, each optionally taking ONE non-dash operand.
_PKG_OPTS = r'(?:-[^\s]+(?:\s+[^-\s][^\s]*)?\s+)*'
HARDLINE_PATTERNS = [
# Root path: any root-anchored path whose components collapse to "/" in the shell ("/", "//",
# "/.", "/./", "/../..", optional trailing glob). Each inter-slash segment must be exactly "."
# or "..", so "/tmp", "/.ssh", even "/..." are literal dirs that fall through to the softer
# DANGEROUS rules. The "/ \*" alt covers `rm -rf / *` (two args: "/" plus the glob).
(_RM_FLAG_PREFIX + _hardline_rm_path(r'/(?:(?:\.\.?)?/)*(?:\.\.?)?\**|/ \*'), "recursive delete of root filesystem"),
(_RM_FLAG_PREFIX + _hardline_rm_path(_HARDLINE_SYSTEM_DIRS), "recursive delete of system directory"),
(_RM_FLAG_PREFIX + _hardline_rm_path(r'(?:~|\$\{?HOME\}?)(?:/?|/\*)?'), "recursive delete of home directory"),
# Command-name rules (mkfs, dd, kill, shutdown...) are _CMDPOS-anchored so quoted prose
# (`echo "does this use mkfs?"`) cannot trip the floor.
# See #93392.
(_CMDPOS + r'mkfs(\.[a-z0-9]+)?\b', "format filesystem (mkfs)"),
# `dd` is a command-name token, so anchor it to command position like mkfs/rm/shutdown (#93392): quoted
# prose such as `git commit -m "never dd of=/dev/sda"` is an argument, not a command. The argument tail
# ([^\n]*of=/dev/...) is kept so flag order doesn't matter.
(_CMDPOS + r'dd\b[^\n]*\bof=/dev/(sd|nvme|hd|mmcblk|vd|xvd)[a-z0-9]*', "dd to raw block device"),
# Positionless rules (no command-name token: `>` sits mid-command, the fork bomb is a function
# definition) are matched against a QUOTE-MASKED variant (_QUOTE_MASKED_HARDLINE_DESCRIPTIONS /
# _mask_quoted_prose) so quoted prose cannot trip them; sh -c / bash -c / eval payloads still scan raw.
# The redirect rule has no command-name token to anchor (`>` appears mid-command: `cat f > /dev/sda`),
# so command-position anchoring is the wrong tool. It is instead matched against a QUOTE-MASKED variant
# of the command (see _QUOTE_MASKED_HARDLINE / _mask_quoted_strings) so quoted prose (`echo "cat f >
# /dev/sda"`) cannot trip it, while shell-carrying wrappers (sh -c / bash -c / eval) still surface their
# payload as a raw detection variant — quoting is not a bypass (#93392).
(r'>\s*/dev/(sd|nvme|hd|mmcblk|vd|xvd)[a-z0-9]*\b', "redirect to raw block device"),
(r':\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:', "fork bomb"),
# Kill every process on the system — anchor the command-name token so `echo "kill -1 sends SIGHUP to
# everything"` doesn't trip (#93392).
(_CMDPOS + r'kill\s+(-[^\s]+\s+)*-1\b', "kill all processes"),
(_CMDPOS + r'(shutdown|reboot|halt|poweroff)\b', "system shutdown/reboot"),
(_CMDPOS + r'init\s+[06]\b', "init 0/6 (shutdown/reboot)"),
(_CMDPOS + r'systemctl\s+(poweroff|reboot|halt|kexec)\b', "systemctl poweroff/reboot"),
(_CMDPOS + r'telinit\s+[06]\b', "telinit 0/6 (shutdown/reboot)"),
]
# Pre-compiled at module load so the hot-path matcher never pays the cold re.compile fan-out
# (re._cache can be evicted by unrelated regex work).
_RE_FLAGS = re.IGNORECASE | re.DOTALL
# Positionless hardline rules matched against quote-masked variants (see above).
_QUOTE_MASKED_HARDLINE_DESCRIPTIONS = frozenset({"redirect to raw block device", "fork bomb"})
HARDLINE_PATTERNS_COMPILED = [
(re.compile(p, _RE_FLAGS), d, d in _QUOTE_MASKED_HARDLINE_DESCRIPTIONS) for p, d in HARDLINE_PATTERNS
]
# Commands that hand a quoted argument to another shell to EXECUTE: quoted text is code, not
# prose, so quote-masked hardline rules scan the raw string.
_SHELL_CARRIER_NAMES = frozenset({"eval", "sh", "bash", "zsh", "ksh", "dash", "source", "."})
def _contains_shell_carrier(command: str) -> bool:
"""Return whether any command-position word is a shell-carrying command."""
return any(
os.path.basename(_deobfuscate_shell_word_for_detection(word)).lower() in _SHELL_CARRIER_NAMES
for _, _, word in _iter_shell_command_word_spans(command)
)
def _mask_quoted_prose(command: str) -> str:
"""Blank out quoted string CONTENT for positionless hardline matching (detection-only).
Quote characters stay; inside double quotes `$(...)` and backtick spans are kept RAW because
the shell really executes them. An unclosed quote masks to end-of-string, which cannot hide a
runnable command (the shell would not run it either).
Detection-only rewrite used by the quote-masked hardline rules (redirect-to-block-device, fork bomb):
text inside single or double quotes is data the shell passes as an argument, so `echo "cat f >
/dev/sda"` must not trip the unconditional floor (#93392). Unquoted text is untouched.
"""
return "".join(
command[i:j] if quote is None or kind in ("quote", "subst") else " " * (j - i)
for kind, i, j, quote in _scan_shell(command, subst="q", naive_backtick=True)
)
# ---- Sudo stdin guard: without SUDO_PASSWORD configured, an explicit "sudo -S" is the LLM piping
# a guessed password via stdin (brute-force vector). Unconditional block.
_SUDO_STDIN_RE = re.compile(r'(?:^|[;&|`\n]|&&|\|\||\$\()\s*sudo\s+-S\b', re.IGNORECASE)
def _check_sudo_stdin_guard(command: str) -> tuple:
"""Detect ``sudo -S`` without configured SUDO_PASSWORD -> (is_blocked, description). When
SUDO_PASSWORD is set, ``_transform_sudo_command`` injects ``-S`` itself, so this guard only
fires when the LLM wrote it explicitly."""
if "SUDO_PASSWORD" not in os.environ and _SUDO_STDIN_RE.search(_normalize_command_for_detection(command).lower()):
return (True, "sudo password guessing via stdin (sudo -S)")
return (False, None)
def detect_hardline_command(command: str) -> tuple:
"""Check hardline patterns (NEVER bypassable, even in YOLO) -> (is_hardline, description)."""
if _command_parser_limit_exceeded(command):
return (True, _PARSER_LIMIT_DESCRIPTION)
# The malformed-quoting verdict needs the author's quote state. Normalization strips escapes
# (`\"` -> `"`), so a shell-valid pattern like `grep -o "[^\"]*"` lexed as unterminated and was
# reported as a hardline block (118 of 125 hardline blocks in one week of real use, every one a
# benign grep). Only quoted newlines are masked: they are data, and masking keeps quoting intact.
_, malformed_grep = _grep_safe_detection_variant(_mask_quoted_newlines(command))
if malformed_grep:
return (True, _MALFORMED_EXEC_DESCRIPTION)
for command_variant in _command_detection_variants(command):
variant_lower = command_variant.lower()
masked_lower: str | None = None
for pattern_re, description, quote_masked in HARDLINE_PATTERNS_COMPILED:
if quote_masked and masked_lower is None:
# Positionless rules see quoted prose as DATA, except under shell carriers
# (sh -c, eval, source) whose quoted argument is code — those scan raw. bash -c
# payloads also surface as their own raw variants via _execution_flag_findings.
masked_lower = (
variant_lower if _contains_shell_carrier(command_variant)
else _mask_quoted_prose(command_variant).lower()
)
if pattern_re.search(masked_lower if quote_masked else variant_lower):
return (True, description)
return (False, None)
# ---- Dangerous command patterns -----------------------------------------------------------
DANGEROUS_PATTERNS = [
(r'\brm\s+(-[^\s]*\s+)*/', "delete in root path"),
(r'\brm\s+-[^\s]*r', "recursive delete"),
(r'\brm\s+--recursive\b', "recursive delete (long flag)"),
# GNU rm permutes options, so flags may FOLLOW operands (`rm build/ -rf`). The operand run
# cannot cross a command separator (so `rm foo | grep -r` is not attributed to rm), a quote,
# or a bare ` -- ` end-of-options (after which `-rf` is a literal filename). The flag token
# must follow whitespace so the `r` in long options like `--registry` does not count.
(r'\brm\s+(?!--(?:\s|$))(?:(?!\s--(?:\s|$))[^\n"\';|&])*\s' r'(?:-[a-z]*r[a-z]*\b|--recursive\b)',
# GNU rm permutes options, so a recursive flag group may legally FOLLOW the operands: `rm build/ -rf`,
# `rm build/ -r -f`, and `rm build/ --recursive --force` are all equivalent to the flags-first
# spellings the two patterns above catch — without this rule they run with no approval prompt at all.
# Port of openai/codex#33464 ("recognize force options when they follow operands").
"recursive delete (flags after operands)"),
# Windows cmd/powershell destructive built-ins: gate only when executed through the shell so
# prose/filenames containing "del"/"rd" do not trip.
(r'\bcmd(?:\.exe)?\s+/(?:c|k)\s+.*\b(?:del|erase|rd|rmdir)\b', "Windows cmd destructive delete"),
# PowerShell runs the verb as default positional arg (no -Command needed); anchor the verb to command
# position (after leading -Flag switches and optional -Command/-c) so `-File c:\del-logs\run.ps1` is not caught.
(r'\b(?:powershell|pwsh)(?:\.exe)?\b(?:\s+-\S+)*\s+(?:-(?:command|c)\s+)?["\']?(?:remove-item|rmdir|erase|del|rd|ri|rm)\b', "Windows PowerShell destructive delete"),
(r'\b(?:powershell|pwsh)(?:\.exe)?\b.*\s-(?:encodedcommand|enc|e)\b', "PowerShell encoded command execution"),
# ── Windows destructive tier: native Windows EXEs/cmdlets reachable from ANY backend on a
# Windows host (incl. git-bash). Input is lowercased by the variant loop, so patterns are
# lowercase. Each requires the destructive flag/verb so benign usage (`taskkill /IM app.exe`,
# `reg query`, `icacls file`) does NOT prompt. Bare Remove-Item form (ACP clients, pwsh-default
# SSH hosts, or compound commands where `powershell` appeared earlier).
# See #69472.
(r'\bremove-item\b[^\n;|&]*\s-(?:recurse|force)\b', "PowerShell destructive delete (Remove-Item)"),
# Bare cmd builtins with /s (recurse) or /q (quiet); plain `del file.txt` is covered only by the prefixed rule.
(r'\b(?:del|erase|rd|rmdir)\s+(?:/[a-z]\s+)*/[sq]\b', "Windows destructive delete (recursive/quiet switch)"),
# Remote content piped to Invoke-Expression — PowerShell's `curl | sh`.
(r'\b(?:iwr|invoke-webrequest|invoke-restmethod|irm|curl|wget)\b[^\n]*\|\s*(?:iex|invoke-expression)\b', "pipe remote content to PowerShell (iwr | iex)"),
(r'\b(?:iex|invoke-expression)\s*\(\s*(?:iwr|invoke-webrequest|invoke-restmethod|irm)\b', "execute remote content via Invoke-Expression"),
# Force process kills — Windows analogue of pkill -9.
(r'\btaskkill\b[^\n]*\s/f\b', "force kill processes (taskkill /F)"),
(r'\bstop-process\b[^\n]*\s-force\b', "force kill processes (Stop-Process -Force)"),
# Volume/disk destruction — Windows analogue of mkfs / dd.
(r'\bformat-volume\b', "format filesystem (Format-Volume)"),
(r'\bclear-disk\b', "wipe disk (Clear-Disk)"),
(r'\bdiskpart\b', "disk partitioning (diskpart)"),
(r'\bformat(?:\.com)?\s+[a-z]:', "format drive (format.com)"),
(r'\bcipher\s+/w\b', "wipe free space (cipher /w)"),
# ACL destruction — Windows analogue of chmod 777.
(r'\bicacls\b[^\n]*\s/grant\b[^\n]*\b(?:everyone|todos|jeder|tout\s+le\s+monde|\*s-1-1-0)\b', "grant Everyone access (icacls)"),
(r'\bicacls\b[^\n]*\s/reset\b', "reset ACLs recursively (icacls /reset)"),
# Backup/recovery destruction — classic ransomware prep.
(r'\bvssadmin\b[^\n]*\bdelete\s+shadows\b', "delete volume shadow copies (vssadmin)"),
(r'\bwbadmin\b[^\n]*\bdelete\b', "delete backups (wbadmin)"),
(r'\bbcdedit\b[^\n]*\s/set\b', "modify boot configuration (bcdedit /set)"),
# Registry deletion with force flag.
(r'\breg(?:\.exe)?\s+delete\b', "registry delete (reg delete)"),
(r'\bremove-itemproperty\b[^\n]*\s-force\b', "registry value delete (Remove-ItemProperty -Force)"),
# Windows service/system stop — analogue of systemctl stop.
(r'\bstop-service\b[^\n]*\s-force\b', "force stop service (Stop-Service -Force)"),
(r'\bsc(?:\.exe)?\s+(?:stop|delete)\b', "stop/delete service (sc)"),
# Windows-form credential paths; the POSIX ~/.ssh patterns never match drive-letter or backslash spellings.
(r'\busers[\\/][^\\/\s]+[\\/]\.ssh\b', "access to SSH keys (Windows path)"),
(r'\bappdata[\\/](?:local|roaming)[\\/]hermes[^\n]*\.env\b', "access to Hermes secrets (Windows path)"),
# ── end of Windows tier
(r'\bchmod\s+(-[^\s]*\s+)*(777|666|o\+[rwx]*w|a\+[rwx]*w)\b', "world/other-writable permissions"),
(r'\bchmod\s+--recursive\b.*(777|666|o\+[rwx]*w|a\+[rwx]*w)', "recursive world/other-writable (long flag)"),
(r'\bchown\s+(-[^\s]*)?R\s+root', "recursive chown to root"),
(r'\bchown\s+--recur[a-z]*\b.*root', "recursive chown to root (long flag)"),
# _CMDPOS-anchored like the hardline twins: quoted prose mentioning mkfs/dd must not require approval to echo.
# See #93392.
(_CMDPOS + r'mkfs\b', "format filesystem"),
(_CMDPOS + r'dd\s+.*if=', "disk copy"),
(r'>\s*/dev/sd', "write to block device"),
(r'\bDROP\s+(TABLE|DATABASE)\b', "SQL DROP"),
# [^\n]* not .*: under DOTALL a WHERE on the *next* line would satisfy the lookahead and
# silently allow DELETE without WHERE.
(r'\bDELETE\s+FROM\b(?![^\n]*\bWHERE\b)', "SQL DELETE without WHERE"),
(r'\bTRUNCATE\s+(TABLE)?\s*\w', "SQL TRUNCATE"),
(rf'>\s*{_SYSTEM_CONFIG_PATH}', "overwrite system config"),
(r'\bsystemctl\s+(-[^\s]+\s+)*(stop|restart|disable|mask)\b', "stop/restart system service"),
(r'\bkill\s+-9\s+-1\b', "kill all processes"),
(r'\bpkill\s+-9\b', "force kill processes"),
# killall with SIGKILL (-9 / -KILL / -s KILL / -SIGKILL) and `killall -r <regex>` broad sweeps
# that can wipe unrelated processes.
(r'\bkillall\s+(-[^\s]*\s+)*-(9|KILL|SIGKILL)\b', "force kill processes (killall -KILL)"),
(r'\bkillall\s+(-[^\s]*\s+)*-s\s+(KILL|SIGKILL|9)\b', "force kill processes (killall -s KILL)"),
(r'\bkillall\s+(-[^\s]*\s+)*-r\b', "kill processes by regex (killall -r)"),
(r':\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:', "fork bomb"),
# Shell -c is parsed structurally by _execution_flag_findings(); a regex searching a dash-token
# for "c" also matched --norc/--rcfile/--restricted.
(r'\b(curl|wget)\b.*\|\s*(?:[/\w]*/)?(?:ba)?sh(?:\s|$|-c)', "pipe remote content to shell"),
(r'\b(bash|sh|zsh|ksh)\s+<\s*<?\s*\(\s*(curl|wget)\b', "execute remote script via process substitution"),
# eval/source/. $(curl ...) — equivalent to piping remote content to a shell.
(r'(?:\beval\b|\bsource\b|\.)\s*(?:\$\(\s*|`\s*)(?:curl|wget)\b', "execute remote content via command substitution"),
# Cloud instance-metadata (IMDS) credential endpoints — deterministic containment-escape
# detection. On a cloud VM these serve live IAM/service-account credentials to ANY local
# process with no auth, so a fetch is credential exfiltration unless the operator expects it.
# The host literals have no other use, so their appearance ANYWHERE in the command (any HTTP
# client, env assignment, or script argument) is the signal; lookarounds keep other 169.254.x.x
# link-local addresses and longer dotted strings out. This prompts for approval (legit uses
# exist on real cloud VMs) — it is NOT a hardline block. Covers the link-local IPv4 endpoint
# (AWS/Azure/GCP/OpenStack), its AWS IPv6 form fd00:ec2::254, the GCP hostname, and Alibaba
# Cloud's 100.100.100.200.
(r'(?<![\d.])(?:169\.254\.169\.254|100\.100\.100\.200)(?![\d.])'
r'|(?<![\w.-])metadata\.google\.internal(?![\w.-])'
r'|fd00:ec2::254',
"cloud metadata endpoint access (instance credentials)"),
# Decode-and-execute: `echo <base64> | base64 -d | bash` carries no dangerous keywords in the
# raw text yet runs arbitrary commands.
(r'\b(base64|base32|base16)\s+(?:-[dD]|--decode)\b.*\|\s*\b(bash|sh|zsh|ksh|dash)\b', "pipe decoded content to shell (possible command obfuscation)"),
# xxd uses -r for decode, not -d.
(r'\bxxd\s+-r\b.*\|\s*\b(bash|sh|zsh|ksh|dash)\b', "pipe xxd-decoded content to shell (possible command obfuscation)"),
# `echo 'eq -pe v/' | tr 'eqv' 'rmf' | bash` decodes to `rm -rf /`.
(r'\becho\b[^|]*\|\s*\btr\b[^|]*\|\s*\b(bash|sh|zsh|ksh|dash)\b', "pipe tr-transformed output to shell (possible command obfuscation)"),
(r'\bopenssl\b.*\b(?:base64|enc)\b[^|]*\s+-[dD]\b[^|]*\|\s*\b(bash|sh|zsh|ksh|dash)\b',
"pipe openssl-decoded content to shell (possible command obfuscation)"),
(rf'\btee\b.*["\']?{_SENSITIVE_WRITE_TARGET}', "overwrite system file via tee"),
(rf'>>?\s*["\']?{_SENSITIVE_WRITE_TARGET}', "overwrite system file via redirection"),
(rf'\btee\b.*["\']?{_PROJECT_SENSITIVE_WRITE_TARGET}["\']?{_WRITE_TARGET_BOUNDARY}', "overwrite project env/config via tee"),
(rf'>>?\s*["\']?{_PROJECT_SENSITIVE_WRITE_TARGET}["\']?{_WRITE_TARGET_BOUNDARY}', "overwrite project env/config via redirection"),
(r'\bxargs\s+.*\brm\b', "xargs with rm"),
# -execdir has the same semantics as -exec (runs in each match's directory).
(r'\bfind\b.*-exec(?:dir)?\s+(/\S*/)?rm\b', "find -exec/-execdir rm"),
(r'\bfind\b.*-delete\b', "find -delete"),
# Gateway lifecycle: stopping/restarting the gateway kills all running agents. Global flags
# between `hermes` and `gateway` (`hermes -p ade gateway restart`) are allowed so a profile flag can't slip past.
(r'\bhermes\s+(?:-{1,2}\S+(?:\s+\S+)?\s+)*gateway\s+(stop|restart)\b', "stop/restart hermes gateway (kills running agents)"),
(r'\bhermes\s+update\b', "hermes update (restarts gateway, kills running agents)"),
# Docker/Podman daemon redirect — global flags or env that point the CLI at a DIFFERENT (often remote) daemon:
# `docker -H ssh://prod stop app` looks local but operates on remote infra, so any redirect requires approval
# regardless of subcommand. The flag must be in global position (before the subcommand) and -H/--host/--context
# must carry a value, keeping `docker -h` and `docker run -h <hostname>` out. Listed BEFORE the lifecycle rules so
# a redirected lifecycle command surfaces the more specific reason.
(r'\bdocker\s+(?:-{1,2}\S+(?:[=\s]\S+)?\s+)*(?:-h|--host)[=\s]+\S+', "docker with remote daemon redirect (-H/--host)"),
(r'\bdocker\s+(?:-{1,2}\S+(?:[=\s]\S+)?\s+)*(?:-c|--context)[=\s]+\S+', "docker with daemon redirect (--context: alternate daemon)"),
(r'\bdocker\s+context\s+use\b', "docker context use (switches default daemon for future commands)"),
(r'\bpodman\s+(?:-{1,2}\S+(?:[=\s]\S+)?\s+)*(?:--url|--connection|--identity)[=\s]+\S+', "podman with remote daemon redirect (--url/--connection/--identity)"),
(r'\bpodman\s+(?:-{1,2}\S+(?:[=\s]\S+)?\s+)*(?:-r\b|--remote\b)', "podman remote mode (-r/--remote: remote daemon)"),
(r'\b(?:docker_host|docker_context|container_host|container_connection)=\S+', "docker/podman daemon redirect via environment (DOCKER_HOST/CONTAINER_HOST)"),
# Container lifecycle (docker.sock mounts let the agent stop/kill containers) always needs
# consent. Global flags between docker/compose and the verb and the legacy `docker-compose`
# binary are allowed so a flag can't slip past.
(r'\bdocker(?:-compose|\s+compose)\s+(?:-{1,2}\S+(?:[=\s]\S+)?\s+)*(restart|stop|kill|down)\b', "docker compose restart/stop/kill/down (container lifecycle)"),
(r'\bdocker\s+(?:-{1,2}\S+(?:[=\s]\S+)?\s+)*(restart|stop|kill)\b', "docker restart/stop/kill (container lifecycle)"),
# Gateway protection: never start gateway outside systemd management
(r'gateway\s+run\b.*(&\s*$|&\s*;|\bdisown\b|\bsetsid\b)', "start gateway outside systemd (use 'systemctl --user restart hermes-gateway')"),
(r'\bnohup\b.*gateway\s+run\b', "start gateway outside systemd (use 'systemctl --user restart hermes-gateway')"),
# Self-termination protection: prevent agent from killing its own process
(r'\b(pkill|killall)\b.*\b(hermes|gateway|cli\.py)\b', "kill hermes/gateway process (self-termination)"),
# Self-termination via kill + $(pgrep/pidof): the substitution is opaque to the name-based
# pattern above, so catch the structural form.
(r'\bkill\b.*\$\(\s*(pgrep|pidof)\b', "kill process via pgrep/pidof expansion (self-termination)"),
(r'\bkill\b.*`\s*(pgrep|pidof)\b', "kill process via backtick pgrep/pidof expansion (self-termination)"),
# launchctl-driven gateway stop/restart on macOS (label `ai.hermes.gateway`). Two independent lookaheads, NOT a
# sequential match: a for-loop building the label from a list defined EARLIER (`for item in 'ai.hermes...'; do
# launchctl bootout "$label"`) never has "hermes" after the verb, and that slipped past and restarted 4 gateways
# with zero approval. Erring broad is correct for an approval gate: an extra prompt is cheap.
(r'(?=[\s\S]*\blaunchctl\s+(?:stop|kickstart|bootout|unload|kill|disable|remove)\b)(?=[\s\S]*\b(?:hermes|ai\.hermes)\b)', "stop/restart hermes launchd service (kills running agents)"),
(rf'\b(cp|mv|install)\b.*\s{_SYSTEM_CONFIG_PATH}', "copy/move file into system config path"),
(rf'\b(cp|mv|install)\b.*\s["\']?{_PROJECT_SENSITIVE_WRITE_TARGET}["\']?{_COMMAND_TAIL}', "overwrite project env/config file"),
# cp/mv/install OVERWRITING a credential/SSH/shell-rc/Hermes file (key implant, login-time
# injection) — pairs the tee/redirection coverage. Anchored to the command tail so only the
# DESTINATION fires; reading OUT of a sensitive path (`cp ~/.ssh/config /tmp/x`) stays safe.
# The trailing `[^\s"\']*` consumes the rest of the destination filename.
# The tee/redirection patterns above already gate _SENSITIVE_WRITE_TARGET (~/.ssh/*,
# ~/.netrc/.pgpass/.npmrc/.pypirc, shell rc files, ~/.hermes/config.yaml/.env), but cp/mv/install was
# only paired for /etc and project-relative env/config — so `cp evil ~/.ssh/authorized_keys` (key
# implant), `cp creds ~/.netrc`, and `cp evil ~/.bashrc` (login-time command injection) slipped through
# with auto-approve. Same unpaired-door rationale as #14639 / the sed-tee-redirect pairing on these
# targets. `authorized_keys` after the `~/.ssh/` fragment).
(rf'\b(cp|mv|install)\b.*\s["\']?{_SENSITIVE_WRITE_TARGET}[^\s"\']*["\']?{_COMMAND_TAIL}', "copy/move file into sensitive credential/SSH/shell-rc path"),
# In-place edits mutate the file directly, bypassing redirection/tee/cp coverage; gate the same
# startup/credential files.
(rf'\bsed\s+-[^\s]*i.*(?:{_USER_SENSITIVE_WRITE_TARGET})[^\s"\']*', "in-place edit of sensitive credential/SSH/shell-rc path"),
(rf'\bsed\s+--in-place\b.*(?:{_USER_SENSITIVE_WRITE_TARGET})[^\s"\']*', "in-place edit of sensitive credential/SSH/shell-rc path (long flag)"),
(rf'\b(?:perl|ruby)\b.*(?:^|\s)-[^\s]*i\b.*(?:{_USER_SENSITIVE_WRITE_TARGET})[^\s"\']*', "in-place edit of sensitive credential/SSH/shell-rc path (perl/ruby)"),
(rf'\bsed\s+-[^\s]*i.*\s{_SYSTEM_CONFIG_PATH}', "in-place edit of system config"),
(rf'\bsed\s+--in-place\b.*\s{_SYSTEM_CONFIG_PATH}', "in-place edit of system config (long flag)"),
# sed -i on Hermes config/.env bypasses the redirection/tee rules; pairs the file_tools
# write_file/patch deny so the terminal side is not an open door.
# In-place edit of a Hermes-managed security file (~/.hermes/config.yaml or .env). sed -i bypasses the
# redirection/tee patterns above because it mutates the file directly. See #14639.
(rf'\bsed\s+-[^\s]*i.*(?:{_HERMES_CONFIG_PATH}|{_HERMES_ENV_PATH})', "in-place edit of Hermes config/env"),
(rf'\bsed\s+--in-place\b.*(?:{_HERMES_CONFIG_PATH}|{_HERMES_ENV_PATH})', "in-place edit of Hermes config/env (long flag)"),
# perl/ruby -i: the flag may be its own token after other flags (`-p -i -e`), combined (`-pi`), or carry a backup
# suffix (`-i.bak`), so match any flag token containing `i` anywhere; `perl -e '...'` (no -i) does not trip.
# perl -i and ruby -i perform the same in-place mutation as sed -i but are not caught by the -e/-c
# script-execution pattern above (which targets code evaluation, not file mutation). Pairs the sed -i
# coverage from #14639.
(rf'\b(?:perl|ruby)\b.*(?:^|\s)-[^\s]*i\b.*(?:{_HERMES_CONFIG_PATH}|{_HERMES_ENV_PATH})', "in-place edit of Hermes config/env (perl/ruby)"),
# Interpreter heredocs are handled by _execution_flag_findings(); only shell heredocs stay
# regex-based. `bash <<'EOF'` runs arbitrary commands without triggering the `bash -c` path.
(r'\b(bash|sh|zsh|ksh)\s+<<', "shell execution via heredoc"),
# Git destructive operations. `git reset --hard` accepts any unambiguous long-flag prefix (--h,
# --ha, --har): --hard is the only reset mode starting with "h", and `--help` is special-cased
# by git before mode resolution.
(r'\bgit\s+reset\s+--h(?:a(?:r(?:d)?)?)?\b', "git reset --hard (destroys uncommitted changes)"),
(r'\bgit\s+push\b.*--forc[a-z]*\b', "git force push (rewrites remote history)"),
(r'\bgit\s+push\b.*-f\b', "git force push short flag (rewrites remote history)"),
(r'\bgit\s+clean\s+-[^\s]*f', "git clean with force (deletes untracked files)"),
(r'\bgit\s+branch\s+-D\b', "git branch force delete"),
# `-D` = `-d --force`; the long spellings are different tokens, so match delete+force in either order, bounded to
# one command segment (no `;`/`|`/`&`/newline) so an unrelated later command isn't contaminated.
(r'\bgit\s+branch\b[^;|&\n]*?(?:-d\b|--delete\b)[^;|&\n]*?(?:-f\b|--force\b)', "git branch force delete (long flags)"),
(r'\bgit\s+branch\b[^;|&\n]*?(?:-f\b|--force\b)[^;|&\n]*?(?:-d\b|--delete\b)', "git branch force delete (long flags, force-first)"),
# chmod +x then immediate run: the script content may hold dangerous commands individual patterns miss.
(r'\bchmod\s+\+x\b.*[;&|]+\s*\./', "chmod +x followed by immediate execution"),
# Sudo stdin/askpass/shell/list-privs flags. The agent has no TTY, so sudo invocations that succeed
# non-interactively read the password from stdin (-S) or askpass (-A); -s (shell) and -a (list) are gated as
# privilege chains (read SUDO_PASSWORD from .env -> sudo -S -s). Plain `sudo cmd` is TTY-bound and excluded. Input
# is lowercased, so S/s and A/a collapse. Lazy `[^;|&\n]*?` allows flag args without spanning separators. sudo
# resolves unambiguous long-flag prefixes: `--stdin` is the only long option starting with "st", `--askpass` the
# only one starting with "a".
(r'\bsudo\b[^;|&\n]*?\s+(?:-s\b|--st[a-z]*\b|-a\b|--a[a-z]*\b)', "sudo with privilege flag (stdin/askpass/shell/list)"),
# Combined short-flag form (-nS, -sa, -las).
(r'\bsudo\b[^;|&\n]*?\s+-[a-z]*[sa][a-z]*\b', "sudo with combined-flag privilege escalation"),
# Package-manager uninstall commands can remove installed software outside
# the current project (notably `npm uninstall -g`). Treat their destructive
# subcommands like other state-removing operations while leaving installs
# and updates alone.
# _CMDPOS-anchored (quoted prose like `git commit -m "npm uninstall docs"` is data); the
# option group also swallows one operand (`--prefix DIR`, `--proxy URL`, `--cwd DIR`).
(_CMDPOS + r'npm\s+' + _PKG_OPTS + r'(?:uninstall|unlink|remove|rm|r|un)\b', "package manager uninstall"),
(_CMDPOS + r'pnpm\s+' + _PKG_OPTS + r'(?:uninstall|remove|rm|un)\b', "package manager uninstall"),
(_CMDPOS + r'yarn\s+' + _PKG_OPTS + r'(?:global\s+)?(?:uninstall|remove)\b', "package manager uninstall"),
(_CMDPOS + r'pip(?:3)?\s+' + _PKG_OPTS + r'uninstall\b', "package manager uninstall"),
(_CMDPOS + r'brew\s+' + _PKG_OPTS + r'(?:uninstall|remove|rm)\b', "package manager uninstall"),
]
DANGEROUS_PATTERNS_COMPILED = [(re.compile(p, _RE_FLAGS), d) for p, d in DANGEROUS_PATTERNS]
# Preserve approvals stored under the removed interpreter regex rules.
_REMOVED_PATTERN_KEY_ALIASES = {
"script execution via -e/-c flag": "(python[23]?|perl|ruby|node)\\s+-[ec]\\s+",
"script execution via heredoc": "(python[23]?|perl|ruby|node)\\s+<<",
}
# description <-> legacy regex-derived key (the old approval key, kept for backwards compatibility
# with stored allowlist/session entries), both ways.
_PATTERN_KEY_ALIASES: dict[str, set[str]] = {}
for _canonical_key, _legacy_key in [
(d, p.split(r'\b')[1] if r'\b' in p else p[:20]) for p, d in DANGEROUS_PATTERNS
] + list(_REMOVED_PATTERN_KEY_ALIASES.items()):
_PATTERN_KEY_ALIASES.setdefault(_canonical_key, set()).update({_canonical_key, _legacy_key})
_PATTERN_KEY_ALIASES.setdefault(_legacy_key, set()).update({_legacy_key, _canonical_key})
def _approval_key_aliases(pattern_key: str) -> set[str]:
"""All approval keys for this pattern: the description plus the historical regex-derived key
older allowlist/session entries may still use."""
return _PATTERN_KEY_ALIASES.get(pattern_key, {pattern_key})
# ---- Detection ----------------------------------------------------------------------------
def _normalize_command_for_detection(command: str) -> str:
"""Normalize a command before pattern matching so ANSI escapes, null bytes, Unicode fullwidth
forms, and shell splicing tricks cannot bypass detection."""
from tools.ansi_strip import strip_ansi
command = unicodedata.normalize('NFKC', strip_ansi(command).replace('\x00', ''))
# Collapse backslash-newline continuations (`rm -rf \<newline>/` runs as `rm -rf /`). MUST
# precede the generic escape strip below, whose [^\n] class skips newlines and would leave the
# backslash wedged between tokens, defeating the structured rm/mkfs/dd patterns incl. the HARDLINE floor.
command = re.sub(r'\\\r?\n', '', command)
# Fold absolute user/Hermes home prefixes to ~/ and ~/.hermes/ so the static patterns catch /home/alice/.bashrc
# and C:\Users\alice\.bashrc. Resolved at detection time (not import time) so it tracks HOME/HERMES_HOME set
# later. MUST run before the backslash strip (which would dissolve C:\Users\alice to C:Usersalice). Hermes home
# first: on Windows it nests under the user home, and folding the user home first would eat the prefix it needs.
command = _rewrite_resolved_hermes_home(command)
command = _rewrite_resolved_user_home(command)
# Strip backslash-escapes (r\m -> rm) and empty-string literals (r''m -> rm).
command = re.sub(r'\\([^\n])', r'\1', command)
command = re.sub(r"''|\"\"", '', command)
# Collapse $IFS / ${IFS...} (incl. `${IFS:0:1}`) to a space: IFS defaults to whitespace, so `rm${IFS}-rf${IFS}/`
# runs as `rm -rf /`, and every pattern — incl. the hardline floor — anchors on literal \s between tokens.
return re.sub(r'\$\{IFS\b[^}]*\}|\$IFS\b', ' ', command)
# Shell metacharacters, quotes, and whitespace that terminate a path token.
_PATH_TOKEN_STOP = r"""\s'"`;|&<>()"""
_PATH_TAIL = r"(?P<tail>(?:[/\\][^/\\" + _PATH_TOKEN_STOP + r"]*)+)"
@functools.lru_cache(maxsize=64)
def _home_prefix_fold_regex(path: str):
"""Compile a regex matching *path* as an absolute directory prefix.
Components match with either separator so native Windows, forward-slash, and mixed forms all
fold; the caller normalizes the tail's backslashes to ``/``. A non-empty tail is required, so a
bare home is never folded. Returns ``None`` for an unset/degenerate path (fewer than two
components: ``/``, ``C:\\``, ``""``) so a stray HOME cannot rewrite unrelated prefixes."""
components = [c for c in re.split(r"[/\\]+", path) if c] if path else []
if len(components) < 2:
return None
# Optional leading root separator; a Windows drive letter is a component.
return re.compile(r"[/\\]*" + r"[/\\]+".join(re.escape(c) for c in components) + _PATH_TAIL)
def _fold_home_prefixes(command: str, paths, replacement: str) -> str:
"""Fold each resolved home prefix in *command* to *replacement* (no trailing separator; the tail
supplies it). Longest first so a deeper home folds before a shorter overlapping one that would clobber it."""
for path in dict.fromkeys(sorted((p for p in paths if p), key=len, reverse=True)):
pattern = _home_prefix_fold_regex(path)
if pattern is not None:
command = pattern.sub(lambda m: replacement + m.group("tail").replace("\\", "/"), command)
return command
def _rewrite_resolved_user_home(command: str) -> str:
"""User home (expanduser / realpath / $HOME) -> ``~/``; no-op when unset, degenerate, or unresolvable."""
try:
# expanduser, realpath, and an explicit HOME — Windows expanduser uses USERPROFILE, not HOME.
home = os.path.expanduser("~")
paths = [home, os.path.realpath(home), os.environ.get("HOME", "")]
except Exception:
return command
return _fold_home_prefixes(command, paths, "~")
def _rewrite_resolved_hermes_home(command: str) -> str:
"""Resolved HERMES_HOME (and its realpath) -> ``~/.hermes/`` so the _HERMES_CONFIG_PATH /
_HERMES_ENV_PATH rules match Docker/gateway deployments that spell the absolute path."""
try:
from hermes_constants import get_hermes_home
home = get_hermes_home().expanduser()
paths = [str(home), str(home.resolve(strict=False))]
except Exception:
return command
return _fold_home_prefixes(command, paths, "~/.hermes")
_PARAM_REPLACEMENT_RE = re.compile(r"\$\{[^}/\s]+/[^}/]*/(?P<replacement>[^}]*)\}")
_PARAM_DEFAULT_RE = re.compile(r"\$\{[^}:}\s]+:-(?P<default>[^}]*)\}")
_SIMPLE_SHELL_LITERAL_RE = re.compile(r"^[A-Za-z0-9_./:@%+=,-]+$")
_ENV_ASSIGNMENT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=.*")
_COMMAND_WRAPPER_WORDS = {"sudo", "env", "exec", "nohup", "setsid", "time", "command", "builtin",
"nice", "timeout", "stdbuf", "ionice", "chrt", "taskset", "chroot"}
_SUDO_OPTIONS_WITH_ARG = {"-c", "--close-from", "-g", "--group", "-h", "--host", "-p", "--prompt", "-u", "--user"}
# Adapted from embwl0x's command-position work in #76063. Option operands are
# data, not executable positions; option spelling remains case-sensitive.
_COMMAND_WRAPPER_OPTIONS_WITH_ARG = {
"chroot": {"--groups", "--userspec"},
"sudo": _SUDO_OPTIONS_WITH_ARG,
"env": {"-a", "--argv0", "-C", "--chdir", "-S", "--split-string", "-u", "--unset"},
"exec": {"-a"}, "nice": {"-n", "--adjustment"},
"time": {"-f", "--format", "-o", "--output"},
"timeout": {"-k", "--kill-after", "-s", "--signal"},
"stdbuf": {"-e", "--error", "-i", "--input", "-o", "--output"},
"ionice": {"-c", "--class", "-n", "--classdata"},
}
_COMMAND_WRAPPER_NON_EXECUTING_OPTIONS = {
"command": {"-v", "-V"}, "chrt": {"-p", "--pid"},
"ionice": {"-p", "--pid", "--pgid", "--uid"}, "taskset": {"-p", "--pid"},
}
_COMMAND_WRAPPER_POSITIONAL_ARGS = {"chroot": 1, "chrt": 1, "taskset": 1, "timeout": 1}
_SHELL_COMMAND_TRANSITIONS = {"if", "then", "else", "elif", "do", "while", "until", "!"}
_SHELL_REDIRECTION_RE = re.compile(r"(?:[0-9]+)?(?:>>|<<|<>|>&|<&|>\||[<>])")
_INTERPRETER_NAME_RES = tuple((family, re.compile(pattern)) for family, pattern in (
("python", r"py(?:\.exe)?|python[23]?(?:\.\d+)*(?:\.exe)?"), ("node", r"node(?:js)?(?:\.exe)?"),
("perl", r"perl[0-9]*(?:\.\d+)*(?:\.exe)?"), ("ruby", r"ruby[0-9.]*(?:\.exe)?"), ("php", r"php(?:\.exe)?"),
("powershell", r"powershell(?:\.exe)?|pwsh(?:\.exe)?"),
))
_INTERPRETER_EXEC_FLAGS = {
"python": {"-c"}, "node": {"-e", "--eval", "-p", "--print"}, "perl": {"-e", "--eval"}, "ruby": {"-e"},
"php": {"-r"}, "powershell": {"-command", "-c", "-file", "-f"},
}
_INTERPRETER_WITH_ARG = {
"python": {"-W", "-X", "--check-hash-based-pycs"},
"node": {"-C", "--conditions", "--cpu-prof-dir", "--diagnostic-dir", "--icu-data-dir", "--import", "--loader",
"--openssl-config", "--require", "--title"},
"perl": {"-0", "-F", "-I", "-M", "-m", "-x"}, "ruby": {"-C", "-E", "-F", "-I", "-K", "-r"},
"php": {"-c", "-d", "-z"},
"powershell": {"-configurationname", "-custompipename", "-executionpolicy", "-inputformat", "-outputformat",
"-settingsfile", "-version", "-windowstyle", "-workingdirectory"},
}
_READ_TOOL_EXEC_FLAGS = {
"sort": {"--compress-program"}, "rg": {"--pre", "--hostname-bin"}, "ag": {"--pager"},
"man": {"--pager", "--html", "-P", "-H"},
}
# Required-argument options are ownership boundaries: an option-looking next token is data, not another option. These
# sets mirror the invocation grammar of the supported binaries (ripgrep 14, GNU sort, man-db, and ag 2.2).
_READ_TOOL_LONG_OPTIONS_WITH_ARG = {
"rg": {
"--after-context", "--before-context", "--color", "--colors", "--context", "--context-separator",
"--dfa-size-limit", "--encoding", "--engine", "--field-context-separator", "--field-match-separator",
"--file", "--generate", "--glob", "--hostname-bin", "--hyperlink-format", "--iglob", "--ignore-file",
"--max-columns", "--max-count", "--max-depth", "--max-filesize", "--path-separator", "--pre", "--pre-glob",
"--regex-size-limit", "--regexp", "--replace", "--sort", "--sortr", "--threads", "--type", "--type-add",
"--type-clear", "--type-not",
},
"sort": {
"--batch-size", "--buffer-size", "--compress-program", "--field-separator", "--files0-from", "--key",
"--output", "--parallel", "--random-source", "--sort", "--temporary-directory",
},
"man": {
"--config-file", "--encoding", "--extension", "--locale", "--manpath", "--pager", "--preprocessor",
"--prompt", "--recode", "--sections", "--systems",
},
"ag": {
"--ackmate-dir-filter", "--color-line-number", "--color-match", "--color-path", "--depth",
"--filename-pattern", "--file-search-regex", "--ignore", "--ignore-dir", "--max-count", "--pager",
"--path-to-ignore", "--width", "--workers",
},
}
_READ_TOOL_SHORT_OPTIONS_WITH_ARG = {
"rg": frozenset("efEmjgdtTABCMr"), "sort": frozenset("koStT"), "man": frozenset("CRLmMSserEPp"),
"ag": frozenset("gGmpW"),
}
_GREP_OPTIONS_WITH_ARG = {
"--after-context", "--before-context", "--binary-files", "--context", "--directories", "--devices", "--exclude",
"--exclude-dir", "--exclude-from", "--include", "--label", "--max-count", "--regexp", "--file",
}
_GREP_SHORT_OPTIONS_WITH_ARG = {"A", "B", "C", "D", "d", "e", "f", "m"}
_BASH_OPTIONS_WITH_ARG = {"-O", "+O", "-o", "+o", "--init-file", "--rcfile"}
_BASH_SHORT_OPTION_LETTERS = frozenset("ilrsDcabefhkmnptuvxBCEHPTOo")
_MAX_DETECTION_COMMAND_CHARS, _MAX_SEPARATOR_FREE_COMMAND_CHARS, _MAX_DETECTION_SEGMENTS = 128_000, 4_096, 25_000
_PARSER_LIMIT_DESCRIPTION = "command parser limit exceeded"
_MALFORMED_EXEC_DESCRIPTION = "command parser limit or malformed executable payload"
_GATEWAY_LIFECYCLE_SPLICE_DESCRIPTION = "stop/restart hermes gateway via shell-spliced verb (kills running agents)"
def _command_parser_limit_exceeded(command: str) -> bool:
"""Bound all parser work before normalization/tokenization. Separator counting is deliberately
conservative: quoted separators over-count, but crossing the ceiling fails closed rather than
letting an uninspected suffix execute."""
if len(command) > _MAX_DETECTION_COMMAND_CHARS:
return True
# Long separator-free input has no compound-command utility and makes every regex inspect one giant token.
if len(command) > _MAX_SEPARATOR_FREE_COMMAND_CHARS and not any(char in command for char in ";&|\n"):
return True
return sum(command.count(char) for char in ";&|\n") >= _MAX_DETECTION_SEGMENTS
def _backtick_end_from(segment: str, i: int) -> int | None:
"""Index of the backtick closing the one opened at ``i``, or None when none follows."""
j = segment.find("`", i + 1)
while j != -1 and segment[j - 1] == "\\":
j = segment.find("`", j + 1)
return None if j == -1 else j
def _shell_tokens_with_spans(segment: str, start: int):
"""Return shell words as ``(value, start, end, quoted)`` or ``None`` on malformed quoting.
Deliberately small lexer that never expands shell syntax; it exists to keep source spans (which
``shlex`` does not expose) for deciding which quoted grep operand is data, not another command.
Lexing stops at the end of the simple command that begins at *start*: an unquoted ``;``, ``|``,
``&`` or newline, or the ``)`` / backtick that closes the substitution the command sits inside.
Without that, a grep nested as ``"$(grep … | cut …)"`` was lexed together with the enclosing
command's closing quote, read as unbalanced quoting, and reported as a hardline block (546
blocked turns in one run, every one a false positive; ``sed -n "$(grep -n X f | cut -d: -f1),+3p" f``
is the canonical shape)."""
tokens, value, token_start, quote = [], [], None, None
depth = 0 # $(...) nesting opened AFTER start; a closer at depth 0 ends the enclosing substitution
# A backtick opened AFTER start is an operand substitution (``grep -e `cmd` f``); the matching
# closer belongs to it, not to an enclosing backtick the command might sit inside.
in_backtick = False
def flush(end: int) -> None:
raw = segment[token_start:end]
# Only a wholly single-quoted operand is inert shell data. Double quotes still execute $()
# and backticks; unquoted substitutions do too.
inert = (raw.startswith("'") and raw.endswith("'")) or ("='" in raw and raw.endswith("'"))
tokens.append(("".join(value), token_start, end, inert))
end_at = len(segment)
for kind, i, _, _ in _scan_shell(segment, start):
ch = segment[i]
if kind == "char" and not quote:
if ch.isspace() and ch != "\n":
if token_start is not None:
flush(i)
value, token_start = [], None
continue
if segment.startswith("$(", i):
depth += 1
elif ch == "`":
if in_backtick:
in_backtick = False
elif depth == 0 and _backtick_end_from(segment, i) is None:
end_at = i # unmatched: it closes the substitution this command sits inside
break
else:
in_backtick = True
elif ch == ")":
if depth == 0:
end_at = i
break
depth -= 1
elif ch in ";|&\n":
end_at = i
break
if token_start is None:
token_start = i
if kind == "quote":
quote = None if quote else ch
elif kind == "esc":
value.append(segment[i + 1])
elif ch == "\\" and not quote:
return None # dangling backslash
else:
value.append(ch)
if quote:
return None
if token_start is not None:
flush(end_at)
return tokens
def _quoted_grep_pattern_spans(command: str) -> tuple[list[tuple[int, int]], bool]:
"""Structurally locate quoted grep PCRE operands -> (spans, malformed). On an ambiguous or
malformed grep parse callers fail closed and use the original command: no text is hidden on
an uncertain parse."""
spans: list[tuple[int, int]] = []
offset = 0
for segment in _iter_top_level_shell_segments(command):
segment_at = command.find(segment, offset)
offset = segment_at + len(segment)
for start, _, word in _iter_shell_command_word_spans(segment):
if os.path.basename(_deobfuscate_shell_word_for_detection(word)).lower() not in {"grep", "egrep"}:
continue
tokens = _shell_tokens_with_spans(segment, start)
if tokens is None:
return [], True
args, pattern_indexes = tokens[1:], []
pcre = explicit_patterns = False
operand_index, i, options = None, 0, True
while i < len(args):
token = args[i][0]
if options and token == "--":
options = False
elif options and token.startswith("--"):
option, equals, _ = token.partition("=")
pcre = pcre or option == "--perl-regexp"
explicit_patterns = explicit_patterns or option in {"--regexp", "--file"}
takes_next = option in _GREP_OPTIONS_WITH_ARG and not equals
if takes_next and i + 1 >= len(args):
return [], True
if option == "--regexp":
pattern_indexes.append(i + 1 if takes_next else i)
i += 1 if takes_next else 0
elif options and token.startswith("-") and token != "-":
chars = token[1:]
for j, char in enumerate(chars):
pcre = pcre or char == "P"
explicit_patterns = explicit_patterns or char in {"e", "f"}
if char in _GREP_SHORT_OPTIONS_WITH_ARG:
# The first argument-taking short option owns the rest of the bundle,
# or the next token when it comes last.
attached = j + 1 < len(chars)
if not attached and i + 1 >= len(args):
return [], True
if char == "e":
pattern_indexes.append(i if attached else i + 1)
i += 0 if attached else 1
break
elif operand_index is None:
operand_index = i
i += 1
if not explicit_patterns:
if operand_index is None:
return [], pcre
pattern_indexes.append(operand_index)
if pcre:
spans.extend(
(segment_at + token_start, segment_at + token_end)
for _, token_start, token_end, quoted in map(args.__getitem__, pattern_indexes) if quoted
)
return spans, False
def _splice(command: str, edits) -> str:
"""Apply sorted, non-overlapping ``(start, end, text)`` edits to *command* in one pass
(re-slicing per edit is quadratic on 10k+ segments)."""
parts, previous = [], 0
for start, end, text in edits:
parts.extend((command[previous:start], text))
previous = end
return "".join(parts) + command[previous:]
def _grep_safe_detection_variant(command: str) -> tuple[str, bool]:
spans, malformed = _quoted_grep_pattern_spans(command)
if malformed or not spans:
return command, malformed
return _splice(command, [(start, end, " " * (end - start)) for start, end in spans]), False
def _interpreter_family(executable: str) -> str | None:
name = os.path.basename(executable).lower()
return next((family for family, name_re in _INTERPRETER_NAME_RES if name_re.fullmatch(name)), None)
def _shell_segment_tokens(segment: str, start: int) -> list[str] | None:
"""Tokenize an already-bounded command segment. ``None`` distinguishes malformed quoting from
an empty segment so callers can fail closed for a program-bearing option rather than silently
skip it."""
try:
lexer = shlex.shlex(segment[start:], posix=True, punctuation_chars="<>")
lexer.whitespace_split, lexer.commenters = True, ""
return list(lexer)
except ValueError:
return None
def _iter_top_level_shell_segments(command: str):
"""Yield top-level command segments in one left-to-right pass."""
start = 0
for kind, i, j, quote in _scan_shell(command, comments=True):
if kind == "comment" or (kind == "char" and quote is None and command[i] in ";&|\n"):
if start < i:
yield command[start:i]
start = j
if start < len(command):
yield command[start:]
def _interpreter_exec_flag(family: str, args: list[str]) -> str | None:
"""Return an execution-bearing interpreter option, if present."""
flags, with_arg = _INTERPRETER_EXEC_FLAGS[family], _INTERPRETER_WITH_ARG[family]
powershell = family == "powershell"
skip_value = False
for token in args:
if skip_value:
skip_value = False
continue
if token == "--" or (not powershell and not token.startswith("-")):
break
option, equals, _ = token.partition("=")
comparable = option.lower() if powershell else option
if comparable in flags:
return comparable
# `-Wonce` and `ruby -rjson` attach an option value; they are not short-option bundles containing an execution
# flag. PowerShell's normal long options also use one dash, so bundle parsing never applies to that family.
has_attached_option_value = any(
option.startswith(short) and len(option) > len(short)
for short in with_arg if short.startswith("-") and not short.startswith("--")
)
if not powershell and not option.startswith("--") and len(option) > 2 and not has_attached_option_value:
bundled = next((f"-{char}" for char in option[1:] if f"-{char}" in flags), None)
if bundled:
return bundled
skip_value = comparable in with_arg and not equals
return None
def _bash_exec_payload(args: list[str]) -> tuple[bool, str | None]:
"""Return whether Bash ``-c`` occurs and the command string it owns.
Bash's O/o options consume the following argument even when they precede a later ``-c`` or
share its short-option bundle; the two startup-file long options own their next token.
Parsing those first prevents both missed payloads and false ``-c`` hits."""
index = 0
while index < len(args):
token = args[index]
if token == "--" or not token.startswith(("-", "+")):
break
if token in _BASH_OPTIONS_WITH_ARG:
index += 2
continue
chars = token[1:]
# Bash option letters are case-sensitive; restricting to the documented alphabet
# preserves invalid controls such as `-Wc`.
if token.startswith("--") or not set(chars) <= _BASH_SHORT_OPTION_LETTERS:
index += 1
continue
consumed_option_arg = int("O" in chars or "o" in chars)
if "c" in chars:
payload_index = index + 1 + consumed_option_arg
return True, (args[payload_index] if payload_index < len(args) else None)
index += 1 + consumed_option_arg
return False, None
def _read_tool_exec_flag(tool: str, args: list[str]) -> tuple[str, str] | None:
"""Return (option, program) for a read-only tool's program-running flag."""
flags = _READ_TOOL_EXEC_FLAGS[tool]
index = 0
while index < len(args):
token = args[index]
if token == "--":
break
option, equals, payload = token.partition("=")
payload = payload if equals else None
matched = option if option in flags else None
if tool == "man" and token.startswith(("-P", "-H")) and len(token) > 2:
matched, payload = token[:2], token[2:]
if matched:
if payload is None and index + 1 < len(args):
payload = args[index + 1]
# The option owns its program argument regardless of spelling; the real binaries
# execute a '-'-prefixed payload rather than reparsing it.
if payload:
return matched, payload
index += 2 if payload is not None and "=" not in token else 1
elif option in _READ_TOOL_LONG_OPTIONS_WITH_ARG[tool] and payload is None:
index += 2
elif token.startswith("-") and not token.startswith("--") and len(token) > 1:
# In a short bundle, the first argument-taking option owns the rest of the token, or
# the following token when it occurs last.
shorts = _READ_TOOL_SHORT_OPTIONS_WITH_ARG[tool]
owner = next((k for k, char in enumerate(token[1:], start=1) if char in shorts), None)
index += 2 if owner == len(token) - 1 else 1
else:
index += 1
return None
def _execution_flag_findings(command: str):
"""Yield scoped execution mechanisms and any executable payloads."""
for segment in _iter_top_level_shell_segments(command):
for start, _, word in _iter_shell_command_word_spans(segment):
executable = _deobfuscate_shell_word_for_detection(word)
tokens = _shell_segment_tokens(segment, start)
executable_name = os.path.basename(executable).lower()
family = _interpreter_family(executable)
if tokens is None:
if family is not None or executable_name in _READ_TOOL_EXEC_FLAGS:
yield (_MALFORMED_EXEC_DESCRIPTION, None)
continue
if not tokens:
continue
args = tokens[1:]
if family and _interpreter_exec_flag(family, args):
yield ("script execution via -e/-c flag", None)
elif family and any(token.startswith("<<") for token in args):
yield ("script execution via heredoc", None)
else:
if executable_name in {"bash", "sh", "zsh", "ksh"}:
found, payload = _bash_exec_payload(args)
if found:
yield ("shell command via -c/-lc flag", payload)
if executable_name in _READ_TOOL_EXEC_FLAGS:
finding = _read_tool_exec_flag(executable_name, args)
if finding:
yield (f"arbitrary program execution via {executable_name} {finding[0]}", finding[1])
def _skip_shell_whitespace(command: str, pos: int) -> int:
while pos < len(command) and command[pos].isspace():
pos += 1
return pos
def _scan_shell(text: str, start: int = 0, end: int | None = None, *, subst: str = "",
brace: bool = False, stop_unterminated: bool = False, naive_backtick: bool = False,
comments: bool = False):
"""Yield ``(kind, i, j, quote)`` lexical steps over ``text[start:end]`` without expanding.
The single quote/escape state machine behind every detection scanner. ``kind`` is ``"char"``
(one char), ``"esc"`` (backslash + the char it escapes; never inside single quotes), ``"quote"``
(an opening/closing quote char) or ``"subst"`` (a ``$(...)`` / backtick / ``${...}`` span);
With *comments*, ``"comment"`` spans are skipped without interpreting their quote syntax.
``quote`` is the state the step was read in (``None``, ``'`` or ``"``). Substitutions are
recognized unquoted when ``"u"`` is in *subst*, inside double quotes when ``"q"`` is; *brace*
adds unquoted ``${...}``. An unterminated substitution falls through as plain chars unless
*stop_unterminated*, which yields ``("subst", i, None, quote)`` and ends the scan (the caller
descends to *end* itself). *naive_backtick* closes a backtick at the next backtick even if
escaped (the quoted-prose masker's historical behavior)."""
n = len(text) if end is None else end
quote: str | None = None
i = start
while i < n:
ch = text[i]
kind, j = "char", i + 1
if comments and quote is None and _is_shell_comment_start(text, i):
kind, j = "comment", text.find("\n", i, n)
if j < 0:
j = n
elif quote != "'" and ch == "\\" and i + 1 < n:
kind, j = "esc", i + 2
elif ch == quote or (quote is None and ch in "'\""):
kind = "quote"
elif quote != "'" and ("q" if quote else "u") in subst and (
ch == "`" or text.startswith("$(", i) or (brace and not quote and text.startswith("${", i))
):
if ch == "`":
close = text.find("`", i + 1) + 1 or None if naive_backtick else _scan_backtick_end(text, i)
elif text[i + 1] == "(":
close = _scan_dollar_paren_end(text, i)
else:
close = text.find("}", i + 2) + 1 or None
if close is not None:
kind, j = "subst", close
elif stop_unterminated:
yield ("subst", i, None, quote)
return
yield (kind, i, j, quote)
if kind == "quote":
quote = None if quote else ch
i = j
def _scan_dollar_paren_end(command: str, start: int) -> int | None:
"""Return the offset after a balanced ``$(...)`` command substitution."""
depth = 1
for kind, i, _, quote in _scan_shell(command, start + 2):
if kind == "char" and not quote:
depth += command.startswith("$(", i) - (command[i] == ")")
if depth == 0:
return i + 1
return None
def _scan_backtick_end(command: str, start: int) -> int | None:
# Backticks have no quote awareness: only a backslash escapes the next char.
match = re.compile(r"(?:\\.|[^`\\])*`", re.DOTALL).match(command, start + 1)
return match.end() if match else None
def _read_shell_word(command: str, pos: int) -> tuple[int, int, str]:
"""Read one shell word without executing expansions."""
start = end = _skip_shell_whitespace(command, pos)
for kind, i, j, quote in _scan_shell(command, start, subst="u", brace=True):
if kind == "char" and quote is None and (command[i].isspace() or command[i] in ";&|<>()"):
break
end = j
return (start, end, command[start:end])
def _literal_command_substitution_output(script: str) -> str | None:
"""Resolve tiny literal command substitutions without executing a shell."""
try:
tokens = shlex.split(script, posix=True)
except ValueError:
tokens = []
if not tokens:
return None
command, args = tokens[0].lower(), tokens[1:]
if command == "echo":
while args and re.fullmatch(r"-[nEe]+", args[0]):
args = args[1:]
elif command != "printf":
return None
if len(args) == 1 and _SIMPLE_SHELL_LITERAL_RE.fullmatch(args[0]):
return args[0]
if command == "printf" and len(args) == 2 and args[0] == "%s" and _SIMPLE_SHELL_LITERAL_RE.fullmatch(args[1]):
return args[1]
return None
def _replace_simple_command_substitutions(word: str) -> str:
chars: list[str] = []
i = 0
while i < len(word):
opener = 2 if word.startswith("$(", i) else 1 if word[i] == "`" else 0
end = (_scan_dollar_paren_end if opener == 2 else _scan_backtick_end)(word, i) if opener else None
replacement = _literal_command_substitution_output(word[i + opener:end - 1]) if end is not None else None
if replacement is None:
replacement, end = word[i], i + 1
chars.append(replacement)
i = end
return "".join(chars)
def _replace_simple_shell_expansions(word: str) -> str:
word = _replace_simple_command_substitutions(word)
word = _PARAM_REPLACEMENT_RE.sub(lambda match: match.group("replacement"), word)
return _PARAM_DEFAULT_RE.sub(lambda match: match.group("default"), word)
def _strip_shell_word_syntax(word: str) -> str:
return "".join(
word[i + 1] if kind == "esc" else word[i]
for kind, i, _, _ in _scan_shell(word) if kind != "quote"
)
def _deobfuscate_shell_word_for_detection(word: str) -> str:
"""Approximate how shell syntax can spell a command word: collapses quoting/escaping plus
simple literal command substitutions in the word itself. Intentionally narrow and non-executing."""
for _ in range(2):
previous = word
word = _strip_shell_word_syntax(_replace_simple_shell_expansions(word))
if word == previous:
break
return word
def _is_shell_comment_start(command: str, index: int) -> bool:
return command[index] == "#" and (index == 0 or command[index - 1].isspace()
or command[index - 1] in ";&|()<>")
def _iter_shell_command_starts(command: str):
starts = [0]
def scan(start: int, end: int) -> None:
skip = -1
for kind, i, j, quote in _scan_shell(command, start, end, subst="uq", stop_unterminated=True,
comments=True):
if kind == "subst":
# Record a nested $(...)/backtick command start and scan its body.
inner = i + (1 if command[i] == "`" else 2)
starts.append(inner)
scan(inner, end if j is None else j - 1)
elif kind == "char" and quote is None and i != skip:
# `${` opens a parameter expansion, not a brace group: a start marked inside it would
# split `${IFS}` and defeat the IFS collapse in normalization.
if command[i] in "({;\n" and not (command[i] == "{" and i > 0 and command[i - 1] == "$"):
starts.append(i + 1)
elif command[i] in "&|":
repeated = i + 1 < end and command[i + 1] == command[i]
skip = i + 1 if repeated else skip
starts.append(i + 1 + repeated)
scan(0, len(command))
seen = set()
for start in starts:
start = _skip_shell_whitespace(command, start)
if start >= len(command) or start in seen or _is_shell_comment_start(command, start):
continue
seen.add(start)
yield start
_, end, word = _read_shell_word(command, start)
if word in _SHELL_COMMAND_TRANSITIONS:
starts.append(end)
def _mark_command_starts(command: str, marker: str = "\n") -> str:
"""Insert *marker* (a newline) before each real (quote-aware) command start.
``\\n`` is already a ``_CMDPOS`` separator, so this exposes subshell ``(cmd)`` and brace-group
``{ cmd; }`` openers — which the flat pattern class omits — to the anchored patterns WITHOUT the
quoted-prose false positives that adding ``(`` / ``{`` to ``_CMDPOS`` would cause: starts inside
quotes are never produced, so ``--title "block (reboot)"`` is left as-is."""
offsets = sorted(o for o in _iter_shell_command_starts(command) if o > 0)
return _splice(command, [(o, o, marker) for o in offsets]) if offsets else command
def _mask_quoted_newlines(command: str) -> str:
"""Replace raw newlines inside single/double quotes with a space (detection-only).
A quoted newline is DATA to the shell, yet the flat ``_CMDPOS`` class treats every raw ``\\n``
as a command start, so multi-line quoted arguments (commit messages, heredoc text) tripped the
hardline blocklist when a data line began with e.g. ``sudo reboot``. Quote tracking mirrors
``_iter_shell_command_starts``. Unquoted newlines pass through and ``_mark_command_starts``
still re-inserts newlines at genuine starts; an unclosed quote absorbs following newlines
exactly as the shell would, so masking them cannot hide a runnable command."""
if "\n" not in command:
return command
return _mask_quoted_newlines_span(command, 0, len(command))
def _mask_quoted_newlines_span(command: str, start: int, end: int) -> str:
"""``_mask_quoted_newlines`` over ``command[start:end]``. A ``$(...)`` / backtick substitution
inside double quotes is EXECUTABLE, not data: its body is re-scanned with a fresh quote state so a
newline that separates commands inside it survives as a command boundary. Masking it as quoted
data turned ``"$(grep x f\nreboot)"`` into ``... f reboot)``, which no later stage can tell from an
operand; the pre-fix scanner only caught it by refusing the whole command as malformed."""
out: list[str] = []
for kind, i, j, quote in _scan_shell(command, start, end, subst="q"):
if kind == "subst":
# j is the index just past the closer; keep the opener and closer, recurse into the body.
body_start = i + (2 if command.startswith("$(", i) else 1)
body_end = j - 1
out.append(command[i:body_start])
out.append(_mask_quoted_newlines_span(command, body_start, body_end))
out.append(command[body_end:j])
elif quote and kind == "char" and command[i] == "\n":
out.append(" ")
else:
out.append(command[i:j])
return "".join(out)
def _iter_shell_command_word_spans(command: str):
"""Yield command-position words that may be executable names."""
for pos in _iter_shell_command_starts(command):
wrapper, positionals = None, 0
options, skip_arg = True, False
while pos < len(command):
redirect = _SHELL_REDIRECTION_RE.match(command, _skip_shell_whitespace(command, pos))
if redirect:
_, pos, _ = _read_shell_word(command, redirect.end())
continue
word_start, word_end, word = _read_shell_word(command, pos)
if word_start == word_end:
break
pos = word_end
deobfuscated = _deobfuscate_shell_word_for_detection(word)
name = os.path.basename(deobfuscated).lower()
if skip_arg:
skip_arg = False
continue
if wrapper and options and deobfuscated == "--":
options = False
continue
if wrapper and options and deobfuscated.startswith("-"):
option = deobfuscated.split("=", 1)[0]
if wrapper == "env" and (option == "--split-string" or deobfuscated.startswith("-S")):
# The split string and remaining argv form ONE command, handled
# by _env_split_payload; the suffix is not a new executable.
break
queries = _COMMAND_WRAPPER_NON_EXECUTING_OPTIONS.get(wrapper, set())
if option in queries or (wrapper == "command" and not option.startswith("--")
and set(option[1:]) & {"v", "V"}):
break
skip_arg = "=" not in deobfuscated and option in _COMMAND_WRAPPER_OPTIONS_WITH_ARG.get(wrapper, set())
continue
if positionals:
positionals -= 1
continue
if _ENV_ASSIGNMENT_RE.fullmatch(word):
continue
yield (word_start, word_end, word)
if name not in _COMMAND_WRAPPER_WORDS:
break
wrapper, options = name, True
positionals = _COMMAND_WRAPPER_POSITIONAL_ARGS.get(name, 0)
def _shell_command_segment(command: str, start: int) -> str:
"""Bound a candidate to its command, preserving quoted argument bytes."""
end = len(command)
for kind, i, _, quote in _scan_shell(command, start, subst="uq", brace=True, comments=True):
if kind == "comment" or (kind == "char" and quote is None and command[i] in ";&|\n)`"):
end = i
break
return command[start:end].strip()
def _split_env_string(payload: str) -> list[str] | None:
r"""Project GNU env -S literal argv, not POSIX shell words.
Dynamic ${NAME} expansion is deliberately not evaluated: the execution
backend's environment need not be this process's environment.
"""
escapes = {"f": "\f", "n": "\n", "r": "\r", "t": "\t", "v": "\v",
"#": "#", "$": "$", "\"": "\"", "'": "'", "\\": "\\"}
args, word = [], []
quote, started, index = None, False, 0
while index < len(payload):
char = payload[index]
index += 1
if char == "\\":
if index == len(payload):
return None
escaped = payload[index]
if quote == "'" and escaped not in ("'", "\\"):
word.append(char)
started = True
continue
index += 1
if escaped == "c":
if quote:
return None
break
if escaped == "_" and quote is None:
if started:
args.append("".join(word))
word, started = [], False
continue
if escaped not in escapes and escaped != "_":
return None
word.append(" " if escaped == "_" else escapes[escaped])
started = True
continue
if char in ("'", '"') and (quote is None or char == quote):
quote = char if quote is None else None
started = True
continue
if quote is None and char in " \t\n\r\v\f":
if started:
args.append("".join(word))
word, started = [], False
continue
if quote is None and char == "#" and not started:
break
if char == "$" and quote != "'":
return None
word.append(char)
started = True
if quote:
return None
if started:
args.append("".join(word))
return args
def _env_split_payload(tokens: list[str]) -> str | None:
index = 1
while index < len(tokens):
token = tokens[index]
if token == "--" or not token.startswith("-"):
return None
option, equals, value = token.partition("=")
if option == "--split-string" or token.startswith("-S"):
attached = equals if option == "--split-string" else len(token) > 2
if not attached:
index += 1
payload = (value if option == "--split-string" else token[2:]) if attached else (
tokens[index] if index < len(tokens) else "")
args = _split_env_string(payload)
# Protect literal separators when reusing command-position detection;
# only a real shell -c carrier may turn these argv bytes into code.
return shlex.join(args + tokens[index + 1:]) if args is not None else None
index += 2 if not equals and option in _COMMAND_WRAPPER_OPTIONS_WITH_ARG["env"] else 1
return None
def _deny_command_variants(command: str):
"""Add executable projections without reparsing normalized argument data.
Whole-input matching is retained for existing globs. New projections parse
the original quote state, preserve path-specific rules, and fold only the
executable basename (never arbitrary argument paths).
"""
yield from _command_detection_variants(command)
pending, seen = [command], set()
while pending:
source = pending.pop()
if source in seen:
continue
seen.add(source)
for start, end, word in _iter_shell_command_word_spans(source):
segment = _shell_command_segment(source, start)
executable = _deobfuscate_shell_word_for_detection(word)
tail = segment[end - start:]
# Collapse only unquoted inter-word whitespace; quoted prose is data.
parts = []
for kind, i, j, quote in _scan_shell(tail):
if kind == "char" and quote is None and tail[i].isspace():
if not parts or parts[-1] != " ":
parts.append(" ")
else:
parts.append(tail[i:j])
tail = "".join(parts)
for name in dict.fromkeys((executable, os.path.basename(executable))):
candidate = name + tail
yield candidate
# Apply the existing text matching semantics only AFTER locating
# executable positions; never parse its rewritten quotes again.
yield _normalize_command_for_detection(candidate)
if os.path.basename(executable) == "env":
tokens = _shell_segment_tokens(segment, 0)
if tokens:
payload = _env_split_payload(tokens)
if payload:
pending.append(payload)
for _, payload in _execution_flag_findings(source):
if payload:
pending.append(payload)
def _command_detection_variants(command: str):
# Mask quoted newlines BEFORE normalization: normalization strips escapes (\" -> ") and ""
# pairs, corrupting quote tracking (`echo "a\""` becomes an unterminated quote) so masking
# afterwards could swallow a REAL unquoted newline separator. The raw command carries faithful quote state.
normalized = _normalize_command_for_detection(_mask_quoted_newlines(command))
# Quote-aware grep parsing hides only structurally identified pattern operands; malformed or
# ambiguous input stays byte-for-byte intact.
grep_safe, _ = _grep_safe_detection_variant(normalized)
seen = {grep_safe}
yield grep_safe
def fresh(variant: str) -> bool:
if not variant or variant in seen:
return False
seen.add(variant)
return True
# Windows-path variant: normalization strips backslashes as shell escapes, so `del C:\Users\me\.ssh\id_rsa`
# reaches the patterns as `del C:Usersme.sshid_rsa`. When the RAW command has a drive-letter or UNC backslash
# path, also yield a variant with backslashes flattened to `/` BEFORE normalization. Gated on a real path shape so
# POSIX escape semantics (`echo a\"b`) are untouched elsewhere.
# See #69472.
if re.search(r"(?:[A-Za-z]:|\\\\)[\\\\]", command) or re.search(r"[A-Za-z]:\\", command):
win_variant = _normalize_command_for_detection(_mask_quoted_newlines(command.replace("\\", "/")))
if fresh(win_variant):
yield win_variant
# Program-bearing options are parsed in their owning command's context; surfacing only the payload lets the
# hardline floor inspect what will actually run without promoting similar flags or quoted prose.
pending = [normalized]
while pending:
for _, payload in _execution_flag_findings(pending.pop()):
if fresh(payload):
yield payload
# A payload may start with an option-looking program and then invoke a hardline command
# after a separator; mark its starts.
marked_payload = _mark_command_starts(payload)
if marked_payload != payload and fresh(marked_payload):
yield marked_payload
pending.append(payload)
# Subshell `(cmd)` / brace-group `{ cmd; }` openers put `cmd` at a real command position the flat `_CMDPOS`
# patterns can't see (adding `(`/`{` there would match quoted prose like `--title "(reboot)"`). Insert a newline
# at each start the QUOTE-AWARE tokenizer found instead; this covers every `_CMDPOS` rule in one place.
marked = _mark_command_starts(grep_safe)
if marked != grep_safe and fresh(marked):
yield marked
# Every variant above tracks quotes on NORMALIZED text, where `\"` has already become `"`. That
# flips quote parity, so in `cat "f\"n.txt"; rm -rf /` the `; rm` start sat "inside" a phantom
# quote, no start was marked, and the hardline floor let it through. Mark starts on the RAW
# command (only quoted newlines masked), then normalize; the leading space keeps the marker
# from being eaten as a `\<newline>` continuation when the preceding text ends in a backslash.
faithful = _normalize_command_for_detection(_mark_command_starts(_mask_quoted_newlines(command), marker=" \n"))
if fresh(faithful):
yield faithful
# Quoting/escaping can spell an executable in pieces (r\m, r''m). Keep that deobfuscation scoped
# to command words so arguments don't false-positive.
for word_start, word_end, word in _iter_shell_command_word_spans(normalized):
deobfuscated = _deobfuscate_shell_word_for_detection(word)
if deobfuscated and deobfuscated != word:
variant = normalized[:word_start] + deobfuscated + normalized[word_end:]
if fresh(variant):
yield variant
def _is_verification_artifact_cleanup(command: str) -> bool:
"""Return whether *command* only removes one Hermes ad-hoc temp script."""
try:
argv = shlex.split(command, posix=True)
except ValueError:
return False
if len(argv) != 3 or argv[0] != "rm" or argv[1] != "-f":
return False
operand = argv[2]
temp_dir = os.path.realpath(tempfile.gettempdir())
basename = os.path.basename(operand)
return (
operand == os.path.join(temp_dir, basename)
and os.path.dirname(os.path.realpath(operand)) == temp_dir
and re.fullmatch(r"hermes-(?:verify|ad-hoc)-[A-Za-z0-9_.-]+", basename) is not None
)
def _is_shell_token_spliced_gateway_lifecycle(command: str) -> bool:
"""Catch gateway-lifecycle verbs spelled with quote splicing.
Backslash splicing (``kick\\start``) is undone by normalization, but quote splicing is not:
``_deobfuscate_shell_word_for_detection`` is deliberately scoped to command-position words
(widening it would let quoted prose like ``git commit -m "rm -rf /"`` match), and the spliced
verb is an ARGUMENT, so ``launchctl kick"start" -k gui/501/ai.hermes.gateway`` auto-approved.
Delegates to ``cron.lifecycle_guard`` (shlex-tokenized, anchored on a hermes-gateway
identifier). Runs last so an ordinary pattern match keeps its more specific reason; this layer
only prompts — the non-bypassable block still lives in ``cron.lifecycle_guard``.
``_normalize_command_for_detection`` strips backslash escapes, so ``kick\\start`` already reaches the
launchctl pattern above. See #80269.
"""
try:
from cron.lifecycle_guard import contains_gateway_lifecycle_command
except Exception:
return False
return contains_gateway_lifecycle_command(command)
def detect_dangerous_command(command: str) -> tuple:
"""Check dangerous patterns -> (is_dangerous, pattern_key, description)."""
if _command_parser_limit_exceeded(command):
return (True, _PARSER_LIMIT_DESCRIPTION, _PARSER_LIMIT_DESCRIPTION)
if _is_verification_artifact_cleanup(command):
return (False, None, None)
for command_variant in _command_detection_variants(command):
command_lower = command_variant.lower()
for pattern_re, description in DANGEROUS_PATTERNS_COMPILED:
if pattern_re.search(command_lower):
return (True, description, description)
normalized = _normalize_command_for_detection(command)
for description, _ in _execution_flag_findings(normalized):
return (True, description, description)
if _is_shell_token_spliced_gateway_lifecycle(command):
return (True, _GATEWAY_LIFECYCLE_SPLICE_DESCRIPTION, _GATEWAY_LIFECYCLE_SPLICE_DESCRIPTION)
return (False, None, None)