hermes-hub/src/antigravity_provider/router/security_guard.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

701 lines
32 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.

"""Security perimeter, boundary enforcement, credential protection, and forensic guards for Hermes Hub."""
from __future__ import annotations
import os
import re
import shlex
import shutil
import tempfile
import urllib.parse
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
from antigravity_provider import paths
# ═══════════════════════════════════════════════════════════════
# Exceptions
# ═══════════════════════════════════════════════════════════════
class SecurityViolationError(Exception):
"""Base exception for security perimeter violations."""
def __init__(self, message: str, safe_alternative: Optional[str] = None, violation_type: str = "general"):
super().__init__(message)
self.message = message
self.safe_alternative = safe_alternative
self.violation_type = violation_type
class BoundaryViolationError(SecurityViolationError):
"""Raised when an operation attempts to access or mutate paths outside allowed workspace."""
def __init__(self, message: str, safe_alternative: Optional[str] = None):
super().__init__(message, safe_alternative=safe_alternative, violation_type="boundary")
class CredentialProtectionError(SecurityViolationError):
"""Raised when an operation attempts to directly delete or corrupt protected credential files."""
def __init__(self, message: str, safe_alternative: Optional[str] = None):
super().__init__(message, safe_alternative=safe_alternative, violation_type="credentials")
class NetworkBoundaryViolationError(SecurityViolationError):
"""Raised when an outbound network request targets a host not in the allowed destination whitelist."""
def __init__(self, message: str, safe_alternative: Optional[str] = None):
super().__init__(message, safe_alternative=safe_alternative, violation_type="network")
# ═══════════════════════════════════════════════════════════════
# Secret Scrubbing Utilities
# ═══════════════════════════════════════════════════════════════
BLOCKED_KEY_SUBSTRINGS: tuple[str, ...] = (
"api_key",
"token",
"secret",
"password",
"client_secret",
"refresh_token",
"access_token",
"private_key",
"jwt",
"auth_token",
"credential",
"bearer",
)
AUTH_BEARER_PATTERN = re.compile(r"Bearer\s+[A-Za-z0-9._~+/-]+", re.IGNORECASE)
ACCESS_TOKEN_QUERY_PATTERN = re.compile(r"(access_token=)[^&]+", re.IGNORECASE)
API_KEY_HEADER_PATTERN = re.compile(r"([A-Za-z0-9_-]*(?:api[_-]?key|token|secret|password)\s*[:=]\s*)[A-Za-z0-9._~+/-]+", re.IGNORECASE)
GENERIC_SECRET_PATTERNS = [
re.compile(r"sk-[A-Za-z0-9_-]{10,}", re.IGNORECASE),
re.compile(r"gh[opsu]_[A-Za-z0-9_-]{10,}", re.IGNORECASE),
re.compile(r"xox[baprs]-[A-Za-z0-9_-]{10,}", re.IGNORECASE),
re.compile(r"eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9._~+/-]{10,}", re.IGNORECASE),
]
def scrub_secrets(data: Any) -> Any:
"""Recursively scrub credentials, API keys, and authorization tokens from strings, dictionaries, and collections."""
if isinstance(data, dict):
result = {}
for k, v in data.items():
k_lower = str(k).lower()
if any(sub in k_lower for sub in BLOCKED_KEY_SUBSTRINGS):
result[k] = "***"
elif isinstance(v, (dict, list, tuple, set)):
result[k] = scrub_secrets(v)
elif isinstance(v, str):
result[k] = scrub_string(v)
else:
result[k] = v
return result
elif isinstance(data, list):
return [scrub_secrets(item) for item in data]
elif isinstance(data, tuple):
return tuple(scrub_secrets(item) for item in data)
elif isinstance(data, set):
return {scrub_secrets(item) for item in data}
elif isinstance(data, str):
return scrub_string(data)
return data
def scrub_string(text: str) -> str:
"""Mask credentials, bearer tokens, query parameters, and API keys within arbitrary text strings."""
if not text or not isinstance(text, str):
return str(text or "")
s = AUTH_BEARER_PATTERN.sub("Bearer ***", text)
s = ACCESS_TOKEN_QUERY_PATTERN.sub(r"\1***", s)
s = API_KEY_HEADER_PATTERN.sub(r"\1***", s)
for pat in GENERIC_SECRET_PATTERNS:
s = pat.sub("***", s)
return s
# ═══════════════════════════════════════════════════════════════
# P0-1: Workspace Boundary & Destructive Operations Guard
# ═══════════════════════════════════════════════════════════════
DESTRUCTIVE_COMMAND_NAMES: Set[str] = {
"rm",
"rmdir",
"unlink",
"del",
"erase",
"rd",
"remove-item",
"ri",
"trash-put",
"srm",
"shred",
}
# ── Единый конвейер разбора пути ────────────────────────────────
#
# Граница вокруг агентских 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:
"""Enforces explicit workspace boundaries, defends credential directories, and inspects destructive operations."""
def __init__(self, additional_allowed_roots: Optional[List[Path]] = None):
self._custom_roots: List[Path] = [r.resolve() for r in (additional_allowed_roots or [])]
def get_allowed_roots(self) -> List[Path]:
"""Return the canonical list of allowed roots (Project Root, HERMES_HOME, explicit allowed roots)."""
roots: List[Path] = []
try:
repo_root = paths.get_repo_root().resolve()
roots.append(repo_root)
except Exception:
pass
try:
hermes_home = paths.get_hermes_home().resolve()
roots.append(hermes_home)
except Exception:
pass
roots.extend(self._custom_roots)
# Deduplicate while preserving order
deduped = []
seen = set()
for r in roots:
resolved = r.resolve()
if resolved not in seen:
seen.add(resolved)
deduped.append(resolved)
return deduped
def get_forbidden_paths(self) -> List[Path]:
"""Return unconditionally protected paths (Credentials, SSH, Core Hub Configs)."""
forbidden = []
try:
hermes_home = paths.get_hermes_home().resolve()
# Credential pools
forbidden.append((hermes_home / "agy_profiles").resolve())
forbidden.append((hermes_home / "codex_profiles").resolve())
forbidden.append((hermes_home / "opencode_profiles").resolve())
forbidden.append((hermes_home / "claude_profiles").resolve())
forbidden.append((hermes_home / "grok_profiles").resolve())
forbidden.append((hermes_home / "local_profiles").resolve())
# Sensitive config files
forbidden.append((hermes_home / "auth.json").resolve())
forbidden.append((hermes_home / "hub_settings.json").resolve())
forbidden.append((hermes_home / "router_profiles.yaml").resolve())
except Exception:
pass
# User SSH directory
try:
ssh_dir = (Path.home() / ".ssh").resolve()
forbidden.append(ssh_dir)
except Exception:
pass
# User global credentials
try:
user_agy = (Path.home() / ".hermes" / "agy_profiles").resolve()
if user_agy not in forbidden:
forbidden.append(user_agy)
except Exception:
pass
return forbidden
def is_inside_allowed_root(self, path: Path | str) -> bool:
"""Check whether the given path resolves within any allowed root."""
try:
target, resolve_error = canonical_path(path)
if target is None:
# Путь не разрешается — считать его находящимся внутри
# разрешённого корня нельзя.
return False
for root in self.get_allowed_roots():
try:
target.relative_to(root)
return True
except ValueError:
continue
return False
except Exception:
return False
def is_forbidden_path(self, path: Path | str) -> Tuple[bool, Optional[str]]:
"""Check whether the path touches an unconditionally protected directory or file."""
try:
target, resolve_error = canonical_path(path)
if target is None:
return True, resolve_error
# 1. Exact match or child of forbidden directory
for fpath in self.get_forbidden_paths():
if target == fpath:
return True, f"Путь {target} является защищённым системным ресурсом"
try:
target.relative_to(fpath)
return True, f"Путь {target} находится внутри защищённого каталога учётных данных {fpath}"
except ValueError:
continue
# 2. Match critical filenames
target_name = target.name.lower()
if target_name in {"auth.json", "hub_settings.json", "id_rsa", "id_ed25519", "known_hosts"}:
return True, f"Файл {target_name} является защищённым системным файлом"
# 3. Match .git core internals
for part in target.parts:
if part.lower() == ".git":
# Disallow deleting/modifying .git root, hooks, objects, or config
if target_name in {"config", "head", "index"} or "objects" in target.parts or "hooks" in target.parts:
return True, "Прямое разрушение служебных файлов git (.git/) запрещено"
return False, None
except Exception as exc:
return True, f"Ошибка разрешения пути: {exc}"
def validate_path(self, path: Path | str, operation: str = "read") -> Tuple[bool, str, Optional[str]]:
"""Validate whether an operation on path is permitted.
Returns: (is_allowed: bool, reason: str, safe_alternative: Optional[str])
"""
target, resolve_error = canonical_path(path)
if target is None:
return False, f"Недопустимый путь: {resolve_error}", "Укажите путь внутри проекта явно, без нераскрытых переменных"
# Check unconditional forbidden paths for mutating/deleting operations
if operation in {"delete", "write", "truncate", "move"}:
is_forbidden, forbidden_reason = self.is_forbidden_path(target)
if is_forbidden:
alt = (
"Для удаления аккаунта используйте штатное действие 'delete_credentials' с подтверждением"
if "agy_profiles" in str(target) or "auth" in str(target)
else "Выполняйте операцию только в рабочей области проекта"
)
return False, f"Запрещённая операция над защищённым ресурсом: {forbidden_reason}", alt
# Check boundary containment
if not self.is_inside_allowed_root(target):
roots_display = ", ".join(str(r) for r in self.get_allowed_roots())
return (
False,
f"Путь '{target}' находится за пределами разрешённых рабочих областей: [{roots_display}]",
"Переместите целевой файл в каталог проекта или рабочую область агента",
)
return True, "OK", None
def validate_command(self, cmd_line: str | List[str], cwd: Optional[Path | str] = None) -> Tuple[bool, str, Optional[str]]:
"""Inspect and classify a shell command line for destructive or boundary-violating operations.
Returns: (is_allowed: bool, reason: str, safe_alternative: Optional[str])
"""
if not cmd_line:
return True, "OK", None
# Parse command tokens
if isinstance(cmd_line, list):
tokens = list(cmd_line)
windows_dialect = any(looks_like_windows_dialect(str(t)) for t in tokens)
else:
# Диалект берётся из самой команды: строку в записи cmd нельзя
# разбирать posix-правилами — shlex съест "\\" как экранирование,
# и разделитель пути исчезнет ещё до проверки.
windows_dialect = looks_like_windows_dialect(cmd_line)
try:
tokens = shlex.split(cmd_line, posix=not windows_dialect)
except Exception:
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:
return True, "OK", None
base_cwd = Path(cwd or paths.get_repo_root()).resolve()
cmd_name = Path(tokens[0]).name.lower()
if cmd_name.endswith(".exe"):
cmd_name = cmd_name[:-4]
# 1. Check destructive command names
if cmd_name in DESTRUCTIVE_COMMAND_NAMES:
# Extract target arguments (skip flags starting with - or /)
targets = []
for arg in tokens[1:]:
if arg.startswith("-") or (windows_dialect and arg.startswith("/") and len(arg) == 2):
continue
targets.append(arg)
if not targets:
# If no targets specified (e.g. interactive rm), check cwd
ok, reason, alt = self.validate_path(base_cwd, operation="delete")
if not ok:
return False, f"Команда {cmd_name} запущена в недопустимом каталоге: {reason}", alt
else:
for target_arg in targets:
# Тильда и переменные окружения раскрываются ДО проверки, и
# одинаково для обоих диалектов.
#
# Без этого "rm -rf ~/.hermes/agy_profiles" не считался
# абсолютным путём, склеивался с каталогом проекта в путь с
# буквальным "~" внутри и признавался допустимым; а
# "rm -rf $HOME/.hermes" ровно так же проходил на Windows,
# где переменной HOME в окружении нет. Самый естественный
# способ написать опасную команду обходил защиту ровно там,
# ради чего она и делалась — на каталоге учётных данных.
target_path, resolve_error = canonical_path(target_arg, base_cwd=base_cwd)
if target_path is None:
return (
False,
f"Команда '{cmd_name}' обращается к непроверяемому пути: {resolve_error}",
"Укажите путь внутри проекта явно, без нераскрытых переменных",
)
ok, reason, alt = self.validate_path(target_path, operation="delete")
if not ok:
return False, f"Команда '{cmd_name}' пытается удалить недопустимый путь '{target_arg}': {reason}", alt
# 2. Check git clean -fdx / git reset --hard targets
if cmd_name == "git":
subcmd = tokens[1].lower() if len(tokens) > 1 else ""
if subcmd == "clean" and any("-f" in a or "-x" in a or "-d" in a for a in tokens[2:]):
# Git clean inside workspace is allowed only if CWD is strictly within project root
ok, reason, alt = self.validate_path(base_cwd, operation="delete")
if not ok:
return False, f"git clean запущен вне проекта: {reason}", alt
return True, "OK", None
def dry_run_deletion(self, target_paths: List[Path | str]) -> Dict[str, Any]:
"""Perform a safe dry-run assessment of a pending deletion, returning affected files and risk analysis."""
files_to_delete: List[Dict[str, Any]] = []
dirs_to_delete: List[Dict[str, Any]] = []
total_bytes = 0
risk_level = "low"
has_violations = False
reasons: List[str] = []
for p_raw in target_paths:
p = Path(p_raw).expanduser().resolve()
ok, reason, alt = self.validate_path(p, operation="delete")
if not ok:
has_violations = True
reasons.append(reason)
risk_level = "critical"
continue
if p.is_file():
try:
sz = p.stat().st_size
except OSError:
sz = 0
total_bytes += sz
files_to_delete.append({"path": str(p), "size_bytes": sz, "type": "file"})
elif p.is_dir():
dir_bytes = 0
file_count = 0
for root, _, files in os.walk(p):
for f in files:
fp = Path(root) / f
try:
fsz = fp.stat().st_size
except OSError:
fsz = 0
dir_bytes += fsz
file_count += 1
total_bytes += dir_bytes
dirs_to_delete.append({"path": str(p), "file_count": file_count, "size_bytes": dir_bytes, "type": "directory"})
if file_count > 10:
risk_level = "medium" if risk_level != "critical" else risk_level
return {
"dry_run": True,
"allowed": not has_violations,
"risk_level": risk_level,
"total_files": len(files_to_delete),
"total_dirs": len(dirs_to_delete),
"total_bytes": total_bytes,
"files": files_to_delete[:100],
"directories": dirs_to_delete,
"violations": reasons,
}
def safe_delete_file(self, path: Path | str, dry_run: bool = False) -> Dict[str, Any]:
"""Safely delete a single file after boundary and credential checks, supporting dry-run."""
target = Path(path).expanduser().resolve()
ok, reason, alt = self.validate_path(target, operation="delete")
if not ok:
raise BoundaryViolationError(reason, safe_alternative=alt)
if dry_run:
return self.dry_run_deletion([target])
if not target.exists():
return {"deleted": False, "reason": "File does not exist", "path": str(target)}
if target.is_dir():
raise BoundaryViolationError(f"Путь '{target}' является директорией, используйте safe_delete_dir")
target.unlink()
return {"deleted": True, "path": str(target)}
def safe_delete_dir(self, path: Path | str, dry_run: bool = False) -> Dict[str, Any]:
"""Safely delete a directory tree after boundary and credential checks, supporting dry-run."""
target = Path(path).expanduser().resolve()
ok, reason, alt = self.validate_path(target, operation="delete")
if not ok:
raise BoundaryViolationError(reason, safe_alternative=alt)
if dry_run:
return self.dry_run_deletion([target])
if not target.exists():
return {"deleted": False, "reason": "Directory does not exist", "path": str(target)}
if not target.is_dir():
raise BoundaryViolationError(f"Путь '{target}' не является директорией")
shutil.rmtree(target)
return {"deleted": True, "path": str(target)}
# ═══════════════════════════════════════════════════════════════
# P0-3: Network Boundary Guard & Whitelist
# ═══════════════════════════════════════════════════════════════
ALLOWED_OUTBOUND_HOSTS: Set[str] = {
# 1. AI Provider APIs
"api.anthropic.com",
"generativelanguage.googleapis.com",
"api.x.ai",
"api.openai.com",
"api.deepseek.com",
"openrouter.ai",
"api.together.xyz",
"api.groq.com",
"api.mistral.ai",
"dashscope.aliyuncs.com",
"open.bigmodel.cn",
"api.moonshot.cn",
# 2. Release & Update Hosts
"api.github.com",
"github.com",
"raw.githubusercontent.com",
"objects.githubusercontent.com",
"github-releases.githubusercontent.com",
# 3. Local LLMs & Diagnostics
"localhost",
"127.0.0.1",
"0.0.0.0",
"::1",
}
class NetworkBoundaryGuard:
"""Enforces explicit network boundaries for all outgoing Hub HTTP/HTTPS requests."""
@classmethod
def is_host_allowed(cls, host_or_url: str) -> bool:
"""Check whether the given hostname or URL target is allowed by the whitelist."""
if not host_or_url:
return False
clean = host_or_url.strip().lower()
if "://" in clean:
parsed = urllib.parse.urlparse(clean)
host = (parsed.hostname or "").lower()
else:
host = clean.split(":")[0].lower()
if not host:
return False
# Exact match
if host in ALLOWED_OUTBOUND_HOSTS:
return True
# Wildcard subdomain match (e.g. *.githubusercontent.com, *.aliyuncs.com, *.googleapis.com)
for allowed in ALLOWED_OUTBOUND_HOSTS:
if allowed.startswith("*."):
suffix = allowed[1:]
if host.endswith(suffix):
return True
elif host.endswith("." + allowed):
# Subdomains of explicitly trusted domains (e.g. download.github.com)
return True
# Local subnet / private IP / loopback allow
if host.startswith("127.") or host.startswith("10.") or host.startswith("192.168."):
return True
return False
@classmethod
def validate_outbound_url(cls, url: str) -> None:
"""Validate destination URL, raising NetworkBoundaryViolationError if host is not permitted."""
if not cls.is_host_allowed(url):
parsed = urllib.parse.urlparse(url)
hostname = parsed.hostname or url
raise NetworkBoundaryViolationError(
f"Сетевое обращение к хосту '{hostname}' заблокировано сетевой границей хаба.",
safe_alternative="Используйте разрешённых провайдеров из белого списка или настройте локальный прокси",
)
# ═══════════════════════════════════════════════════════════════
# P0-2: Agent Workspace Guard & Role Credential Separation
# ═══════════════════════════════════════════════════════════════
class AgentWorkspaceGuard:
"""Provides workspace isolation and scoped credential injection per agent/role."""
@staticmethod
def get_agent_workspace_dir(agent_id: str) -> Path:
"""Return dedicated workspace directory for an agent, ensuring it exists."""
clean_id = re.sub(r"[^a-zA-Z0-9_-]+", "-", str(agent_id).strip()).strip("-").lower()
ws_dir = paths.get_hermes_home() / "workspaces" / f"agent-{clean_id}"
ws_dir.mkdir(parents=True, exist_ok=True)
return ws_dir.resolve()
@staticmethod
def build_agent_subprocess_env(
agent_id: str,
role: str,
assigned_profile_id: Optional[str] = None,
) -> Dict[str, str]:
"""Construct an isolated environment containing ONLY the credentials needed for the agent's assigned role."""
from antigravity_provider.agy_subprocess import build_safe_subprocess_env
from antigravity_provider.router.adapters.antigravity_adapter import get_profile_env_dir
ws_dir = AgentWorkspaceGuard.get_agent_workspace_dir(agent_id)
overrides: Dict[str, str] = {
"HERMES_AGENT_ID": agent_id,
"HERMES_AGENT_ROLE": role,
"HERMES_AGENT_WORKSPACE": str(ws_dir),
}
# Role-scoped profile isolation: inject HOME/USERPROFILE targeting the agent's profile directory
if assigned_profile_id:
profile_dir = get_profile_env_dir(assigned_profile_id)
overrides["HOME"] = str(profile_dir)
overrides["USERPROFILE"] = str(profile_dir)
overrides["HOMEPATH"] = str(profile_dir)
return build_safe_subprocess_env(overrides=overrides)
# Singleton instance
_workspace_guard = WorkspaceBoundaryGuard()
def get_workspace_guard() -> WorkspaceBoundaryGuard:
return _workspace_guard