fix(security): граница workspace держится одинаково на Windows и Linux

Оба красных Windows-джоба CI падали по причинам, воспроизведённым локально.

1. Инвариант A37 не держался на Windows. "rm -rf $HOME/.hermes" проходил
   мимо защиты: переменной HOME в окружении Windows нет, expandvars оставлял
   "$HOME" как есть, путь переставал быть абсолютным, склеивался с каталогом
   проекта и оказывался "внутри разрешённого корня". Зеркальная дыра на
   Linux: "%USERPROFILE%\.hermes" и "C:\Windows" проходили так же.

   Разбор пути сведён в один конвейер: классификация диалекта shell по самой
   команде (а не по системе-хозяину) -> раскрытие распознанных переменных, с
   разрешением HOME/USERPROFILE в домашний каталог даже когда их нет в
   окружении -> нормализация разделителей -> канонизация -> сравнение с
   защищёнными корнями. Каждый несостоявшийся шаг закрывает проход:
   непроверяемый путь не считается разрешённым. Через тот же конвейер
   пропущены validate_path, is_forbidden_path и is_inside_allowed_root.

2. UnicodeEncodeError ронял verify_multi_provider_router.py на cp1252-консоли
   Windows-раннера — падал вывод, не логика. Общий помощник
   console_encoding.force_utf8_output ставит UTF-8 на потоки и оставляет
   запасной путь, если перекодировать поток нельзя. Той же реализацией
   заменён самодельный блок в cli_commands.

Проверено: скрипт проходит 10/10 под PYTHONIOENCODING=cp1252 и ascii.
Новые тесты воспроизводят окружение обеих систем на любой из них и падают
на прежнем guard ровно на дефекте из CI (6 failed), проходят на новом.

Тесты: 739 -> 755 passed, 2 skipped, 4 deselected. ruff check . чисто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ochenstarik-ui 2026-09-03 01:02:40 +07:00
parent 89435eadb5
commit 7136ab2878
5 changed files with 284 additions and 22 deletions

View file

