mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(approvals): detect exec-via-flag escapes on read-only commands (port kilocode#11890)
Flags on otherwise read-only commands that execute an arbitrary program
(sort --compress-program, rg --pre/--hostname-bin, ag --pager,
man -P/--pager/--html) were invisible to approval detection: the flag
value is opaque argument text, so 'sort --compress-program=sh f' ran
without a prompt and a hardline payload smuggled through the flag
('sort --compress-program="rm -rf /" f') bypassed the unconditional
floor entirely.
Two layers, mirroring Kilo-Org/kilocode#11890:
- New DANGEROUS_PATTERNS entries flag the mechanism itself, so the
command requires approval even when the payload is a script whose
contents we cannot see.
- _exec_flag_payloads() surfaces each flag's program value as its own
detection variant in _command_detection_variants(), so hardline
payloads anchor at command position and hit the floor.
E2E: 11 attack shapes detected, 5 hardline payloads reach the floor,
11 legit commands (rg --pretty, grep -P, pip install --pre, man -k
pager) unflagged. 554 approval-suite tests green.
This commit is contained in:
parent
830165473e
commit
2e2a0dfe8b
2 changed files with 169 additions and 0 deletions
100
tests/tools/test_exec_via_flag_guard.py
Normal file
100
tests/tools/test_exec_via_flag_guard.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""Exec-via-flag escape detection (port of Kilo-Org/kilocode#11890).
|
||||
|
||||
Otherwise read-only commands can execute an arbitrary program through a
|
||||
flag: ``sort --compress-program``, ``rg --pre`` / ``--hostname-bin``,
|
||||
``ag --pager``, ``man -P`` / ``--pager`` / ``-H`` / ``--html``. Two layers
|
||||
are under test:
|
||||
|
||||
1. The mechanism itself is flagged dangerous (approval required) even when
|
||||
the payload is opaque (``--pre=sh``).
|
||||
2. A hardline payload smuggled through the flag value reaches the
|
||||
unconditional floor via ``_exec_flag_payloads`` detection variants.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.approval import (
|
||||
_exec_flag_payloads,
|
||||
detect_dangerous_command,
|
||||
detect_hardline_command,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", [
|
||||
"sort --compress-program='rm -rf ~' names.txt",
|
||||
'sort -S 1b --compress-program "sh" names.txt',
|
||||
"sort --compress-program=sh names.txt",
|
||||
"rg --pre sh -e . names.txt",
|
||||
"rg --pre=sh -e . names.txt",
|
||||
"rg --hostname-bin=sh pattern",
|
||||
"ag --pager sh foo",
|
||||
"ag --pager=sh foo",
|
||||
"man -P sh ls",
|
||||
"man -Psh ls",
|
||||
"man --pager=sh ls",
|
||||
"man --html=sh ls",
|
||||
])
|
||||
def test_exec_via_flag_is_dangerous(command):
|
||||
is_dangerous, _key, desc = detect_dangerous_command(command)
|
||||
assert is_dangerous, f"exec-via-flag escape not detected: {command}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", [
|
||||
# A hardline payload inside the flag value must reach the unconditional
|
||||
# floor, not just the softer dangerous prompt.
|
||||
"sort --compress-program='rm -rf --no-preserve-root /' names.txt",
|
||||
'sort --compress-program "rm -rf --no-preserve-root /" names.txt',
|
||||
"rg --pre 'rm -rf --no-preserve-root /' -e . x",
|
||||
"man -P 'rm -rf --no-preserve-root /' ls",
|
||||
# man -H (browser) is invisible to the lowercased dangerous-pattern
|
||||
# matcher (would collide with `man -h` help), but the payload extractor
|
||||
# runs on the original-case command, so a dangerous payload still
|
||||
# reaches the floor.
|
||||
"man -H 'rm -rf --no-preserve-root /' ls",
|
||||
"ag --pager 'rm -rf --no-preserve-root /' foo",
|
||||
])
|
||||
def test_hardline_payload_in_exec_flag_hits_floor(command):
|
||||
is_hardline, desc = detect_hardline_command(command)
|
||||
assert is_hardline, f"hardline payload escaped the floor: {command}"
|
||||
assert desc == "recursive delete of root filesystem"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", [
|
||||
"sort names.txt",
|
||||
"sort -k2 -n data.csv -o out.csv",
|
||||
"rg 'compress-program' docs/",
|
||||
"rg -n pattern src/",
|
||||
"rg --pretty pattern src/", # --pre must not match --pretty
|
||||
"ag foo src/",
|
||||
"man ls",
|
||||
"man -k pager", # -k arg happens to be the word "pager"
|
||||
"grep -P 'foo(?=bar)' file.txt", # -P is perl-regex on grep, not a pager
|
||||
"pip install --pre somepackage", # --pre without rg context
|
||||
"git log --oneline",
|
||||
])
|
||||
def test_legit_commands_not_flagged(command):
|
||||
is_hardline, _ = detect_hardline_command(command)
|
||||
is_dangerous, _key, desc = detect_dangerous_command(command)
|
||||
assert not is_hardline, f"legit command hardlined: {command}"
|
||||
assert not (is_dangerous and "via" in (desc or "")), (
|
||||
f"legit command flagged as exec-via-flag: {command} ({desc})"
|
||||
)
|
||||
|
||||
|
||||
def test_exec_flag_payload_extraction():
|
||||
payloads = list(_exec_flag_payloads(
|
||||
"sort --compress-program='rm -rf /' a.txt"))
|
||||
assert payloads == ["rm -rf /"]
|
||||
|
||||
payloads = list(_exec_flag_payloads("man -Psh ls"))
|
||||
assert payloads == ["sh"]
|
||||
|
||||
payloads = list(_exec_flag_payloads("rg --pre=sh -e . x"))
|
||||
assert payloads == ["sh"]
|
||||
|
||||
# Long flag with no rg/man/sort context still extracts, but a benign
|
||||
# payload matches no dangerous pattern (defense-in-depth only).
|
||||
assert list(_exec_flag_payloads("sort data.txt")) == []
|
||||
|
||||
# A flag value that is itself another flag is not a payload.
|
||||
assert list(_exec_flag_payloads("man --pager --html ls")) == []
|
||||
|
|
@ -612,6 +612,28 @@ DANGEROUS_PATTERNS = [
|
|||
(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"),
|
||||
# Exec-via-flag escapes on otherwise read-only commands (port of
|
||||
# Kilo-Org/kilocode#11890). These flags make an innocuous-looking
|
||||
# command execute an ARBITRARY PROGRAM: sort spawns its compressor for
|
||||
# temp runs, rg runs a preprocessor per file, ag/man spawn a pager or
|
||||
# browser. Without these rules the flag value is opaque argument text,
|
||||
# so `sort --compress-program='rm -rf ~' f` looked like a plain sort.
|
||||
# The payload is additionally surfaced to the hardline floor via
|
||||
# _exec_flag_payloads() in _command_detection_variants below — these
|
||||
# entries make the *mechanism itself* require approval even when the
|
||||
# payload points at a script whose contents we cannot see.
|
||||
(r'\bsort\b[^\n;|&]*--compress-program', "arbitrary program execution via sort --compress-program"),
|
||||
(r'\brg\b[^\n;|&]*--pre[=\s]', "arbitrary program execution via rg --pre"),
|
||||
(r'\brg\b[^\n;|&]*--hostname-bin', "arbitrary program execution via rg --hostname-bin"),
|
||||
(r'\bag\b[^\n;|&]*--pager', "arbitrary program execution via ag --pager"),
|
||||
# man -P/--pager runs an arbitrary pager program; -H/--html runs an
|
||||
# arbitrary browser. Detection input is lowercased, so `-p` here also
|
||||
# covers the rare legit `man -p` (preprocessor list) — an acceptable
|
||||
# approval-prompt false positive. `(?!-)` keeps `--help`/`--html` long
|
||||
# flags out of the short-flag branch.
|
||||
(r'\bman\b[^\n;|&]*\s-(?!-)\w*p', "arbitrary program execution via man pager flag"),
|
||||
(r'\bman\b[^\n;|&]*\s--pager', "arbitrary program execution via man pager flag"),
|
||||
(r'\bman\b[^\n;|&]*\s--html', "arbitrary program execution via man --html"),
|
||||
# find -exec rm / -execdir rm — the -execdir variant (same semantics,
|
||||
# runs in the directory of each match) was previously missed. Claude
|
||||
# Code 2.1.113 tightened their equivalent find rule to stop auto-
|
||||
|
|
@ -1349,10 +1371,57 @@ def _iter_shell_command_word_spans(command: str):
|
|||
break
|
||||
|
||||
|
||||
_EXEC_FLAG_PAYLOAD_RE = re.compile(
|
||||
r"""(?:--compress-program|--pre|--hostname-bin|--pager|--html)
|
||||
(?:=|\s+) # =value or separate word
|
||||
(?P<payload>'[^']*'|"[^"]*"|\S+)""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
# man -P/-H short flags (possibly bundled, e.g. ``man -Psh ls``). Scoped to a
|
||||
# ``man`` command context: ``-P`` is a common flag elsewhere (``grep -P`` is a
|
||||
# perl-regex switch) and extracting its argument there would false-positive on
|
||||
# legitimate searches for dangerous-looking strings.
|
||||
_MAN_EXEC_FLAG_PAYLOAD_RE = re.compile(
|
||||
r"""\bman\b[^\n;|&]*?
|
||||
\s-\w*[PH]
|
||||
(?:=|\s*) # bundled (-Psh), separate word, or =
|
||||
(?P<payload>'[^']*'|"[^"]*"|\S+)""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
|
||||
def _exec_flag_payloads(command: str):
|
||||
"""Yield the program values passed to exec-via-flag options.
|
||||
|
||||
Flags like ``sort --compress-program``, ``rg --pre``, ``ag --pager`` and
|
||||
``man -P`` make an otherwise read-only command execute an arbitrary
|
||||
program. The flag value is normally opaque argument text, so a hardline
|
||||
payload smuggled through it (``sort --compress-program='rm -rf /' f``)
|
||||
never reached the unconditional floor. Surfacing each payload as its own
|
||||
detection variant lets the hardline/dangerous patterns match the wrapped
|
||||
command directly. Port of Kilo-Org/kilocode#11890.
|
||||
"""
|
||||
for regex in (_EXEC_FLAG_PAYLOAD_RE, _MAN_EXEC_FLAG_PAYLOAD_RE):
|
||||
for match in regex.finditer(command):
|
||||
payload = _strip_optional_shell_quotes(match.group("payload"))
|
||||
payload = payload.strip()
|
||||
if payload and not payload.startswith("-"):
|
||||
yield payload
|
||||
|
||||
|
||||
def _command_detection_variants(command: str):
|
||||
normalized = _normalize_command_for_detection(command)
|
||||
seen = {normalized}
|
||||
yield normalized
|
||||
# Exec-via-flag payloads: expose the program argument of flags that make
|
||||
# a read-only command execute an arbitrary program, so a hardline command
|
||||
# hidden inside (e.g.) --compress-program='rm -rf /' anchors at command
|
||||
# position and hits the unconditional floor.
|
||||
for payload in _exec_flag_payloads(normalized):
|
||||
if payload not in seen:
|
||||
seen.add(payload)
|
||||
yield payload
|
||||
# Subshell `(cmd)` and brace-group `{ cmd; }` openers put `cmd` at a real
|
||||
# command position, but the flat `_CMDPOS`-anchored patterns can't see it:
|
||||
# their start-position class deliberately omits `(`/`{` because a bare
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue