hermes-hub/tests/test_a37_isolation_guards.py
ochenstarik-ui 7136ab2878 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>
2026-09-03 19:01:30 +07:00

478 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Test suite for Task A37: Agent Isolation, Credential Protection, Workspace Boundaries, and Forensic Audit."""
from __future__ import annotations
import os
from pathlib import Path
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from antigravity_provider.router.security_guard import (
WorkspaceBoundaryGuard,
NetworkBoundaryGuard,
AgentWorkspaceGuard,
BoundaryViolationError,
NetworkBoundaryViolationError,
scrub_secrets,
scrub_string,
get_workspace_guard,
)
from antigravity_provider.router.unified_health import EventLogService
from antigravity_provider.router.action_handler import ActionExecutor, do_delete_credentials
from antigravity_provider.router.web.server import app
# ═══════════════════════════════════════════════════════════════
# P0-1: Workspace Boundary & Destructive Operations Guard Tests
# ═══════════════════════════════════════════════════════════════
class TestWorkspaceBoundaryGuard:
"""Tests for workspace boundaries, traversal prevention, and credential defense."""
def test_deletion_outside_allowed_workspace_is_blocked(self, tmp_path):
guard = WorkspaceBoundaryGuard(additional_allowed_roots=[tmp_path / "allowed_workspace"])
(tmp_path / "allowed_workspace").mkdir(parents=True, exist_ok=True)
outside_file = tmp_path / "outside_workspace" / "sensitive_file.txt"
outside_file.parent.mkdir(parents=True, exist_ok=True)
outside_file.write_text("critical data", encoding="utf-8")
ok, reason, safe_alt = guard.validate_path(outside_file, operation="delete")
assert ok is False
assert "за пределами разрешённых рабочих областей" in reason
assert safe_alt is not None
with pytest.raises(BoundaryViolationError):
guard.safe_delete_file(outside_file)
def test_unconditional_protection_of_credential_pools_and_ssh(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
guard = WorkspaceBoundaryGuard(additional_allowed_roots=[hermes_home])
# 1. agy_profiles
agy_profile = hermes_home / "agy_profiles" / "ag-w1" / "auth.json"
agy_profile.parent.mkdir(parents=True, exist_ok=True)
agy_profile.write_text("token", encoding="utf-8")
ok, reason, alt = guard.validate_path(agy_profile, operation="delete")
assert ok is False
assert "защищённого каталога учётных данных" in reason or "защищённым" in reason
assert "delete_credentials" in alt
# 2. hub_settings.json
settings_p = hermes_home / "hub_settings.json"
settings_p.write_text("{}", encoding="utf-8")
ok, reason, _ = guard.validate_path(settings_p, operation="delete")
assert ok is False
# 3. .ssh
fake_ssh = Path.home() / ".ssh" / "id_rsa"
ok, reason, _ = guard.validate_path(fake_ssh, operation="delete")
assert ok is False
def test_path_traversal_and_symlink_bypass_prevention(self, tmp_path):
ws_dir = tmp_path / "workspace"
ws_dir.mkdir(parents=True, exist_ok=True)
secret_outside = tmp_path / "secret.txt"
secret_outside.write_text("secret", encoding="utf-8")
guard = WorkspaceBoundaryGuard(additional_allowed_roots=[ws_dir])
# Relative path traversal attack: workspace/../../secret.txt
traversal_path = ws_dir / "subdir" / ".." / ".." / "secret.txt"
ok, reason, _ = guard.validate_path(traversal_path, operation="delete")
assert ok is False
assert "за пределами" in reason
# Symlink attack: symlink inside workspace pointing outside
link_inside = ws_dir / "link_to_outside"
try:
link_inside.symlink_to(secret_outside)
ok_symlink, reason_symlink, _ = guard.validate_path(link_inside, operation="delete")
assert ok_symlink is False
assert "за пределами" in reason_symlink
except OSError:
# On platforms without symlink privileges, skip symlink creation
pass
def test_shell_command_analysis_and_classification(self, tmp_path):
guard = WorkspaceBoundaryGuard(additional_allowed_roots=[tmp_path / "project"])
project_dir = tmp_path / "project"
project_dir.mkdir(parents=True, exist_ok=True)
# 1. Dangerous rm -rf / or outside
ok, reason, _ = guard.validate_command("rm -rf /etc/passwd", cwd=project_dir)
assert ok is False
# 2. Windows del C:\Windows
ok, reason, _ = guard.validate_command("del /f /q C:\\Windows\\System32", cwd=project_dir)
assert ok is False
# 3. Safe rm within project
ok, reason, _ = guard.validate_command("rm temp_file.log", cwd=project_dir)
assert ok is True
def test_dry_run_deletion_reports_metadata_without_deleting(self, tmp_path):
guard = WorkspaceBoundaryGuard(additional_allowed_roots=[tmp_path])
test_dir = tmp_path / "to_delete"
test_dir.mkdir(parents=True, exist_ok=True)
file1 = test_dir / "file1.txt"
file2 = test_dir / "file2.txt"
file1.write_text("hello 1", encoding="utf-8")
file2.write_text("hello 222", encoding="utf-8")
res = guard.dry_run_deletion([test_dir])
assert res["dry_run"] is True
assert res["allowed"] is True
assert res["total_dirs"] >= 1
assert res["total_files"] == 0 # directory item
assert res["total_bytes"] == len("hello 1".encode()) + len("hello 222".encode())
assert file1.exists()
assert file2.exists()
# ═══════════════════════════════════════════════════════════════
# P0-2: Agent Isolation & Credential Separation Tests
# ═══════════════════════════════════════════════════════════════
class TestAgentIsolation:
"""Tests for dedicated agent workspaces, role-scoped credentials, and actor attribution."""
def test_dedicated_agent_workspace_creation(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
ws_dev1 = AgentWorkspaceGuard.get_agent_workspace_dir("developer-1")
ws_rev = AgentWorkspaceGuard.get_agent_workspace_dir("code-reviewer")
assert ws_dev1.is_dir()
assert ws_rev.is_dir()
assert ws_dev1 != ws_rev
assert "agent-developer-1" in str(ws_dev1)
assert "agent-code-reviewer" in str(ws_rev)
def test_role_scoped_credential_isolation(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
env_dev1 = AgentWorkspaceGuard.build_agent_subprocess_env(
agent_id="developer-1",
role="coder",
assigned_profile_id="ag-w1",
)
assert env_dev1["HERMES_AGENT_ID"] == "developer-1"
assert env_dev1["HERMES_AGENT_ROLE"] == "coder"
assert "ag-w1" in env_dev1["HOME"]
assert "ag-w1" in env_dev1["USERPROFILE"]
# Ensure no cross-agent foreign API keys leak
assert "ANTHROPIC_API_KEY" not in env_dev1
assert "OPENAI_API_KEY" not in env_dev1
def test_actor_attribution_in_action_executor(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
EventLogService._instance = None
event_svc = EventLogService.get()
res = ActionExecutor.execute(
action="dry_run_delete",
data={"paths": [str(tmp_path)]},
actor="agent:developer-1",
)
assert res["ok"] is True
events = event_svc.get_events(limit=5)
actor_events = [e for e in events if e.actor == "agent:developer-1"]
assert len(actor_events) > 0
assert actor_events[0].action == "dry_run_delete"
assert actor_events[0].outcome == "dry_run"
# ═══════════════════════════════════════════════════════════════
# P0-3: Network Boundary Guard & CORS Tests
# ═══════════════════════════════════════════════════════════════
class TestNetworkBoundaryAndCORS:
"""Tests for outbound destination whitelist, strict CORS, and network HTTP disclosures."""
def test_outbound_destination_whitelist(self):
# Allowed AI providers & releases
assert NetworkBoundaryGuard.is_host_allowed("https://api.anthropic.com/v1/messages") is True
assert NetworkBoundaryGuard.is_host_allowed("https://generativelanguage.googleapis.com") is True
assert NetworkBoundaryGuard.is_host_allowed("https://api.github.com/repos/releases") is True
assert NetworkBoundaryGuard.is_host_allowed("http://127.0.0.1:11434/api/tags") is True
assert NetworkBoundaryGuard.is_host_allowed("http://localhost:8000/v1/models") is True
# Blocked external / rogue hosts
assert NetworkBoundaryGuard.is_host_allowed("https://evil-hacker.com/exfil") is False
assert NetworkBoundaryGuard.is_host_allowed("http://internal-artifactory.local:8081") is False
with pytest.raises(NetworkBoundaryViolationError):
NetworkBoundaryGuard.validate_outbound_url("http://internal-artifactory.local/coordination")
def test_cors_rejects_wildcard_with_credentials(self, client):
response = client.options(
"/api/snapshot",
headers={"Origin": "https://attacker.example.com", "Access-Control-Request-Method": "GET"},
)
# Should NOT return allow-origin: * or echo back attacker origin
allow_origin = response.headers.get("access-control-allow-origin")
assert allow_origin != "*"
assert allow_origin != "https://attacker.example.com"
def test_network_http_warning_on_external_bind(self, client, monkeypatch):
from antigravity_provider.router import settings_service
fake_settings = {
"web_api_host": "0.0.0.0",
"web_api_token": "secret_token_123",
}
monkeypatch.setattr(settings_service, "get_hub_settings", lambda: fake_settings)
res = client.get("/api/settings", headers={"X-Hub-Token": "secret_token_123"})
assert res.status_code == 200
data = res.json()
assert "network_security" in data
assert data["network_security"]["is_external_bind"] is True
assert "открытого HTTP" in data["network_security"]["warning"]
# ═══════════════════════════════════════════════════════════════
# P0-4: Forensic Audit Logging & Secret Scrubbing Tests
# ═══════════════════════════════════════════════════════════════
class TestForensicAuditAndSecretScrubbing:
"""Tests for secret scrubbing and forensic traceability."""
def test_secret_scrubbing_removes_tokens_and_bearer_headers(self):
dirty = {
"user_prompt": "Please summarize this commit",
"auth_header": "Bearer sk-proj-1234567890abcdef123456",
"api_key": "secret_api_key_value",
"nested": {
"token": "ghp_12345678901234567890",
"safe_field": "visible value",
},
}
clean = scrub_secrets(dirty)
assert clean["api_key"] == "***"
assert clean["nested"]["token"] == "***"
assert clean["nested"]["safe_field"] == "visible value"
assert "sk-proj" not in clean["auth_header"]
def test_string_scrubber_masks_inline_secrets(self):
msg = "Request failed with error: Bearer ghp_abcdef1234567890 on host https://api.openai.com?access_token=sk-9876543210"
scrubbed = scrub_string(msg)
assert "ghp_abcdef" not in scrubbed
assert "sk-987654" not in scrubbed
assert "Bearer ***" in scrubbed
assert "access_token=***" in scrubbed
def test_audit_log_captures_actor_and_outcome(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
EventLogService._instance = None
svc = EventLogService.get()
svc.log(
category="account",
message="Удаление профиля grok-worker-1",
actor="reviewer:manual",
action="delete_credentials",
target_profile="grok-worker-1",
target_role="code-reviewer",
outcome="success",
level="warning",
)
events = svc.get_events(limit=5)
assert len(events) >= 1
ev = events[0]
assert ev.actor == "reviewer:manual"
assert ev.action == "delete_credentials"
assert ev.target_profile == "grok-worker-1"
assert ev.target_role == "code-reviewer"
assert ev.outcome == "success"
# ═══════════════════════════════════════════════════════════════
# P0-5: Live Deletion Confirmation & Safe Operations Tests
# ═══════════════════════════════════════════════════════════════
class TestCredentialsDeletionGuard:
"""Tests for explicit credential deletion confirmation and dry-run."""
def test_delete_credentials_dry_run(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from antigravity_provider.router.profile_manager import get_profile_auth_path
auth_p = get_profile_auth_path("grok", "grok-worker-1")
auth_p.parent.mkdir(parents=True, exist_ok=True)
auth_p.write_text("{\"key\": \"val\"}", encoding="utf-8")
res = ActionExecutor.execute(
action="delete_credentials",
data={"provider": "grok", "profile_id": "grok-worker-1", "dry_run": True},
actor="reviewer:manual",
)
assert res["ok"] is True
assert res.get("dry_run") is True
assert auth_p.is_file() # File MUST NOT be deleted during dry-run
def test_delete_credentials_unconfirmed_request(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from antigravity_provider.router.profile_manager import get_profile_auth_path
auth_p = get_profile_auth_path("grok", "grok-worker-1")
auth_p.parent.mkdir(parents=True, exist_ok=True)
auth_p.write_text("{\"key\": \"val\"}", encoding="utf-8")
res = ActionExecutor.execute(
action="delete_credentials",
data={"provider": "grok", "profile_id": "grok-worker-1", "confirmed": False},
actor="agent:developer-1",
)
assert res["ok"] is False
assert res.get("confirmation_required") is True
assert auth_p.is_file() # File MUST NOT be deleted without confirmation
def test_delete_credentials_confirmed_success(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from antigravity_provider.router.profile_manager import get_profile_auth_path
auth_p = get_profile_auth_path("grok", "grok-worker-1")
auth_p.parent.mkdir(parents=True, exist_ok=True)
auth_p.write_text("{\"key\": \"val\"}", encoding="utf-8")
res = ActionExecutor.execute(
action="delete_credentials",
data={"provider": "grok", "profile_id": "grok-worker-1", "confirmed": True},
actor="user:web",
)
assert res["ok"] is True
assert not auth_p.is_file()
@pytest.fixture
def client():
return TestClient(app)
def test_destructive_command_with_tilde_is_rejected(monkeypatch, tmp_path):
"""Команда с тильдой не должна обходить защиту каталогов учётных данных.
validate_command не раскрывал "~" и "$HOME" перед проверкой. Путь
"~/.hermes/agy_profiles" не считался абсолютным, склеивался с каталогом
проекта в путь с буквальным "~" внутри и признавался допустимым.
Измерено на реализации: "rm -rf ~/.hermes/agy_profiles" проходило, а та же
команда с абсолютным путём отклонялась. То есть самый естественный способ
написать опасную команду обходил защиту ровно в том месте, ради которого
она и делалась.
"""
from antigravity_provider.router.security_guard import WorkspaceBoundaryGuard
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
guard = WorkspaceBoundaryGuard()
must_reject = [
"rm -rf ~/.hermes/agy_profiles",
"rm -rf ~/.ssh",
"rm -rf $HOME/.hermes",
]
for cmd in must_reject:
allowed, reason, _alt = guard.validate_command(cmd)
assert not allowed, f"команда с тильдой прошла мимо защиты: {cmd} ({reason})"
# Обычная работа внутри проекта не должна страдать.
allowed, _reason, _alt = guard.validate_command("rm src/temp_file.py")
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})"