@ -18,6 +18,13 @@ for p in [
if p.is_dir() and str(p) not in sys.path: if p.is_dir() and str(p) not in sys.path:
sys.path.insert(0, str(p)) sys.path.insert(0, str(p))
from antigravity_provider.console_encoding import force_utf8_output
# Отчёт печатается по-русски, а консоль Windows-раннера в CI — cp1252: без этого
# первый же [PASS] с кириллицей роняет скрипт UnicodeEncodeError'ом ещё до того,
# как проверки что-либо покажут. Ставится до первого вывода.
force_utf8_output()
from antigravity_provider.router.router_config import ( from antigravity_provider.router.router_config import (
RolePolicy, RolePolicy,
RouterConfig, RouterConfig,

View file

@ -0,0 +1,48 @@
"""Принудительный UTF-8 для потоков вывода.
Инструменты Hermes печатают по-русски, а консоль Windows-раннера в CI работает
в cp1252. Первый же `print` с кириллицей роняет процесс UnicodeEncodeError'ом —
падает вывод, не логика. Измерено на `scripts/verify_multi_provider_router.py`:
строка `[PASS] Чистая конфигурация...` обрывала прогон с кодом 1.
Модуль ставит UTF-8 на stdout/stderr и оставляет запасной путь на случай, когда
перекодировать поток нельзя: тогда непечатаемые символы заменяются, но процесс
продолжает работу. Вывод инструмента не должен быть причиной падения.
"""
from __future__ import annotations
import sys
from typing import Any, Iterable
__all__ = ["force_utf8_output"]
def _reconfigure(stream: Any) -> bool:
"""Перевести один поток на UTF-8. True, если получилось."""
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is None:
return False
for errors in ("strict", "backslashreplace"):
try:
reconfigure(encoding="utf-8", errors=errors)
return True
except Exception:
continue
# Поток не перекодировать (подменён, закрыт, не текстовый). Тогда хотя бы
# снимем строгость с текущей кодировки, чтобы кириллица не роняла процесс.
try:
reconfigure(errors="backslashreplace")
return True
except Exception:
return False
def force_utf8_output(streams: Iterable[str] = ("stdout", "stderr")) -> None:
"""Перевести стандартные потоки на UTF-8; молча пропустить недоступные.
Вызывается один раз на старте точки входа, до первого вывода.
"""
for name in streams:
stream = getattr(sys, name, None)
if stream is not None:
_reconfigure(stream)

View file

@ -9,17 +9,16 @@ import time
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from antigravity_provider.console_encoding import force_utf8_output
from antigravity_provider.router.router_config import RouterConfig, RouterProfileConfig, load_router_config from antigravity_provider.router.router_config import RouterConfig, RouterProfileConfig, load_router_config
from antigravity_provider.router.health_tracker import HealthTracker from antigravity_provider.router.health_tracker import HealthTracker
from antigravity_provider.router.router_engine import RouterEngine, get_router_engine from antigravity_provider.router.router_engine import RouterEngine, get_router_engine
from antigravity_provider.router.adapters import get_adapter from antigravity_provider.router.adapters import get_adapter
from antigravity_provider.router.profile_manager import ProfileAuthManager, mask_email, mask_id from antigravity_provider.router.profile_manager import ProfileAuthManager, mask_email, mask_id
if hasattr(sys.stdout, "reconfigure"): # Вывод CLI — по-русски, консоль Windows по умолчанию не UTF-8. Общий помощник,
try: # тот же, что и у скриптов проверки: одна реализация, одно поведение.
sys.stdout.reconfigure(encoding="utf-8") force_utf8_output()
except Exception:
pass
def print_router_status() -> int: def print_router_status() -> int:

View file

@ -135,6 +135,117 @@ DESTRUCTIVE_COMMAND_NAMES: Set[str] = {
} }
# ── Единый конвейер разбора пути ────────────────────────────────
#
# Граница вокруг агентских shell-действий обязана работать одинаково на всех
# поддерживаемых системах, иначе доказанной она не является ни на одной.
# Измерено: "rm -rf $HOME/.hermes" отклонялось на Linux и проходило на Windows,
# потому что переменной HOME в окружении Windows нет — os.path.expandvars
# оставлял "$HOME" как есть, путь переставал быть абсолютным, склеивался с
# каталогом проекта и оказывался "внутри разрешённого корня". Зеркальная дыра
# на Linux: "%USERPROFILE%\.hermes" и "C:\Windows" тоже проходили.
#
# Порядок шагов: классификация диалекта → раскрытие распознанных переменных →
# нормализация разделителей → канонизация → сравнение с корнями. Каждый шаг,
# который не удался, закрывает проход: непроверяемый путь не считается
# разрешённым.
_HOME_VARIABLE_NAMES = frozenset({"HOME", "USERPROFILE"})
_VARIABLE_REFERENCE = re.compile(
r"\$\{(?P<brace>[A-Za-z_][A-Za-z0-9_]*)\}"
r"|\$(?P<bare>[A-Za-z_][A-Za-z0-9_]*)"
r"|%(?P<percent>[A-Za-z_][A-Za-z0-9_]*)%"
)
_WINDOWS_DRIVE_PREFIX = re.compile(r"^[A-Za-z]:[\\/]")
# Признаки записи в диалекте cmd/PowerShell: %VAR%, буква диска, разделитель "\".
_WINDOWS_DIALECT_MARKERS = re.compile(r"%[A-Za-z_][A-Za-z0-9_]*%|[A-Za-z]:[\\/]|\\[A-Za-z0-9_.]")
def looks_like_windows_dialect(cmd_line: str) -> bool:
"""Записана ли команда в диалекте Windows.
Диалект определяется по самой строке, а не по системе-хозяину: команду в
записи cmd могут прислать и на Linux, и разобрать её posix-правилами нельзя
shlex съест "\" как экранирование и разделитель пути исчезнет.
"""
return bool(_WINDOWS_DIALECT_MARKERS.search(cmd_line))
def expand_path_argument(raw: str) -> Tuple[str, Optional[str]]:
"""Раскрыть "~" и переменные окружения обоих диалектов.
Возвращает (раскрытая строка, причина нераскрытия | None). Имена HOME и
USERPROFILE разрешаются в домашний каталог даже тогда, когда их нет в
окружении: команда, написанная в чужом диалекте, целит ровно туда же.
Всё, что раскрыть не удалось, возвращается причиной вызывающий обязан
закрыться, а не гадать.
"""
unresolved: List[str] = []
def _substitute(match: "re.Match[str]") -> str:
name = match.group("brace") or match.group("bare") or match.group("percent")
value = os.environ.get(name)
if value is None and name.upper() in _HOME_VARIABLE_NAMES:
try:
value = str(Path.home())
except Exception:
value = None
if value is None:
unresolved.append(name)
return match.group(0)
return value
expanded = _VARIABLE_REFERENCE.sub(_substitute, raw)
expanded = os.path.expanduser(expanded)
if expanded.startswith("~"):
# expanduser не смог определить домашний каталог: оставлять "~" внутри
# пути нельзя — он перестанет быть абсолютным и уедет внутрь проекта.
unresolved.append("~")
if unresolved:
return expanded, "не раскрыты: " + ", ".join(sorted(set(unresolved)))
return expanded, None
def canonical_path(raw: Path | str, base_cwd: Optional[Path | str] = None) -> Tuple[Optional[Path], Optional[str]]:
"""Привести аргумент к каноническому пути или объяснить отказ.
Ровно одно из двух возвращаемых значений не None. Отказ это отказ в
доступе: путь, который нельзя достоверно разрешить, нельзя и признать
находящимся внутри разрешённого корня.
"""
text = str(raw)
if not text:
return None, "пустой путь"
expanded, unresolved_reason = expand_path_argument(text)
if unresolved_reason:
return None, f"путь '{raw}' невозможно раскрыть ({unresolved_reason})"
normalized = expanded.replace("\\", "/")
if _WINDOWS_DRIVE_PREFIX.match(normalized) and not Path(normalized).is_absolute():
# Путь с буквой диска на не-Windows: канонизировать его нечем. resolve()
# припишет ему текущий каталог, и "C:/Windows" окажется внутри проекта.
return None, f"путь '{raw}' записан в диалекте другой системы и здесь не проверяем"
candidate = Path(normalized)
if not candidate.is_absolute() and not normalized.startswith("/"):
try:
base = Path(base_cwd) if base_cwd is not None else paths.get_repo_root()
except Exception as exc:
return None, f"не определён базовый каталог для '{raw}': {exc}"
candidate = base / normalized
try:
return candidate.resolve(), None
except Exception as exc:
return None, f"путь '{raw}' не разрешается: {exc}"
class WorkspaceBoundaryGuard: class WorkspaceBoundaryGuard:
"""Enforces explicit workspace boundaries, defends credential directories, and inspects destructive operations.""" """Enforces explicit workspace boundaries, defends credential directories, and inspects destructive operations."""
@ -206,7 +317,11 @@ class WorkspaceBoundaryGuard:
def is_inside_allowed_root(self, path: Path | str) -> bool: def is_inside_allowed_root(self, path: Path | str) -> bool:
"""Check whether the given path resolves within any allowed root.""" """Check whether the given path resolves within any allowed root."""
try: try:
target = Path(path).expanduser().resolve() target, resolve_error = canonical_path(path)
if target is None:
# Путь не разрешается — считать его находящимся внутри
# разрешённого корня нельзя.
return False
for root in self.get_allowed_roots(): for root in self.get_allowed_roots():
try: try:
target.relative_to(root) target.relative_to(root)
@ -220,7 +335,9 @@ class WorkspaceBoundaryGuard:
def is_forbidden_path(self, path: Path | str) -> Tuple[bool, Optional[str]]: def is_forbidden_path(self, path: Path | str) -> Tuple[bool, Optional[str]]:
"""Check whether the path touches an unconditionally protected directory or file.""" """Check whether the path touches an unconditionally protected directory or file."""
try: try:
target = Path(path).expanduser().resolve() target, resolve_error = canonical_path(path)
if target is None:
return True, resolve_error
# 1. Exact match or child of forbidden directory # 1. Exact match or child of forbidden directory
for fpath in self.get_forbidden_paths(): for fpath in self.get_forbidden_paths():
if target == fpath: if target == fpath:
@ -252,10 +369,9 @@ class WorkspaceBoundaryGuard:
Returns: (is_allowed: bool, reason: str, safe_alternative: Optional[str]) Returns: (is_allowed: bool, reason: str, safe_alternative: Optional[str])
""" """
try: target, resolve_error = canonical_path(path)
target = Path(path).expanduser().resolve() if target is None:
except Exception as exc: return False, f"Недопустимый путь: {resolve_error}", "Укажите путь внутри проекта явно, без нераскрытых переменных"
return False, f"Недопустимый путь '{path}': {exc}", "Используйте стандартный относительный путь"
# Check unconditional forbidden paths for mutating/deleting operations # Check unconditional forbidden paths for mutating/deleting operations
if operation in {"delete", "write", "truncate", "move"}: if operation in {"delete", "write", "truncate", "move"}:
@ -290,12 +406,18 @@ class WorkspaceBoundaryGuard:
# Parse command tokens # Parse command tokens
if isinstance(cmd_line, list): if isinstance(cmd_line, list):
tokens = list(cmd_line) tokens = list(cmd_line)
windows_dialect = any(looks_like_windows_dialect(str(t)) for t in tokens)
else: else:
# Диалект берётся из самой команды: строку в записи cmd нельзя
# разбирать posix-правилами — shlex съест "\\" как экранирование,
# и разделитель пути исчезнет ещё до проверки.
windows_dialect = looks_like_windows_dialect(cmd_line)
try: try:
# Windows and POSIX-compatible shlex split tokens = shlex.split(cmd_line, posix=not windows_dialect)
tokens = shlex.split(cmd_line, posix=(os.name != "nt"))
except Exception: except Exception:
tokens = cmd_line.split() tokens = cmd_line.split()
if windows_dialect:
tokens = [t[1:-1] if len(t) > 1 and t[0] == t[-1] and t[0] in "\"'" else t for t in tokens]
if not tokens: if not tokens:
return True, "OK", None return True, "OK", None
@ -310,7 +432,7 @@ class WorkspaceBoundaryGuard:
# Extract target arguments (skip flags starting with - or /) # Extract target arguments (skip flags starting with - or /)
targets = [] targets = []
for arg in tokens[1:]: for arg in tokens[1:]:
if arg.startswith("-") or (os.name == "nt" and arg.startswith("/") and len(arg) == 2): if arg.startswith("-") or (windows_dialect and arg.startswith("/") and len(arg) == 2):
continue continue
targets.append(arg) targets.append(arg)
@ -321,17 +443,23 @@ class WorkspaceBoundaryGuard:
return False, f"Команда {cmd_name} запущена в недопустимом каталоге: {reason}", alt return False, f"Команда {cmd_name} запущена в недопустимом каталоге: {reason}", alt
else: else:
for target_arg in targets: for target_arg in targets:
# Тильда и переменные окружения раскрываются ДО проверки. # Тильда и переменные окружения раскрываются ДО проверки, и
# одинаково для обоих диалектов.
# #
# Без этого "rm -rf ~/.hermes/agy_profiles" не считался # Без этого "rm -rf ~/.hermes/agy_profiles" не считался
# абсолютным путём, склеивался с каталогом проекта в путь с # абсолютным путём, склеивался с каталогом проекта в путь с
# буквальным "~" внутри и признавался допустимым. Проверено: # буквальным "~" внутри и признавался допустимым; а
# команда с тильдой проходила, та же команда с абсолютным # "rm -rf $HOME/.hermes" ровно так же проходил на Windows,
# путём отклонялась. То есть самый естественный способ # где переменной HOME в окружении нет. Самый естественный
# написать опасную команду обходил защиту ровно там, ради # способ написать опасную команду обходил защиту ровно там,
# чего она и делалась — на каталоге учётных данных. # ради чего она и делалась — на каталоге учётных данных.
expanded = os.path.expandvars(os.path.expanduser(target_arg)) target_path, resolve_error = canonical_path(target_arg, base_cwd=base_cwd)
target_path = Path(expanded) if Path(expanded).is_absolute() else (base_cwd / expanded) if target_path is None:
return (
False,
f"Команда '{cmd_name}' обращается к непроверяемому пути: {resolve_error}",
"Укажите путь внутри проекта явно, без нераскрытых переменных",
)
ok, reason, alt = self.validate_path(target_path, operation="delete") ok, reason, alt = self.validate_path(target_path, operation="delete")
if not ok: if not ok:
return False, f"Команда '{cmd_name}' пытается удалить недопустимый путь '{target_arg}': {reason}", alt return False, f"Команда '{cmd_name}' пытается удалить недопустимый путь '{target_arg}': {reason}", alt

View file

@ -396,3 +396,83 @@ def test_destructive_command_with_tilde_is_rejected(monkeypatch, tmp_path):
# Обычная работа внутри проекта не должна страдать. # Обычная работа внутри проекта не должна страдать.
allowed, _reason, _alt = guard.validate_command("rm src/temp_file.py") allowed, _reason, _alt = guard.validate_command("rm src/temp_file.py")
assert allowed, "защита мешает штатной работе внутри проекта" assert allowed, "защита мешает штатной работе внутри проекта"
# ── HUB-1: граница обязана держаться одинаково на Windows и Linux ──
#
# Инвариант A37 падал только на Windows-раннере: "rm -rf $HOME/.hermes"
# проходил мимо защиты, потому что переменной HOME в окружении Windows нет.
# Прогон на одной системе этого не показывал. Тесты ниже воспроизводят
# окружение обеих систем на любой из них, поэтому дыра больше не может
# спрятаться за тем, где именно запущен CI.
_DESTRUCTIVE_BOTH_DIALECTS = [
"rm -rf ~/.hermes/agy_profiles",
"rm -rf ~/.ssh",
"rm -rf $HOME/.hermes",
"rm -rf ${HOME}/.hermes",
r"rm -rf %USERPROFILE%\.hermes",
r"del /f /q %USERPROFILE%\.ssh",
]
@pytest.fixture
def guard_env(monkeypatch, tmp_path):
"""Guard с HERMES_HOME во временном каталоге — вне домашнего и вне проекта."""
from antigravity_provider.router.security_guard import WorkspaceBoundaryGuard
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
return WorkspaceBoundaryGuard()
@pytest.mark.parametrize("simulated_os", ["linux", "windows"])
@pytest.mark.parametrize("cmd", _DESTRUCTIVE_BOTH_DIALECTS)
def test_home_directed_destruction_rejected_on_both_systems(monkeypatch, guard_env, simulated_os, cmd):
"""Удаление по домашнему каталогу отклоняется в обоих окружениях.
Окружение Windows отличается от Linux ровно тем, из-за чего защита и
расходилась: HOME не задан, домашний каталог известен через USERPROFILE.
"""
if simulated_os == "windows":
monkeypatch.delenv("HOME", raising=False)
monkeypatch.setenv("USERPROFILE", str(Path.home()))
else:
monkeypatch.setenv("HOME", str(Path.home()))
monkeypatch.delenv("USERPROFILE", raising=False)
allowed, reason, _alt = guard_env.validate_command(cmd)
assert not allowed, f"[{simulated_os}] команда прошла мимо защиты: {cmd} ({reason})"
@pytest.mark.parametrize("simulated_os", ["linux", "windows"])
def test_guard_fails_closed_on_unresolvable_argument(monkeypatch, guard_env, simulated_os):
"""Нераскрытая переменная — отказ, а не пропуск.
Раньше "$HOME/.hermes" с неизвестной переменной переставал быть абсолютным
путём, склеивался с каталогом проекта и признавался допустимым. Путь,
который нельзя разрешить, нельзя и признать безопасным.
"""
if simulated_os == "windows":
monkeypatch.delenv("HOME", raising=False)
monkeypatch.delenv("HERMES_UNSET_TARGET", raising=False)
for cmd in ["rm -rf $HERMES_UNSET_TARGET/data", r"rm -rf %HERMES_UNSET_TARGET%\data"]:
allowed, reason, _alt = guard_env.validate_command(cmd)
assert not allowed, f"[{simulated_os}] guard не закрылся на нераскрытом пути: {cmd} ({reason})"
def test_foreign_dialect_absolute_path_is_not_treated_as_project_local(guard_env):
"""Путь с буквой диска на Linux не должен считаться внутренним.
Path("C:/Windows").resolve() на Linux приписывает пути текущий каталог, и
удаление системного каталога Windows выглядело как работа внутри проекта.
"""
allowed, reason, _alt = guard_env.validate_command(r"del /f /q C:\Windows\System32")
assert not allowed, f"путь чужого диалекта признан внутренним: {reason}"
def test_normal_work_inside_project_still_allowed(guard_env):
"""Ужесточение не должно мешать штатной работе."""
for cmd in ["rm src/temp_file.py", "rm -rf build/", "rm ./tests/tmp.log"]:
allowed, reason, _alt = guard_env.validate_command(cmd)
assert allowed, f"защита мешает штатной работе: {cmd} ({reason})"