From 640b619496c32eb724dd6f36a7aea6b275e9e823 Mon Sep 17 00:00:00 2001 From: D41910 <158550716+D41910@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:15:25 +0800 Subject: [PATCH] fix(security): normalize program path in the destructive-command screen screen_command classified the command name as tokens[0].lower(), so the same program was caught bare and missed when written with its path. The module already owns the canonical normalizer for this - _program() strips the directory and any Windows extension - and screen_egress and screen_install both use it. Resolve the program through it here too, so all three screens agree on what a program is called. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/harness/command_guard.py | 2 +- tests/test_command_guard.py | 47 +++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/core/harness/command_guard.py b/core/harness/command_guard.py index 60dd5422a..d5111b741 100644 --- a/core/harness/command_guard.py +++ b/core/harness/command_guard.py @@ -155,7 +155,7 @@ def screen_command(command: str) -> str | None: continue if not tokens: continue - reason = _classify(tokens[0].lower(), tokens[1:]) + reason = _classify(_program(tokens[0]), tokens[1:]) if reason is not None: return reason diff --git a/tests/test_command_guard.py b/tests/test_command_guard.py index 363f3c5c5..3a381db8a 100644 --- a/tests/test_command_guard.py +++ b/tests/test_command_guard.py @@ -83,3 +83,50 @@ def test_unparseable_command_defers_to_sandbox(): # Unbalanced quotes can't be tokenised; the screen must not raise and must # pass the command through (None) so the sandbox stays the boundary. assert screen_command('echo "unterminated') is None + + +@pytest.mark.parametrize( + "command", + [ + "/bin/rm -rf /", + "/usr/bin/rm -rf /tmp/x", + "/bin/rm -r -f /tmp/x", # split flags behind a path + "./rm --recursive --force /tmp/x", + "/usr/bin/sudo reboot", + "/bin/dd if=/dev/zero of=/dev/sda", + "/sbin/mkfs.ext4 /dev/sda1", + "/bin/chmod 777 /etc", + "/usr/bin/chmod a+rwx /etc", + "C:/Windows/System32/shutdown.exe /s", # Windows extension stripping + "/usr/bin/rm.exe -rf C:/temp", + "echo hi && /bin/rm -rf /", # path-qualified stage in a sequence + "ls | /usr/bin/rm -rf /", # path-qualified stage in a pipeline + ], +) +def test_blocks_path_qualified_destructive_commands(command): + """An absolute or relative path to the program must not defeat the screen. + + ``screen_egress`` / ``screen_install`` resolve the program through + ``_program()``, which drops the directory and any Windows extension before + matching. ``screen_command`` compared ``tokens[0].lower()`` directly, so the + very same command was classified when typed bare and passed when typed with + its path — ``/bin/rm -rf /`` slipped past the check that ``rm -rf /`` + trips. + """ + + assert screen_command(command) is not None + + +@pytest.mark.parametrize( + "command", + [ + "/bin/ls -la", # path-qualified but benign + "/usr/bin/rm notes.txt", # plain remove stays allowed + "/usr/local/bin/python train.py", + "/bin/echo done && /bin/ls -la", # benign path-qualified sequence + ], +) +def test_allows_path_qualified_benign_commands(command): + """Normalising the program must not turn every absolute path into a hit.""" + + assert screen_command(command) is None